Ticket #15182: 15182.patch

File 15182.patch, 26.3 KB (added by taylor.smock, 2 years ago)

Add validate command on JOSM command line. Output is line-by-line delimited geojson, for use in MapRoulette (AFAIK, MapRoulette is the only major "random problem" platform).

  • new file src/org/openstreetmap/josm/data/validation/ValidatorCLI.java

    diff --git a/src/org/openstreetmap/josm/data/validation/ValidatorCLI.java b/src/org/openstreetmap/josm/data/validation/ValidatorCLI.java
    new file mode 100644
    index 0000000000..6428bb1ba7
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.data.validation;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5
     6import java.io.File;
     7import java.io.FileOutputStream;
     8import java.io.IOException;
     9import java.io.InputStream;
     10import java.nio.charset.StandardCharsets;
     11import java.nio.file.Files;
     12import java.nio.file.Paths;
     13import java.util.Arrays;
     14import java.util.Collection;
     15import java.util.Locale;
     16import java.util.Optional;
     17import java.util.logging.Level;
     18
     19import org.openstreetmap.josm.cli.CLIModule;
     20import org.openstreetmap.josm.data.osm.DataSet;
     21import org.openstreetmap.josm.data.preferences.JosmBaseDirectories;
     22import org.openstreetmap.josm.data.preferences.JosmUrls;
     23import org.openstreetmap.josm.data.projection.ProjectionRegistry;
     24import org.openstreetmap.josm.data.projection.Projections;
     25import org.openstreetmap.josm.gui.MainApplication;
     26import org.openstreetmap.josm.gui.layer.OsmDataLayer;
     27import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
     28import org.openstreetmap.josm.io.Compression;
     29import org.openstreetmap.josm.io.GeoJSONMapRouletteWriter;
     30import org.openstreetmap.josm.io.IllegalDataException;
     31import org.openstreetmap.josm.io.OsmReader;
     32import org.openstreetmap.josm.spi.preferences.Config;
     33import org.openstreetmap.josm.spi.preferences.MemoryPreferences;
     34import org.openstreetmap.josm.tools.Http1Client;
     35import org.openstreetmap.josm.tools.HttpClient;
     36import org.openstreetmap.josm.tools.JosmRuntimeException;
     37import org.openstreetmap.josm.tools.Logging;
     38import org.openstreetmap.josm.tools.OptionParser;
     39import org.openstreetmap.josm.tools.Stopwatch;
     40import org.openstreetmap.josm.tools.Territories;
     41
     42/**
     43 * Add a validate command to the JOSM command line interface.
     44 * @author Taylor Smock
     45 * @since xxx
     46 */
     47public class ValidatorCLI implements CLIModule {
     48    public static ValidatorCLI INSTANCE = new ValidatorCLI();
     49
     50    /** The input file */
     51    private String input;
     52    /** The output file */
     53    private String output;
     54
     55    /** The log level */
     56    private Level logLevel;
     57
     58    private enum Option {
     59        HELP(false, 'h'),
     60        INPUT(true, 'i'),
     61        OUTPUT(true, 'o'),
     62        DEBUG(false, '*'),
     63        TRACE(false, '*');
     64
     65        private final String name;
     66        private final boolean requiresArgument;
     67        private final char shortOption;
     68        Option(final boolean requiresArgument, final char shortOption) {
     69            this.name = name().toLowerCase(Locale.ROOT).replace('_', '-');
     70            this.requiresArgument = requiresArgument;
     71            this.shortOption = shortOption;
     72        }
     73
     74        /**
     75         * Replies the option name
     76         * @return The option name, in lowercase
     77         */
     78        public String getName() {
     79            return this.name;
     80        }
     81
     82        /**
     83         * Determines if this option requires an argument.
     84         * @return {@code true} if this option requires an argument, {@code false} otherwise
     85         */
     86        public boolean requiresArgument() {
     87            return this.requiresArgument;
     88        }
     89
     90        /**
     91         * Replies the short option (single letter) associated with this option.
     92         * @return the short option or '*' if there is no short option
     93         */
     94        public char getShortOption() {
     95            return this.shortOption;
     96        }
     97    }
     98
     99    @Override
     100    public String getActionKeyword() {
     101        return "validate";
     102    }
     103
     104    @Override
     105    public void processArguments(final String[] argArray) {
     106        try {
     107            Logging.setLogLevel(Level.INFO);
     108            this.parseArguments(argArray);
     109            this.initialize();
     110            final Stopwatch stopwatch = Stopwatch.createStarted();
     111            final String task = tr("Validating {0}, saving output to {1}", this.input, this.output);
     112            Logging.info(task);
     113            OsmValidator.initializeTests();
     114            final DataSet dataSet = this.loadDataSet();
     115            // Adding a layer specifically for UntaggedWay.class (it wants the active dataset, which is from the
     116            // current layer).
     117            MainApplication.getLayerManager().addLayer(new OsmDataLayer(dataSet, this.input, new File(this.input)));
     118            Collection<Test> tests = OsmValidator.getEnabledTests(false);
     119            tests.parallelStream().forEach(test -> test.startTest(NullProgressMonitor.INSTANCE));
     120            tests.parallelStream().forEach(test -> test.visit(dataSet.allPrimitives()));
     121            tests.parallelStream().forEach(Test::endTest);
     122            if (Files.isRegularFile(Paths.get(this.output)) && !Files.deleteIfExists(Paths.get(this.output))) {
     123                Logging.error("Could not delete {0}, attempting to append", this.output);
     124            }
     125            GeoJSONMapRouletteWriter geoJSONMapRouletteWriter = new GeoJSONMapRouletteWriter(dataSet);
     126            try (FileOutputStream fileOutputStream = new FileOutputStream(this.output)) {
     127                for (Test test : tests) {
     128                    test.getErrors().stream().map(geoJSONMapRouletteWriter::write)
     129                            .filter(Optional::isPresent).map(Optional::get)
     130                            .map(jsonObject -> jsonObject.toString().getBytes(StandardCharsets.UTF_8)).forEach(bytes -> {
     131                                try {
     132                                    // Write the ASCII Record Separator character
     133                                    fileOutputStream.write(0x1e);
     134                                    fileOutputStream.write(bytes);
     135                                    // Write the ASCII Line Feed character
     136                                    fileOutputStream.write(0x0a);
     137                                } catch (IOException e) {
     138                                    throw new JosmRuntimeException(e);
     139                                }
     140                            });
     141                }
     142            }
     143            Logging.info(stopwatch.toString(task));
     144        } catch (JosmRuntimeException | IllegalArgumentException | IllegalDataException | IOException e) {
     145            Logging.info(e);
     146            System.exit(1);
     147        }
     148        System.exit(0);
     149    }
     150
     151    /**
     152     * Initialize everything that might be needed
     153     *
     154     * Arguments may need to be parsed first.
     155     */
     156    void initialize() {
     157        Logging.setLogLevel(this.logLevel);
     158        HttpClient.setFactory(Http1Client::new);
     159        Config.setBaseDirectoriesProvider(JosmBaseDirectories.getInstance()); // for right-left-hand traffic cache file
     160        Config.setPreferencesInstance(new MemoryPreferences());
     161        Config.setUrlsProvider(JosmUrls.getInstance());
     162        ProjectionRegistry.setProjection(Projections.getProjectionByCode("epsg:3857".toUpperCase(Locale.ROOT)));
     163
     164        Territories.initializeInternalData();
     165        OsmValidator.initialize();
     166    }
     167
     168    /**
     169     * Parse command line arguments and do some low-level error checking.
     170     * @param argArray the arguments array
     171     */
     172    void parseArguments(String[] argArray) {
     173        Logging.setLogLevel(Level.INFO);
     174
     175        OptionParser parser = new OptionParser("JOSM rendering");
     176        for (Option o : Option.values()) {
     177            if (o.requiresArgument()) {
     178                parser.addArgumentParameter(o.getName(),
     179                        OptionParser.OptionCount.REQUIRED,
     180                        arg -> handleOption(o, arg));
     181            } else {
     182                parser.addFlagParameter(o.getName(), () -> handleOption(o));
     183            }
     184            if (o.getShortOption() != '*') {
     185                parser.addShortAlias(o.getName(), Character.toString(o.getShortOption()));
     186            }
     187        }
     188        parser.parseOptionsOrExit(Arrays.asList(argArray));
     189    }
     190
     191    private void handleOption(final Option option) {
     192        switch (option) {
     193        case HELP:
     194            showHelp();
     195            System.exit(0);
     196            break;
     197        case DEBUG:
     198            this.logLevel = Logging.LEVEL_DEBUG;
     199            break;
     200        case TRACE:
     201            this.logLevel = Logging.LEVEL_TRACE;
     202            break;
     203        default:
     204            throw new AssertionError("Unexpected option: " + option);
     205        }
     206    }
     207
     208    private void handleOption(final Option option, final String argument) {
     209        switch (option) {
     210        case INPUT:
     211            this.input = argument;
     212            break;
     213        case OUTPUT:
     214            this.output = argument;
     215            break;
     216        default:
     217            throw new AssertionError("Unexpected option: " + option);
     218        }
     219    }
     220
     221    private DataSet loadDataSet() throws IOException, IllegalDataException {
     222        if (this.input == null) {
     223            throw new IllegalArgumentException(tr("Missing argument - input data file ({0})", "--input|-i"));
     224        }
     225        try (InputStream inputStream = Compression.getUncompressedFileInputStream(Paths.get(this.input))) {
     226            return OsmReader.parseDataSet(inputStream, null);
     227        } catch (IllegalDataException e) {
     228            throw new IllegalDataException(tr("In .osm data file ''{0}'' - ", this.input) + e.getMessage(), e);
     229        }
     230    }
     231
     232    private static void showHelp() {
     233        System.out.println(getHelp());
     234    }
     235
     236    private static String getHelp() {
     237        return tr("JOSM Validation command line interface") + "\n\n" +
     238                tr("Usage") + ":\n" +
     239                "\tjava -jar josm.jar validate <options>\n\n" +
     240                tr("Description") + ":\n" +
     241                tr("Validates data and saves the result to a file.") + "\n\n"+
     242                tr("Options") + ":\n" +
     243                "\t--help|-h                 " + tr("Show this help") + "\n" +
     244                "\t--input|-i <file>         " + tr("Input data file name (.osm)") + "\n" +
     245                "\t--output|-o <file>        " + tr("Output data file name (.geojson, line-by-line delimited for MapRoulette)");
     246    }
     247}
  • src/org/openstreetmap/josm/gui/MainApplication.java

    diff --git a/src/org/openstreetmap/josm/gui/MainApplication.java b/src/org/openstreetmap/josm/gui/MainApplication.java
    index a4572b8b9b..438efd8a07 100644
    a b import org.openstreetmap.josm.data.projection.ProjectionRegistry;  
    9898import org.openstreetmap.josm.data.projection.datum.NTV2GridShiftFileSource;
    9999import org.openstreetmap.josm.data.projection.datum.NTV2GridShiftFileWrapper;
    100100import org.openstreetmap.josm.data.projection.datum.NTV2Proj4DirGridShiftFileSource;
     101import org.openstreetmap.josm.data.validation.ValidatorCLI;
    101102import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker;
    102103import org.openstreetmap.josm.gui.ProgramArguments.Option;
    103104import org.openstreetmap.josm.gui.SplashScreen.SplashProgressMonitor;
    public class MainApplication {  
    311312        registerCLIModule(JOSM_CLI_MODULE);
    312313        registerCLIModule(ProjectionCLI.INSTANCE);
    313314        registerCLIModule(RenderingCLI.INSTANCE);
     315        registerCLIModule(ValidatorCLI.INSTANCE);
    314316    }
    315317
    316318    /**
    public class MainApplication {  
    660662                tr("commands")+":\n"+
    661663                "\trunjosm     "+tr("launch JOSM (default, performed when no command is specified)")+'\n'+
    662664                "\trender      "+tr("render data and save the result to an image file")+'\n'+
    663                 "\tproject     "+tr("convert coordinates from one coordinate reference system to another")+"\n\n"+
     665                "\tproject     " + tr("convert coordinates from one coordinate reference system to another")+ '\n' +
     666                "\tvalidate    " + tr("validate data") + "\n\n" +
    664667                tr("For details on the {0} and {1} commands, run them with the {2} option.", "render", "project", "--help")+'\n'+
    665668                tr("The remainder of this help page documents the {0} command.", "runjosm")+"\n\n"+
    666669                tr("options")+":\n"+
  • src/org/openstreetmap/josm/gui/util/GuiHelper.java

    diff --git a/src/org/openstreetmap/josm/gui/util/GuiHelper.java b/src/org/openstreetmap/josm/gui/util/GuiHelper.java
    index 89ab9f84b0..4c17334af2 100644
    a b public final class GuiHelper {  
    288288     * @since 10271
    289289     */
    290290    public static void assertCallFromEdt() {
    291         if (!SwingUtilities.isEventDispatchThread()) {
     291        if (!SwingUtilities.isEventDispatchThread() && !GraphicsEnvironment.isHeadless()) {
    292292            throw new IllegalStateException(
    293293                    "Needs to be called from the EDT thread, not from " + Thread.currentThread().getName());
    294294        }
  • new file src/org/openstreetmap/josm/io/GeoJSONMapRouletteWriter.java

    diff --git a/src/org/openstreetmap/josm/io/GeoJSONMapRouletteWriter.java b/src/org/openstreetmap/josm/io/GeoJSONMapRouletteWriter.java
    new file mode 100644
    index 0000000000..d61be93cd8
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.io;
     3
     4import java.util.Objects;
     5import java.util.Optional;
     6
     7import javax.json.Json;
     8import javax.json.JsonArray;
     9import javax.json.JsonArrayBuilder;
     10import javax.json.JsonObject;
     11import javax.json.JsonObjectBuilder;
     12import javax.json.JsonValue;
     13
     14import org.openstreetmap.josm.data.osm.DataSet;
     15import org.openstreetmap.josm.data.osm.OsmPrimitive;
     16import org.openstreetmap.josm.data.osm.WaySegment;
     17import org.openstreetmap.josm.data.validation.TestError;
     18import org.openstreetmap.josm.tools.Logging;
     19
     20/**
     21 * Convert {@link TestError} to MapRoulette Tasks
     22 * @author Taylor Smock
     23 * @since xxx
     24 */
     25public class GeoJSONMapRouletteWriter extends GeoJSONWriter {
     26
     27    /**
     28     * Constructs a new {@code GeoJSONWriter}.
     29     * @param ds The originating OSM dataset
     30     */
     31    public GeoJSONMapRouletteWriter(DataSet ds) {
     32        super(ds);
     33        super.setOptions(Options.RIGHT_HAND_RULE, Options.WRITE_OSM_INFORMATION);
     34    }
     35
     36    /**
     37     * Convert a test error to a string
     38     * @param testError The test error to convert
     39     * @return The MapRoulette challenge object
     40     */
     41    public Optional<JsonObject> write(final TestError testError) {
     42        final JsonObjectBuilder jsonObjectBuilder = Json.createObjectBuilder();
     43        final JsonArrayBuilder featuresBuilder = Json.createArrayBuilder();
     44        final JsonObjectBuilder propertiesBuilder = Json.createObjectBuilder();
     45        propertiesBuilder.add("message", testError.getMessage());
     46        Optional.ofNullable(testError.getDescription()).ifPresent(description -> propertiesBuilder.add("description", description));
     47        propertiesBuilder.add("code", testError.getCode());
     48        propertiesBuilder.add("fixable", testError.isFixable());
     49        propertiesBuilder.add("severity", testError.getSeverity().toString());
     50        propertiesBuilder.add("severityInteger", testError.getSeverity().getLevel());
     51        propertiesBuilder.add("test", testError.getTester().getName());
     52        testError.getPrimitives().forEach(primitive -> super.appendPrimitive(primitive, featuresBuilder));
     53        testError.getHighlighted().stream().map(p -> {
     54            if (p instanceof OsmPrimitive) {
     55                return p;
     56            } else if (p instanceof WaySegment) {
     57                return ((WaySegment) p).toWay();
     58            }
     59            Logging.error("Could not convert {0} to an OsmPrimitive", p);
     60            return null;
     61        }).filter(Objects::nonNull).map(OsmPrimitive.class::cast)
     62                .forEach(primitive -> super.appendPrimitive(primitive, featuresBuilder));
     63        final JsonArray featureArray = featuresBuilder.build();
     64        final JsonArrayBuilder featuresMessageBuilder = Json.createArrayBuilder();
     65        if (featureArray.isEmpty()) {
     66            Logging.error("Could not generate task for {0}", testError.getMessage());
     67            return Optional.empty();
     68        }
     69        JsonObject primitive = featureArray.getJsonObject(0);
     70        JsonObjectBuilder replacementPrimitive = Json.createObjectBuilder(primitive);
     71        final JsonObjectBuilder properties;
     72        if (primitive.containsKey("properties") && primitive.get("properties").getValueType() == JsonValue.ValueType.OBJECT) {
     73            properties = Json.createObjectBuilder(primitive.getJsonObject("properties"));
     74        } else {
     75            properties = Json.createObjectBuilder();
     76        }
     77        properties.addAll(propertiesBuilder);
     78        replacementPrimitive.add("properties", properties);
     79        featuresMessageBuilder.add(replacementPrimitive);
     80        for (int i = 1; i < featureArray.size(); i++) {
     81            featuresMessageBuilder.add(featureArray.get(i));
     82        }
     83        // For now, don't add any cooperativeWork objects, as JOSM should be able to find the fixes.
     84        // This should change if the ValidatorCLI can use plugins (especially those introducing external data, like
     85        // the ElevationProfile plugin (which provides elevation data)).
     86        jsonObjectBuilder.add("type", "FeatureCollection");
     87        jsonObjectBuilder.add("features", featuresMessageBuilder);
     88        return Optional.of(jsonObjectBuilder.build());
     89    }
     90}
  • src/org/openstreetmap/josm/io/GeoJSONWriter.java

    diff --git a/src/org/openstreetmap/josm/io/GeoJSONWriter.java b/src/org/openstreetmap/josm/io/GeoJSONWriter.java
    index d2eb644846..cb87f7b767 100644
    a b import java.io.StringWriter;  
    66import java.io.Writer;
    77import java.math.BigDecimal;
    88import java.math.RoundingMode;
     9import java.time.Instant;
     10import java.util.ArrayList;
     11import java.util.Arrays;
    912import java.util.Collection;
    1013import java.util.Collections;
     14import java.util.EnumSet;
    1115import java.util.HashSet;
    1216import java.util.Iterator;
    1317import java.util.List;
    1418import java.util.Map;
    15 import java.util.Map.Entry;
    1619import java.util.Set;
     20import java.util.stream.Collectors;
    1721import java.util.stream.Stream;
    1822
    1923import javax.json.Json;
    import org.openstreetmap.josm.data.osm.MultipolygonBuilder.JoinedPolygon;  
    3539import org.openstreetmap.josm.data.osm.Node;
    3640import org.openstreetmap.josm.data.osm.OsmPrimitive;
    3741import org.openstreetmap.josm.data.osm.Relation;
     42import org.openstreetmap.josm.data.osm.RelationMember;
    3843import org.openstreetmap.josm.data.osm.Way;
    3944import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor;
    4045import org.openstreetmap.josm.data.preferences.BooleanProperty;
    4146import org.openstreetmap.josm.data.projection.Projection;
    4247import org.openstreetmap.josm.data.projection.Projections;
    4348import org.openstreetmap.josm.gui.mappaint.ElemStyles;
     49import org.openstreetmap.josm.tools.Geometry;
    4450import org.openstreetmap.josm.tools.Logging;
    4551import org.openstreetmap.josm.tools.Pair;
     52import org.openstreetmap.josm.tools.Utils;
    4653
    4754/**
    4855 * Writes OSM data as a GeoJSON string, using JSR 353: Java API for JSON Processing (JSON-P).
    import org.openstreetmap.josm.tools.Pair;  
    5158 */
    5259public class GeoJSONWriter {
    5360
     61    enum Options {
     62        /** If using the right hand rule, we have to ensure that the "right" side is the interior of the object. */
     63        RIGHT_HAND_RULE,
     64        /** Write OSM information to the feature properties field. This tries to follow the Overpass turbo format. */
     65        WRITE_OSM_INFORMATION;
     66    }
     67
    5468    private final DataSet data;
    55     private final Projection projection;
     69    private static final Projection projection = Projections.getProjectionByCode("EPSG:4326"); // WGS 84
    5670    private static final BooleanProperty SKIP_EMPTY_NODES = new BooleanProperty("geojson.export.skip-empty-nodes", true);
    5771    private static final BooleanProperty UNTAGGED_CLOSED_IS_POLYGON = new BooleanProperty("geojson.export.untagged-closed-is-polygon", false);
    5872    private static final Set<Way> processedMultipolygonWays = new HashSet<>();
     73    private EnumSet<Options> options = EnumSet.noneOf(Options.class);
    5974
    6075    /**
    6176     * This is used to determine that a tag should be interpreted as a json
    public class GeoJSONWriter {  
    7792     */
    7893    public GeoJSONWriter(DataSet ds) {
    7994        this.data = ds;
    80         this.projection = Projections.getProjectionByCode("EPSG:4326"); // WGS 84
     95    }
     96
     97    /**
     98     * Set the options for this writer. See {@link Options}.
     99     * @param options The options to set.
     100     */
     101    void setOptions(final Options... options) {
     102        this.options.clear();
     103        this.options.addAll(Arrays.asList(options));
    81104    }
    82105
    83106    /**
    public class GeoJSONWriter {  
    117140        }
    118141    }
    119142
     143    /**
     144     * Convert a primitive to a json object
     145     */
    120146    private class GeometryPrimitiveVisitor implements OsmPrimitiveVisitor {
    121147
    122148        private final JsonObjectBuilder geomObj;
    public class GeoJSONWriter {  
    141167                    // no need to write this object again
    142168                    return;
    143169                }
    144                 final JsonArrayBuilder array = getCoorsArray(w.getNodes());
    145170                boolean writeAsPolygon = w.isClosed() && ((!w.isTagged() && UNTAGGED_CLOSED_IS_POLYGON.get())
    146171                        || ElemStyles.hasAreaElemStyle(w, false));
     172                final List<Node> nodes = w.getNodes();
     173                if (writeAsPolygon && options.contains(Options.RIGHT_HAND_RULE) && Geometry.isClockwise(nodes)) {
     174                    Collections.reverse(nodes);
     175                }
     176                final JsonArrayBuilder array = getCoorsArray(nodes);
    147177                if (writeAsPolygon) {
    148178                    geomObj.add("type", "Polygon");
    149179                    geomObj.add("coordinates", Json.createArrayBuilder().add(array));
    public class GeoJSONWriter {  
    162192            try {
    163193                final Pair<List<JoinedPolygon>, List<JoinedPolygon>> mp = MultipolygonBuilder.joinWays(r);
    164194                final JsonArrayBuilder polygon = Json.createArrayBuilder();
    165                 Stream.concat(mp.a.stream(), mp.b.stream())
     195                final Stream<List<Node>> outer = mp.a.stream().map(JoinedPolygon::getNodes).map(nodes -> {
     196                    final ArrayList<Node> tempNodes = new ArrayList<>(nodes);
     197                    tempNodes.add(tempNodes.get(0));
     198                    if (options.contains(Options.RIGHT_HAND_RULE) && Geometry.isClockwise(tempNodes)) {
     199                        Collections.reverse(nodes);
     200                    }
     201                    return nodes;
     202                });
     203                final Stream<List<Node>> inner = mp.b.stream().map(JoinedPolygon::getNodes).map(nodes -> {
     204                    final ArrayList<Node> tempNodes = new ArrayList<>(nodes);
     205                    tempNodes.add(tempNodes.get(0));
     206                    if (options.contains(Options.RIGHT_HAND_RULE) && !Geometry.isClockwise(tempNodes)) {
     207                        Collections.reverse(nodes);
     208                    }
     209                    return nodes;
     210                });
     211                Stream.concat(outer, inner)
    166212                        .map(p -> {
    167                             JsonArrayBuilder array = getCoorsArray(p.getNodes());
    168                             LatLon ll = p.getNodes().get(0).getCoor();
     213                            JsonArrayBuilder array = getCoorsArray(p);
     214                            LatLon ll = p.get(0).getCoor();
    169215                            // since first node is not duplicated as last node
    170216                            return ll != null ? array.add(getCoorArray(null, ll)) : array;
    171217                            })
    public class GeoJSONWriter {  
    210256
    211257        // Properties
    212258        final JsonObjectBuilder propObj = Json.createObjectBuilder();
    213         for (Entry<String, String> t : p.getKeys().entrySet()) {
    214             propObj.add(t.getKey(), convertValueToJson(t.getValue()));
     259        for (Map.Entry<String, String> t : p.getKeys().entrySet()) {
     260            // If writing OSM information, follow Overpass syntax (escape `@` with another `@`)
     261            final String key = options.contains(Options.WRITE_OSM_INFORMATION) && t.getKey().startsWith("@")
     262                    ? '@' + t.getKey() : t.getKey();
     263            propObj.add(key, convertValueToJson(t.getValue()));
     264        }
     265        if (options.contains(Options.WRITE_OSM_INFORMATION)) {
     266            // Use the same format as Overpass
     267            propObj.add("@id", p.getPrimitiveId().getType().getAPIName() + '/' + p.getId()); // type/id
     268            if (!p.isNew()) {
     269                propObj.add("@timestamp", Instant.ofEpochSecond(p.getRawTimestamp()).toString());
     270                propObj.add("@version", Integer.toString(p.getVersion()));
     271                propObj.add("@changeset", Long.toString(p.getChangesetId()));
     272            }
     273            if (p.getUser() != null) {
     274                propObj.add("@user", p.getUser().getName());
     275                propObj.add("@uid", p.getUser().getId());
     276            }
     277            if (options.contains(Options.WRITE_OSM_INFORMATION) && p.getReferrers(true).stream().anyMatch(Relation.class::isInstance)) {
     278                final JsonArrayBuilder jsonArrayBuilder = Json.createArrayBuilder();
     279                for (Relation relation : Utils.filteredCollection(p.getReferrers(), Relation.class)) {
     280                    final JsonObjectBuilder relationObject = Json.createObjectBuilder();
     281                    relationObject.add("rel", relation.getId());
     282                    Collection<RelationMember> members = relation.getMembersFor(Collections.singleton(p));
     283                    // Each role is a separate object in overpass-turbo geojson export. For now, just concat them.
     284                    relationObject.add("role",
     285                            members.stream().map(RelationMember::getRole).collect(Collectors.joining(";")));
     286                    final JsonObjectBuilder relationKeys = Json.createObjectBuilder();
     287                    // Uncertain if the @relation reltags need to be @ escaped. I don't think so, as example output
     288                    // didn't have any metadata in it.
     289                    for (Map.Entry<String, String> tag : relation.getKeys().entrySet()) {
     290                        relationKeys.add(tag.getKey(), convertValueToJson(tag.getValue()));
     291                    }
     292                    relationObject.add("reltags", relationKeys);
     293                }
     294                propObj.add("@relations", jsonArrayBuilder);
     295            }
    215296        }
    216297        final JsonObject prop = propObj.build();
    217298