From be292a600b9291c5c71046331a3ad6aabaeb76e0 Mon Sep 17 00:00:00 2001 From: Thomas Rasch Date: Fri, 9 Aug 2024 12:54:21 +0200 Subject: [PATCH] Command line tool options cleanup --- Package.swift | 2 +- README.md | 18 ++++-- Sources/MVTCLI/CLI.swift | 100 +++++++++++++++++++-------------- Sources/MVTCLI/Dump.swift | 34 ++++++++--- Sources/MVTCLI/Export.swift | 48 ++++++++++++---- Sources/MVTCLI/Import.swift | 77 ++++++++++++++++++++----- Sources/MVTCLI/Info.swift | 17 +++++- Sources/MVTCLI/Merge.swift | 61 ++++++++++++++++---- Sources/MVTCLI/Query.swift | 32 +++++++---- Sources/MVTTools/GeoJson.swift | 18 +++++- 10 files changed, 297 insertions(+), 110 deletions(-) diff --git a/Package.swift b/Package.swift index 6a2ebdb..d5dc808 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( name: "mvt-tools", platforms: [ .iOS(.v13), - .macOS(.v10_15), + .macOS(.v13), .tvOS(.v13), .watchOS(.v6), ], diff --git a/README.md b/README.md index 2a584e9..933a80b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Mapbox vector tiles (MVT) reader/writer library for Swift, together with a tool ## Requirements -This package requires Swift 5.10 or higher (at least Xcode 14), and compiles on iOS (\>= iOS 13), macOS (\>= macOS 10.15), tvOS (\>= tvOS 13), watchOS (\>= watchOS 6) as well as Linux. +This package requires Swift 5.10 or higher (at least Xcode 14), and compiles on iOS (\>= iOS 13), macOS (\>= macOS 13), tvOS (\>= tvOS 13), watchOS (\>= watchOS 6) as well as Linux. ## Installation with Swift Package Manager @@ -111,6 +111,12 @@ You can install the command line tool `mvt` either # mvt -h OVERVIEW: A utility for inspecting and working with vector tiles. +The tile coordinate can be extracted from the path if it's either in the form '/z/x/y' or 'z_x_y'. + +Examples: +- Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt +- https://demotiles.maplibre.org/tiles/2/2/1.pbf + USAGE: mvt OPTIONS: @@ -118,12 +124,12 @@ OPTIONS: -h, --help Show help information. SUBCOMMANDS: - dump (default) Print the vector tile as GeoJSON + dump (default) Print the vector tile as GeoJSON to the console info Print information about the vector tile - merge Merge two or more vector tiles query Query the features in a vector tile - export Export the vector tile as GeoJSON - import Import some GeoJSONs to a vector tile + merge Merge two or more vector tiles + import Import some GeoJSONs into a vector tile + export Export the vector tile as GeoJSON to a file See 'mvt help ' for detailed help. ``` @@ -335,6 +341,6 @@ brew install protobuf swift-protobuf swiftlint MIT -# Author +# Authors Thomas Rasch, Outdooractive diff --git a/Sources/MVTCLI/CLI.swift b/Sources/MVTCLI/CLI.swift index fbfd871..bb31f44 100644 --- a/Sources/MVTCLI/CLI.swift +++ b/Sources/MVTCLI/CLI.swift @@ -6,13 +6,27 @@ import MVTTools @main struct CLI: AsyncParsableCommand { - static let logger = Logger(label: "mvttool") + static let logger = Logger(label: "mvt") static let configuration = CommandConfiguration( commandName: "mvt", abstract: "A utility for inspecting and working with vector tiles.", + discussion: """ + The tile coordinate can be extracted from the path if it's either in the form '/z/x/y' or 'z_x_y'. + + Examples: + - Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt + - https://demotiles.maplibre.org/tiles/2/2/1.pbf + """, version: cliVersion, - subcommands: [Dump.self, Info.self, Merge.self, Query.self, Export.self, Import.self], + subcommands: [ + Dump.self, + Info.self, + Query.self, + Merge.self, + Import.self, + Export.self, + ], defaultSubcommand: Dump.self) } @@ -25,49 +39,21 @@ struct CLIError: LocalizedError { } } -struct Options: ParsableArguments { - - @Flag(name: .shortAndLong, help: "Print some debug info") - var verbose = false - - @Option(name: .short, help: "Tile zoom level - if it can't be extracted from the path") - var z: Int? +struct XYZOptions: ParsableArguments { - @Option(name: .short, help: "Tile x coordinate - if it can't be extracted from the path") + @Option(name: .short, help: "Tile x coordinate, if it can't be extracted from the path") var x: Int? - @Option(name: .short, help: "Tile y coordinate - if it can't be extracted from the path") + @Option(name: .short, help: "Tile y coordinate, if it can't be extracted from the path") var y: Int? - @Argument( - help: "The MVT resource (file or URL). The tile coordinate can be extracted from the path if it's either in the form '/z/x/y' or 'z_x_y'", - completion: .file(extensions: ["pbf", "mvt"])) - var path: String + @Option(name: .short, help: "Tile zoom level, if it can't be extracted from the path") + var z: Int? - // Try to parse x/y/z from the path/URL - mutating func parseUrl( - extractCoordinate: Bool = true, - checkExistence: Bool = true) - throws -> URL + mutating func parseXYZ( + fromPath path: String) + throws -> (Int, Int, Int) { - let url: URL - if path.hasPrefix("http") { - guard let parsedUrl = URL(string: path) else { - throw CLIError("\(path) is not a valid URL") - } - url = parsedUrl - } - else { - url = URL(fileURLWithPath: path) - if checkExistence { - guard try url.checkResourceIsReachable() else { - throw CLIError("The file '\(path)' doesn't exist.") - } - } - } - - guard extractCoordinate else { return url } - if x == nil || y == nil || z == nil @@ -98,10 +84,9 @@ struct Options: ParsableArguments { } } - guard let x, - let y, - let z - else { throw CLIError("Need z, x and y") } + guard let x, let y, let z else { + throw CLIError("Need z, x and y") + } guard x >= 0 else { throw CLIError("x must be >= 0") } guard y >= 0 else { throw CLIError("y must be >= 0") } @@ -111,6 +96,37 @@ struct Options: ParsableArguments { if x >= maximumTileCoordinate { throw CLIError("x at zoom \(z) must be smaller than \(maximumTileCoordinate)") } if y >= maximumTileCoordinate { throw CLIError("y at zoom \(z) must be smaller than \(maximumTileCoordinate)") } + return (x, y, z) + } + +} + +struct Options: ParsableArguments { + + @Flag(name: .shortAndLong, help: "Print some debug info") + var verbose = false + + func parseUrl( + fromPath path: String, + checkPathExistence: Bool = true) + throws -> URL + { + let url: URL + if path.hasPrefix("http") { + guard let parsedUrl = URL(string: path) else { + throw CLIError("\(path) is not a valid URL") + } + url = parsedUrl + } + else { + url = URL(fileURLWithPath: path) + if checkPathExistence { + guard try url.checkResourceIsReachable() else { + throw CLIError("The file '\(path)' doesn't exist.") + } + } + } + return url } diff --git a/Sources/MVTCLI/Dump.swift b/Sources/MVTCLI/Dump.swift index 52586fd..5602dbc 100644 --- a/Sources/MVTCLI/Dump.swift +++ b/Sources/MVTCLI/Dump.swift @@ -6,26 +6,38 @@ extension CLI { struct Dump: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "Print the vector tile as GeoJSON") + static let configuration = CommandConfiguration(abstract: "Print the vector tile as GeoJSON to the console") @Option(name: .shortAndLong, help: "Dump only the specified layer (can be repeated)") var layer: [String] = [] + @OptionGroup + var xyzOptions: XYZOptions + @OptionGroup var options: Options + @Argument( + help: "The MVT resource (file or URL)", + completion: .file(extensions: ["pbf", "mvt"])) + var path: String + mutating func run() async throws { - let url = try options.parseUrl() + let (x, y, z) = try xyzOptions.parseXYZ(fromPath: path) + let url = try options.parseUrl(fromPath: path) - guard let x = options.x, - let y = options.y, - let z = options.z - else { throw CLIError("Something went wrong during argument parsing") } + let layerAllowlist = layer.nonempty - let layerWhitelist = layer.nonempty + if options.verbose { + print("Dumping tile '\(url.lastPathComponent)' [\(x),\(y)]@\(z)") - guard let tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerWhitelist, logger: options.verbose ? CLI.logger : nil) else { - throw CLIError("Failed to parse the tile at \(options.path)") + if let layerAllowlist { + print("Layers: '\(layerAllowlist.joined(separator: ","))'") + } + } + + guard let tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerAllowlist, logger: options.verbose ? CLI.logger : nil) else { + throw CLIError("Failed to parse the tile at '\(path)'") } guard let data = tile.toGeoJson(prettyPrinted: true) else { @@ -34,6 +46,10 @@ extension CLI { print(String(data: data, encoding: .utf8) ?? "", terminator: "") print() + + if options.verbose { + print("Done.") + } } } diff --git a/Sources/MVTCLI/Export.swift b/Sources/MVTCLI/Export.swift index b29133a..2027d34 100644 --- a/Sources/MVTCLI/Export.swift +++ b/Sources/MVTCLI/Export.swift @@ -6,37 +6,57 @@ extension CLI { struct Export: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "Export the vector tile as GeoJSON") + static let configuration = CommandConfiguration(abstract: "Export the vector tile as GeoJSON to a file") @Option(name: .shortAndLong, help: "Output file") var output: String + @Flag(name: .shortAndLong, help: "Force overwrite existing files") + var forceOverwrite = false + @Option(name: .shortAndLong, help: "Export only the specified layer (can be repeated)") var layer: [String] = [] - @Flag(name: .shortAndLong, help: "Format the output GeoJSON") + @Flag(name: .shortAndLong, help: "Pretty-print the output GeoJSON") var prettyPrint = false + @OptionGroup + var xyzOptions: XYZOptions + @OptionGroup var options: Options - mutating func run() async throws { - let url = try options.parseUrl() + @Argument( + help: "The MVT resource (file or URL)", + completion: .file(extensions: ["pbf", "mvt"])) + var path: String - guard let x = options.x, - let y = options.y, - let z = options.z - else { throw CLIError("Something went wrong during argument parsing") } + mutating func run() async throws { + let (x, y, z) = try xyzOptions.parseXYZ(fromPath: path) + let url = try options.parseUrl(fromPath: path) let outputUrl = URL(fileURLWithPath: output) if (try? outputUrl.checkResourceIsReachable()) ?? false { - throw CLIError("Output file must not exist") + if forceOverwrite { + print("Existing file '\(outputUrl.lastPathComponent)' will be overwritten") + } + else { + throw CLIError("Output file must not exist (use --force-overwrite to overwrite existing files)") + } } - let layerWhitelist = layer.nonempty + let layerAllowlist = layer.nonempty - guard let tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerWhitelist, logger: options.verbose ? CLI.logger : nil) else { - throw CLIError("Failed to parse the tile at \(options.path)") + if options.verbose { + print("Dumping tile '\(url.lastPathComponent)' [\(x),\(y)]@\(z) to '\(outputUrl.lastPathComponent)'") + + if let layerAllowlist { + print("Layers: '\(layerAllowlist.joined(separator: ","))'") + } + } + + guard let tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerAllowlist, logger: options.verbose ? CLI.logger : nil) else { + throw CLIError("Failed to parse the tile at '\(path)'") } guard let data = tile.toGeoJson(prettyPrinted: prettyPrint) else { @@ -44,6 +64,10 @@ extension CLI { } try data.write(to: outputUrl, options: .atomic) + + if options.verbose { + print("Done.") + } } } diff --git a/Sources/MVTCLI/Import.swift b/Sources/MVTCLI/Import.swift index cc4194f..f52db09 100644 --- a/Sources/MVTCLI/Import.swift +++ b/Sources/MVTCLI/Import.swift @@ -7,10 +7,25 @@ extension CLI { struct Import: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "Import some GeoJSONs to a vector tile") + static let configuration = CommandConfiguration(abstract: "Import some GeoJSONs into a vector tile") - @Option(name: .shortAndLong, help: "Layer name in the vector tile") - var layer: String? + @Option(name: .shortAndLong, help: "Output file") + var output: String + + @Flag(name: .shortAndLong, help: "Force overwrite an existing --output file") + var forceOverwrite = false + + @Flag(name: .shortAndLong, help: "Append to an existing --output file") + var append = false + + @Option(name: .shortAndLong, help: "Layer name in the vector tile. Can be used with --property-name as a fallback name") + var layerName: String? + + @Option(name: .shortAndLong, help: "Feature property to use for the layer name in the vector tile. Fallback to --layer-name. Will slow down things considerably") + var propertyName: String? + + @OptionGroup + var xyzOptions: XYZOptions @OptionGroup var options: Options @@ -21,15 +36,45 @@ extension CLI { var other: [String] = [] mutating func run() async throws { - let url = try options.parseUrl(checkExistence: false) + let (x, y, z) = try xyzOptions.parseXYZ(fromPath: output) + + let outputUrl = URL(fileURLWithPath: output) + if (try? outputUrl.checkResourceIsReachable()) ?? false { + if forceOverwrite { + print("Existing file '\(outputUrl.lastPathComponent)' will be overwritten") + } + else if append { + print("Existing file '\(outputUrl.lastPathComponent)' will be appended") + } + else { + throw CLIError("Output file must not exist (use --force-overwrite or --append to overwrite existing files)") + } + } - guard let x = options.x, - let y = options.y, - let z = options.z - else { throw CLIError("Something went wrong during argument parsing") } + var tile: VectorTile? + if append, + (try? outputUrl.checkResourceIsReachable()) ?? false + { + tile = VectorTile(contentsOf: outputUrl, x: x, y: y, z: z, logger: options.verbose ? CLI.logger : nil) + } + if tile == nil { + tile = VectorTile(x: x, y: y, z: z, logger: options.verbose ? CLI.logger : nil) + } + guard var tile else { + throw CLIError("Failed to create the tile at \(output)") + } - guard var tile = VectorTile(x: x, y: y, z: z, logger: options.verbose ? CLI.logger : nil) else { - throw CLIError("Failed to create the tile at \(options.path)") + if options.verbose { + print("Import into tile '\(outputUrl.lastPathComponent)' [\(x),\(y)]@\(z)") + } + + if options.verbose { + if let layerName { + print("Import layer: \(layerName)") + } + if let propertyName { + print("Import layer feature property: \(propertyName)") + } } for path in other { @@ -48,18 +93,24 @@ extension CLI { } guard let otherGeoJSON = FeatureCollection(contentsOf: otherUrl) else { - throw CLIError("Failed to parse the GeoJSON at \(path)") + throw CLIError("Failed to parse the GeoJSON at '\(path)'") } - tile.addGeoJson(geoJson: otherGeoJSON, layerName: layer) + print("- \(otherUrl.lastPathComponent)") + + tile.addGeoJson(geoJson: otherGeoJSON, layerName: layerName, propertyName: propertyName) } tile.write( - to: url, + to: outputUrl, options: .init( bufferSize: .extent(512), compression: .level(9), simplifyFeatures: .no)) + + if options.verbose { + print("Done.") + } } } diff --git a/Sources/MVTCLI/Info.swift b/Sources/MVTCLI/Info.swift index a91db20..1ef1d84 100644 --- a/Sources/MVTCLI/Info.swift +++ b/Sources/MVTCLI/Info.swift @@ -11,12 +11,21 @@ extension CLI { @OptionGroup var options: Options + @Argument( + help: "The MVT resource (file or URL)", + completion: .file(extensions: ["pbf", "mvt"])) + var path: String + mutating func run() async throws { - let url = try options.parseUrl(extractCoordinate: false) + let url = try options.parseUrl(fromPath: path) + + if options.verbose { + print("Info for tile '\(url.lastPathComponent)'") + } guard let tileInfo = VectorTile.tileInfo(at: url), var layers = tileInfo["layers"] as? [[String: Any]] - else { throw CLIError("Error retreiving the tile info for \(options.path)") } + else { throw CLIError("Error retreiving the tile info for '\(path)'") } layers.sort { first, second in guard let firstName = first["name"] as? String, @@ -42,6 +51,10 @@ extension CLI { asTableWithHeaders: tableHeader) print(result) + + if options.verbose { + print("Done.") + } } private func dumpSideBySide( diff --git a/Sources/MVTCLI/Merge.swift b/Sources/MVTCLI/Merge.swift index b85ec05..f00e7de 100644 --- a/Sources/MVTCLI/Merge.swift +++ b/Sources/MVTCLI/Merge.swift @@ -11,9 +11,18 @@ extension CLI { @Option(name: .shortAndLong, help: "Output file") var output: String + @Flag(name: .shortAndLong, help: "Force overwrite existing files") + var forceOverwrite = false + + @Flag(name: .shortAndLong, help: "Append to an existing --output file") + var append = false + @Option(name: .shortAndLong, help: "Merge only the specified layer (can be repeated)") var layer: [String] = [] + @OptionGroup + var xyzOptions: XYZOptions + @OptionGroup var options: Options @@ -23,22 +32,42 @@ extension CLI { var other: [String] = [] mutating func run() async throws { - let url = try options.parseUrl() - - guard let x = options.x, - let y = options.y, - let z = options.z - else { throw CLIError("Something went wrong during argument parsing") } + let (x, y, z) = try xyzOptions.parseXYZ(fromPath: output) let outputUrl = URL(fileURLWithPath: output) if (try? outputUrl.checkResourceIsReachable()) ?? false { - throw CLIError("Output file must not exist") + if forceOverwrite { + print("Existing file '\(outputUrl.lastPathComponent)' will be overwritten") + } + else if append { + print("Existing file '\(outputUrl.lastPathComponent)' will be appended") + } + else { + throw CLIError("Output file must not exist (use --force-overwrite or --append to overwrite existing files)") + } } - let layerWhitelist = layer.nonempty + let layerAllowlist = layer.nonempty - guard var tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerWhitelist, logger: options.verbose ? CLI.logger : nil) else { - throw CLIError("Failed to parse the tile at \(options.path)") + var tile: VectorTile? + if append, + (try? outputUrl.checkResourceIsReachable()) ?? false + { + tile = VectorTile(contentsOf: outputUrl, x: x, y: y, z: z, logger: options.verbose ? CLI.logger : nil) + } + if tile == nil { + tile = VectorTile(x: x, y: y, z: z, logger: options.verbose ? CLI.logger : nil) + } + guard var tile else { + throw CLIError("Failed to create the tile at \(output)") + } + + if options.verbose { + print("Merging into tile '\(outputUrl.lastPathComponent)' [\(x),\(y)]@\(z)") + + if let layerAllowlist { + print("Layers: '\(layerAllowlist.joined(separator: ","))'") + } } for path in other { @@ -56,8 +85,12 @@ extension CLI { } } - guard let otherTile = VectorTile(contentsOf: otherUrl, x: x, y: y, z: z, layerWhitelist: layerWhitelist, logger: options.verbose ? CLI.logger : nil) else { - throw CLIError("Failed to parse the tile at \(path)") + guard let otherTile = VectorTile(contentsOf: otherUrl, x: x, y: y, z: z, layerWhitelist: layerAllowlist, logger: options.verbose ? CLI.logger : nil) else { + throw CLIError("Failed to parse the tile at '\(path)'") + } + + if options.verbose { + print("- \(otherUrl.lastPathComponent)") } tile.merge(otherTile) @@ -69,6 +102,10 @@ extension CLI { bufferSize: .extent(512), compression: .level(9), simplifyFeatures: .no)) + + if options.verbose { + print("Done.") + } } } diff --git a/Sources/MVTCLI/Query.swift b/Sources/MVTCLI/Query.swift index cded02e..810a2e2 100644 --- a/Sources/MVTCLI/Query.swift +++ b/Sources/MVTCLI/Query.swift @@ -15,9 +15,17 @@ extension CLI { @Option(name: .shortAndLong, help: "Search only in this layer (can be repeated)") var layer: [String] = [] + @OptionGroup + var xyzOptions: XYZOptions + @OptionGroup var options: Options + @Argument( + help: "The MVT resource (file or URL)", + completion: .file(extensions: ["pbf", "mvt"])) + var path: String + @Argument(help: "Search term, can be a string or a coordinate in the form 'latitude,longitude,tolerance(meters)'") var searchTerm: String @@ -29,7 +37,7 @@ extension CLI { if possibleCoordinateParts.count >= 3 { if let partLatitude = Double(possibleCoordinateParts[0]), let partLongitude = Double(possibleCoordinateParts[1]), - let partTolerance = Double(possibleCoordinateParts[2]), + let partTolerance = Double(possibleCoordinateParts[2])?.rounded(), (-90 ... 90).contains(partLatitude), (-180 ... 180).contains(partLongitude), partTolerance > 0.0 @@ -39,17 +47,21 @@ extension CLI { } } - let url = try options.parseUrl() + let (x, y, z) = try xyzOptions.parseXYZ(fromPath: path) + let url = try options.parseUrl(fromPath: path) - guard let x = options.x, - let y = options.y, - let z = options.z - else { throw CLIError("Something went wrong during argument parsing") } + let layerAllowlist = layer.nonempty - let layerWhitelist = layer.nonempty + guard let tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerAllowlist, logger: options.verbose ? CLI.logger : nil) else { + throw CLIError("Failed to parse the tile at \(path)") + } + + if options.verbose { + print("Searching in tile '\(url.lastPathComponent)' [\(x),\(y)]@\(z)") - guard let tile = VectorTile(contentsOf: url, x: x, y: y, z: z, layerWhitelist: layerWhitelist, logger: options.verbose ? CLI.logger : nil) else { - throw CLIError("Failed to parse the tile at \(options.path)") + if let layerAllowlist { + print("Layers: '\(layerAllowlist.joined(separator: ","))'") + } } var result: FeatureCollection? @@ -63,7 +75,7 @@ extension CLI { } else { if options.verbose { - print("Searching for '\(searchTerm)'...") + print("Searching for '\(searchTerm)'…") } result = search(term: searchTerm, in: tile) } diff --git a/Sources/MVTTools/GeoJson.swift b/Sources/MVTTools/GeoJson.swift index 0b93a61..848ff25 100644 --- a/Sources/MVTTools/GeoJson.swift +++ b/Sources/MVTTools/GeoJson.swift @@ -67,11 +67,23 @@ extension VectorTile { // MARK: - GeoJSON support /// Add some GeoJSON to this tile - public mutating func addGeoJson(geoJson: GeoJson, layerName: String? = nil) { + public mutating func addGeoJson( + geoJson: GeoJson, + layerName: String? = nil, + propertyName: String? = nil) + { guard let features = geoJson.flattened?.features else { return } - let layerName = layerName ?? "Layer-\(layerNames.count)" - appendFeatures(features, to: layerName) + if let propertyName { + for feature in features { + let layerName: String = feature.property(for: propertyName) ?? layerName ?? "Layer-\(layerNames.count)" + appendFeatures([feature], to: layerName) + } + } + else { + let layerName = layerName ?? "Layer-\(layerNames.count)" + appendFeatures(features, to: layerName) + } } /// Replace some GeoJSON in this tile