├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── commonTest │ ├── resources │ │ ├── one_preset_min.json │ │ ├── localizations_en.json │ │ ├── localizations_min.json │ │ ├── localizations_de-Cyrl-AT.json │ │ ├── localizations_de-Cyrl.json │ │ ├── brand_presets_min2.json │ │ ├── localizations.json │ │ ├── preset_wildcard_in_value.json │ │ ├── one_preset_with_placeholder_name.json │ │ ├── brand_presets_min.json │ │ ├── localizations_de.json │ │ ├── localizations_de-AT.json │ │ ├── some_presets_min.json │ │ ├── preset_wildcard_in_key.json │ │ ├── one_preset_full.json │ │ └── one_preset_unsupported_location_set.json │ └── kotlin │ │ └── de │ │ └── westnordost │ │ └── osmfeatures │ │ ├── TestUtils.kt │ │ ├── TestLocalizedFeatureCollection.kt │ │ ├── TestPerCountryFeatureCollection.kt │ │ ├── LivePresetDataAccessAdapter.kt │ │ ├── StartsWithStringTreeTest.kt │ │ ├── IDBrandPresetsFeatureCollectionTest.kt │ │ ├── ContainedMapTreeTest.kt │ │ ├── FeatureTermIndexTest.kt │ │ ├── FeatureTagsIndexTest.kt │ │ ├── IDPresetsTranslationJsonParserTest.kt │ │ ├── IDPresetsJsonParserTest.kt │ │ ├── IDLocalizedFeatureCollectionTest.kt │ │ └── FeatureDictionaryTest.kt ├── commonMain │ └── kotlin │ │ └── de │ │ └── westnordost │ │ └── osmfeatures │ │ ├── Language.kt │ │ ├── ResourceAccessAdapter.kt │ │ ├── StringUtils.kt │ │ ├── GeometryType.kt │ │ ├── PerCountryFeatureCollection.kt │ │ ├── LocalizedFeatureCollection.kt │ │ ├── FileSystemAccess.kt │ │ ├── FeatureTermIndex.kt │ │ ├── FeatureTagsIndex.kt │ │ ├── BaseFeature.kt │ │ ├── LocalizedFeature.kt │ │ ├── IDBrandPresetsFeatureCollection.kt │ │ ├── Feature.kt │ │ ├── IDPresetsTranslationJsonParser.kt │ │ ├── StartsWithStringTree.kt │ │ ├── IDLocalizedFeatureCollection.kt │ │ ├── IDPresetsJsonParser.kt │ │ ├── ContainedMapTree.kt │ │ └── FeatureDictionary.kt ├── jvmMain │ └── kotlin │ │ └── de │ │ └── westnordost │ │ └── osmfeatures │ │ └── Language.jvm.kt ├── androidMain │ └── kotlin │ │ └── de │ │ └── westnordost │ │ └── osmfeatures │ │ ├── Language.android.kt │ │ ├── AssetManagerAccess.kt │ │ └── FeatureDictionary.android.kt └── iosMain │ └── kotlin │ └── de │ └── westnordost │ └── osmfeatures │ └── Language.ios.kt ├── .gitignore ├── gradle.properties ├── settings.gradle.kts ├── gradlew.bat ├── CHANGELOG.md ├── README.md ├── gradlew └── LICENSE /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westnordost/osmfeatures/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/commonTest/resources/one_preset_min.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/id": { 3 | "tags": { "a": "b", "c": "d" }, 4 | "geometry": ["point"] 5 | } 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .kotlin 5 | /.idea 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/Language.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | internal expect fun defaultLanguage(): String -------------------------------------------------------------------------------- /src/commonTest/resources/localizations_en.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "Bakery" 5 | } 6 | } 7 | }}} -------------------------------------------------------------------------------- /src/commonTest/resources/localizations_min.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "bar" 5 | } 6 | } 7 | }}} -------------------------------------------------------------------------------- /src/commonTest/resources/localizations_de-Cyrl-AT.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "бацкхусл" 5 | } 6 | } 7 | }}} -------------------------------------------------------------------------------- /src/commonTest/resources/localizations_de-Cyrl.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "бацкхаус" 5 | } 6 | } 7 | }}} -------------------------------------------------------------------------------- /src/commonTest/resources/brand_presets_min2.json: -------------------------------------------------------------------------------- 1 | { 2 | "yet_another/brand": { 3 | "name": "Talespin", 4 | "tags": { "a": "b", "c": "d" }, 5 | "geometry": ["point"] 6 | } 7 | } -------------------------------------------------------------------------------- /src/commonTest/resources/localizations.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "bar", 5 | "aliases": "one \ntwo\r\n three", 6 | "terms": "a, b" 7 | } 8 | } 9 | }}} -------------------------------------------------------------------------------- /src/jvmMain/kotlin/de/westnordost/osmfeatures/Language.jvm.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import java.util.Locale 4 | 5 | internal actual fun defaultLanguage(): String = 6 | Locale.getDefault().toLanguageTag() -------------------------------------------------------------------------------- /src/androidMain/kotlin/de/westnordost/osmfeatures/Language.android.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import java.util.Locale 4 | 5 | internal actual fun defaultLanguage(): String = 6 | Locale.getDefault().toLanguageTag() -------------------------------------------------------------------------------- /src/commonTest/resources/preset_wildcard_in_value.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/id": { 3 | "name": "test1", 4 | "tags": { "a": "1", "x": "*" }, 5 | "addTags": { "a": "2", "y": "*" }, 6 | "removeTags": { "a": "3", "z": "*" }, 7 | "geometry": ["point"] 8 | } 9 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Gradle 2 | org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" 3 | org.gradle.caching=true 4 | org.gradle.configuration-cache=true 5 | 6 | signing.keyId=08A11DBC 7 | signing.password= 8 | signing.secretKeyRingFile= 9 | -------------------------------------------------------------------------------- /src/commonTest/resources/one_preset_with_placeholder_name.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/id": { 3 | "tags": { "a": "x", "c": "y" }, 4 | "geometry": ["point"] 5 | }, 6 | "some/id-dingsdongs": { 7 | "name": "{some/id}", 8 | "tags": { "a": "b", "c": "d" }, 9 | "geometry": ["point"] 10 | } 11 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/commonTest/resources/brand_presets_min.json: -------------------------------------------------------------------------------- 1 | { 2 | "a/brand": { 3 | "name": "Duckworths", 4 | "tags": { "a": "b", "c": "d" }, 5 | "geometry": ["point"] 6 | }, 7 | "another/brand": { 8 | "name": "Megamall", 9 | "tags": { "a": "b", "c": "d" }, 10 | "geometry": ["point"] 11 | } 12 | } -------------------------------------------------------------------------------- /src/commonTest/resources/localizations_de.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "Bäckerei", 5 | "aliases": "Konditorei", 6 | "terms": "brot" 7 | }, 8 | "another/id": { 9 | "name": "Gullideckel", 10 | "terms": "turtles" 11 | } 12 | } 13 | }}} -------------------------------------------------------------------------------- /src/iosMain/kotlin/de/westnordost/osmfeatures/Language.ios.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import platform.Foundation.NSLocale 4 | import platform.Foundation.currentLocale 5 | import platform.Foundation.localeIdentifier 6 | 7 | internal actual fun defaultLanguage(): String = 8 | NSLocale.currentLocale.localeIdentifier -------------------------------------------------------------------------------- /src/commonTest/resources/localizations_de-AT.json: -------------------------------------------------------------------------------- 1 | { "xx": { "presets" : { 2 | "presets": { 3 | "some/id": { 4 | "name": "Backhusl" 5 | }, 6 | "another/id": { 7 | "terms": "ninja" 8 | }, 9 | "yet/another/id": { 10 | "name": "Brückle", 11 | "aliases": "Drüberrüber" 12 | } 13 | } 14 | }}} -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/ResourceAccessAdapter.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlinx.io.IOException 4 | import kotlinx.io.Source 5 | 6 | interface ResourceAccessAdapter { 7 | fun exists(name: String): Boolean 8 | 9 | @Throws(IOException::class) 10 | fun open(name: String): Source 11 | } -------------------------------------------------------------------------------- /src/commonTest/resources/some_presets_min.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/id": { 3 | "name": "test", 4 | "tags": { "a": "b", "c": "d" }, 5 | "geometry": ["point"] 6 | }, 7 | "another/id": { 8 | "name": "test", 9 | "tags": { "a": "b", "c": "d" }, 10 | "geometry": ["point"] 11 | }, 12 | "yet/another/id": { 13 | "name": "test", 14 | "tags": { "a": "b", "c": "d" }, 15 | "geometry": ["point"] 16 | } 17 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import doist.x.normalize.Form 4 | import doist.x.normalize.normalize 5 | 6 | private val FIND_DIACRITICS = "\\p{InCombiningDiacriticalMarks}+".toRegex() 7 | 8 | internal fun String.canonicalize(): String = 9 | stripDiacritics().lowercase() 10 | 11 | private fun String.stripDiacritics(): String { 12 | return FIND_DIACRITICS.replace(normalize(Form.NFD),"") 13 | } -------------------------------------------------------------------------------- /src/commonTest/resources/preset_wildcard_in_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/id": { 3 | "name": "test", 4 | "tags": { "addr:*": "b" }, 5 | "geometry": ["point"] 6 | }, 7 | "some/id2": { 8 | "name": "test", 9 | "tags": { "a": "b" }, 10 | "addTags": { "addr:*": "b" }, 11 | "geometry": ["point"] 12 | }, 13 | "some/id3": { 14 | "name": "test", 15 | "tags": { "a": "b" }, 16 | "removeTags": { "addr:*": "c" }, 17 | "geometry": ["point"] 18 | } 19 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlinx.io.IOException 4 | import kotlinx.io.Source 5 | import kotlinx.io.buffered 6 | import kotlinx.io.files.Path 7 | import kotlinx.io.files.SystemFileSystem 8 | 9 | fun useResource(file: String, block: (Source) -> R): R = 10 | resource(file).use { block(it) } 11 | 12 | @Throws(IOException::class) 13 | fun resource(file: String): Source = 14 | SystemFileSystem.source(Path("src/commonTest/resources", file)).buffered() -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/GeometryType.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | enum class GeometryType { 4 | /** an OSM node that is not a member of any way */ 5 | POINT, 6 | 7 | /** an OSM node that is a member of one or more ways */ 8 | VERTEX, 9 | 10 | /** an OSM way that is not an area */ 11 | LINE, 12 | 13 | /** a OSM way that is closed/circular (the first and last nodes are the same) or a type=multipolygon relation */ 14 | AREA, 15 | 16 | /** an OSM relation */ 17 | RELATION 18 | } 19 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/PerCountryFeatureCollection.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** A collection of features grouped by country code */ 4 | internal interface PerCountryFeatureCollection { 5 | /** Returns all features with the given country codes. */ 6 | fun getAll(countryCodes: List): Collection 7 | 8 | /** Returns the feature with the given id with the given country code or null if it has not been 9 | * found */ 10 | fun get(id: String, countryCodes: List): Feature? 11 | } 12 | -------------------------------------------------------------------------------- /src/commonTest/resources/one_preset_full.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/id": { 3 | "tags": { "a": "b", "c": "d" }, 4 | "geometry": ["point", "vertex", "line", "area", "relation"], 5 | "icon": "abc", 6 | "imageURL": "someurl", 7 | "name": "foo", 8 | "aliases": ["one", "two"], 9 | "terms": ["1","2"], 10 | "addTags": { "e": "f" }, 11 | "removeTags": { "d": "g" }, 12 | "preserveTags": ["^name"], 13 | "locationSet": { 14 | "include": ["de","gb"], 15 | "exclude": ["it"] 16 | }, 17 | "searchable": false, 18 | "matchScore": 0.5 19 | } 20 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/LocalizedFeatureCollection.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** A localized collection of features */ 4 | internal interface LocalizedFeatureCollection { 5 | /** Returns all features in the given IETF language tag(s). */ 6 | fun getAll(languages: List): Collection 7 | 8 | /** Returns the feature with the given id in the given IETF language tag(s) or null if it has 9 | * not been found (for the given IETF language tag(s)) */ 10 | fun get(id: String, languages: List): Feature? 11 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/TestLocalizedFeatureCollection.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | 4 | class TestLocalizedFeatureCollection(private val features: List) : LocalizedFeatureCollection { 5 | 6 | override fun getAll(languages: List): Collection = 7 | features.filter { languages.contains(it.language) } 8 | 9 | override fun get(id: String, languages: List): Feature? { 10 | val feature = features.find { it.id == id } ?: return null 11 | if (!languages.contains(feature.language)) return null 12 | return feature 13 | } 14 | } -------------------------------------------------------------------------------- /src/androidMain/kotlin/de/westnordost/osmfeatures/AssetManagerAccess.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import android.content.res.AssetManager 4 | import kotlinx.io.IOException 5 | import kotlinx.io.Source 6 | import kotlinx.io.asSource 7 | import kotlinx.io.buffered 8 | import java.io.File 9 | 10 | internal class AssetManagerAccess( 11 | private val assetManager: AssetManager, 12 | private val basePath: String 13 | ): ResourceAccessAdapter { 14 | 15 | override fun exists(name: String): Boolean = 16 | assetManager.list(basePath)?.contains(name) ?: false 17 | 18 | @Throws(IOException::class) 19 | override fun open(name: String): Source = 20 | assetManager.open(basePath + File.separator + name).asSource().buffered() 21 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/TestPerCountryFeatureCollection.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | class TestPerCountryFeatureCollection(private val features: List) : PerCountryFeatureCollection { 4 | 5 | override fun getAll(countryCodes: List): Collection = 6 | features.filter { it.isAvailableIn(countryCodes) } 7 | 8 | override fun get(id: String, countryCodes: List): Feature? = 9 | features.find { it.isAvailableIn(countryCodes) } 10 | 11 | private fun Feature.isAvailableIn(countryCodes: List): Boolean = 12 | countryCodes.none { 13 | excludeCountryCodes.contains(it) 14 | } && 15 | countryCodes.any { 16 | includeCountryCodes.contains(it) || it == null && includeCountryCodes.isEmpty() 17 | } 18 | } -------------------------------------------------------------------------------- /src/androidMain/kotlin/de/westnordost/osmfeatures/FeatureDictionary.android.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import android.content.res.AssetManager 4 | 5 | /** Create a new FeatureDictionary which gets its data from the given directory in the app's 6 | * asset folder. Optionally, the path to the brand presets can be specified. */ 7 | @JvmOverloads 8 | fun FeatureDictionary.Companion.create( 9 | assetManager: AssetManager, 10 | presetsBasePath: String, 11 | brandPresetsBasePath: String? = null 12 | ) = FeatureDictionary( 13 | featureCollection = 14 | IDLocalizedFeatureCollection(AssetManagerAccess(assetManager, presetsBasePath)), 15 | brandFeatureCollection = 16 | brandPresetsBasePath?.let { 17 | IDBrandPresetsFeatureCollection(AssetManagerAccess(assetManager, brandPresetsBasePath)) 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | @Suppress("UnstableApiUsage") 5 | mavenContent { 6 | includeGroupAndSubgroups("androidx") 7 | includeGroupAndSubgroups("com.android") 8 | includeGroupAndSubgroups("com.google") 9 | } 10 | } 11 | mavenCentral() 12 | gradlePluginPortal() 13 | } 14 | } 15 | 16 | dependencyResolutionManagement { 17 | @Suppress("UnstableApiUsage") 18 | repositories { 19 | google { 20 | mavenContent { 21 | includeGroupAndSubgroups("androidx") 22 | includeGroupAndSubgroups("com.android") 23 | includeGroupAndSubgroups("com.google") 24 | } 25 | } 26 | mavenCentral() 27 | } 28 | } 29 | 30 | rootProject.name = "osmfeatures" 31 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/FileSystemAccess.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlinx.io.IOException 4 | import kotlinx.io.Source 5 | import kotlinx.io.buffered 6 | import kotlinx.io.files.FileSystem 7 | import kotlinx.io.files.Path 8 | 9 | class FileSystemAccess( 10 | private val fileSystem: FileSystem, 11 | private val basePath: String 12 | ) : ResourceAccessAdapter { 13 | 14 | init { 15 | val metadata = fileSystem.metadataOrNull(Path(basePath)) 16 | require(metadata != null) { "$basePath does not exist" } 17 | require(metadata.isDirectory) { "$basePath is not a directory" } 18 | } 19 | 20 | override fun exists(name: String): Boolean = 21 | fileSystem.exists(Path(basePath, name)) 22 | 23 | @Throws(IOException::class) 24 | override fun open(name: String): Source = 25 | fileSystem.source(Path(basePath, name)).buffered() 26 | } 27 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/LivePresetDataAccessAdapter.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.engine.cio.* 5 | import io.ktor.client.request.* 6 | import io.ktor.client.statement.* 7 | import io.ktor.utils.io.* 8 | import kotlinx.coroutines.runBlocking 9 | import kotlinx.io.Buffer 10 | import kotlinx.io.IOException 11 | import kotlinx.io.Source 12 | import kotlinx.io.buffered 13 | 14 | class LivePresetDataAccessAdapter : ResourceAccessAdapter { 15 | 16 | private val client = HttpClient(CIO) { expectSuccess = true } 17 | private val baseUrl = "https://raw.githubusercontent.com/openstreetmap/id-tagging-schema/main/dist" 18 | 19 | override fun exists(name: String): Boolean = 20 | name in listOf("presets.json", "de.json", "en.json", "en-GB.json") 21 | 22 | @Throws(IOException::class) 23 | override fun open(name: String): Source { 24 | val url = when(name) { 25 | "presets.json" -> "$baseUrl/presets.json" 26 | else -> "$baseUrl/translations/$name" 27 | } 28 | 29 | return runBlocking { client.get(url).bodyAsChannel().asSource().buffered() } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/FeatureTermIndex.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** 4 | * Index that makes finding Features whose name/term/... starts with a given string very efficient. 5 | * 6 | * Based on the StartsWithStringTree data structure, see that class. */ 7 | internal class FeatureTermIndex(features: Collection, getStrings: (Feature) -> List) { 8 | // name/term/... -> features 9 | private val featureMap = HashMap>(features.size) 10 | private val tree: StartsWithStringTree 11 | 12 | init { 13 | for (feature in features) { 14 | for (string in getStrings(feature)) { 15 | val map = featureMap.getOrPut(string) { ArrayList(1) } 16 | map.add(feature) 17 | } 18 | } 19 | tree = StartsWithStringTree(featureMap.keys) 20 | } 21 | 22 | fun getAll(startsWith: String): Set { 23 | val result = HashSet() 24 | for (string in tree.getAll(startsWith)) { 25 | val fs = featureMap[string] 26 | if (fs != null) result.addAll(fs) 27 | } 28 | return result 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/StartsWithStringTreeTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertTrue 6 | 7 | class StartsWithStringTreeTest { 8 | 9 | @Test 10 | fun copes_with_empty_collection() { 11 | assertTrue(tree().getAll("any").isEmpty()) 12 | } 13 | 14 | @Test 15 | fun find_single_string() { 16 | val t = tree("anything") 17 | assertEquals(listOf("anything"), t.getAll("a")) 18 | assertEquals(listOf("anything"), t.getAll("any")) 19 | assertEquals(listOf("anything"), t.getAll("anything")) 20 | } 21 | 22 | @Test 23 | fun do_not_find_single_string() { 24 | val t = tree("anything", "more", "etc") 25 | assertTrue(t.getAll("").isEmpty()) 26 | assertTrue(t.getAll("nything").isEmpty()) 27 | assertTrue(t.getAll("anything else").isEmpty()) 28 | } 29 | 30 | @Test 31 | fun find_several_strings() { 32 | val t = tree("anything", "anybody", "anytime") 33 | assertEquals(setOf("anything", "anybody", "anytime"), t.getAll("any").toSet()) 34 | } 35 | } 36 | 37 | private fun tree(vararg strings: String) = StartsWithStringTree(strings.toList()) -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/FeatureTagsIndex.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** 4 | * Index that makes finding Features whose tags are completely contained by a given set of tags 5 | * very efficient. 6 | * 7 | * Based on ContainedMapTree data structure, see that class. */ 8 | internal class FeatureTagsIndex(features: Collection) { 9 | // tags -> list of features 10 | private val featureMap = HashMap, MutableList>(features.size) 11 | private val tree: ContainedMapTree 12 | 13 | init { 14 | for (feature in features) { 15 | val map = featureMap.getOrPut(feature.tags) { ArrayList(1) } 16 | map.add(feature) 17 | } 18 | tree = ContainedMapTree(featureMap.keys) 19 | } 20 | 21 | fun getAll(tags: Map): List { 22 | val result = ArrayList() 23 | for (map in tree.getAll(tags)) { 24 | val features = featureMap[map].orEmpty() 25 | for (feature in features) { 26 | if (feature.tagKeys.all { tags.containsKey(it) }) { 27 | result.add(feature) 28 | } 29 | } 30 | } 31 | return result 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/commonTest/resources/one_preset_unsupported_location_set.json: -------------------------------------------------------------------------------- 1 | { 2 | "some/invalid_include": { 3 | "name": "test", 4 | "tags": { "a": "b" }, 5 | "geometry": ["point"], 6 | "locationSet": { 7 | "include": ["de", "150"], 8 | "exclude": ["da"] 9 | } 10 | }, 11 | "some/invalid_exclude": { 12 | "name": "test", 13 | "tags": { "a": "b" }, 14 | "geometry": ["point"], 15 | "locationSet": { 16 | "include": ["de"], 17 | "exclude": ["150"] 18 | } 19 | }, 20 | "another/invalid_exclude": { 21 | "name": "test", 22 | "tags": { "a": "b" }, 23 | "geometry": ["point"], 24 | "locationSet": { 25 | "include": ["de"], 26 | "exclude": [[0.0,0.0]] 27 | } 28 | }, 29 | "another/invalid_include": { 30 | "name": "test", 31 | "tags": { "a": "b" }, 32 | "geometry": ["point"], 33 | "locationSet": { 34 | "include": [[0.0,0.0]], 35 | "exclude": ["de"] 36 | } 37 | }, 38 | "some/ok": { 39 | "name": "test", 40 | "tags": { "a": "b" }, 41 | "geometry": ["point"], 42 | "locationSet": { 43 | "include": ["001"], 44 | "exclude": ["de"] 45 | } 46 | }, 47 | "another/ok": { 48 | "name": "test", 49 | "tags": { "a": "b" }, 50 | "geometry": ["point"], 51 | "locationSet": { 52 | "include": ["de-hh","de"], 53 | "exclude": ["de-sh"] 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/BaseFeature.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Data class associated with the Feature interface. Represents a non-localized feature. */ 4 | data class BaseFeature( 5 | override val id: String, 6 | override val tags: Map, 7 | override val geometry: List, 8 | override val icon: String? = null, 9 | override val imageURL: String? = null, 10 | override val names: List, 11 | override val terms: List = listOf(), 12 | override val includeCountryCodes: List = listOf(), 13 | override val excludeCountryCodes: List = listOf(), 14 | override val isSearchable: Boolean = true, 15 | override val matchScore: Float = 1f, 16 | override val isSuggestion: Boolean = false, 17 | override val addTags: Map = tags, 18 | override val removeTags: Map = addTags, 19 | override val preserveTags: List = listOf(), 20 | override val tagKeys: Set = setOf(), 21 | override val addTagKeys: Set = tagKeys, 22 | override val removeTagKeys: Set = addTagKeys 23 | ): Feature { 24 | override val canonicalNames: List = names.map { it.canonicalize() } 25 | override val canonicalTerms: List = terms.map { it.canonicalize() } 26 | 27 | override val language: String? get() = null 28 | override fun toString(): String = id 29 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/IDBrandPresetsFeatureCollectionTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.assertEquals 4 | import kotlin.test.Test 5 | 6 | class IDBrandPresetsFeatureCollectionTest { 7 | @Test 8 | fun load_brands() { 9 | val c = IDBrandPresetsFeatureCollection(object : ResourceAccessAdapter { 10 | override fun exists(name: String) = name == "presets.json" 11 | override fun open(name: String) = resource("brand_presets_min.json") 12 | }) 13 | assertEquals( 14 | setOf("Duckworths", "Megamall"), 15 | c.getAll(listOf(null)).map { it.name }.toSet() 16 | ) 17 | assertEquals("Duckworths", c.get("a/brand", listOf(null))?.name) 18 | assertEquals("Megamall", c.get("another/brand", listOf(null))?.name) 19 | assertEquals(true, c.get("a/brand", listOf(null))?.isSuggestion) 20 | assertEquals(true, c.get("another/brand", listOf(null))?.isSuggestion) 21 | } 22 | 23 | @Test 24 | fun load_brands_by_country() { 25 | val c = IDBrandPresetsFeatureCollection(object : ResourceAccessAdapter { 26 | override fun exists(name: String) = name == "presets-DE.json" 27 | override fun open(name: String) = resource("brand_presets_min2.json") 28 | }) 29 | assertEquals( 30 | setOf("Talespin"), 31 | c.getAll(listOf("DE")).map { it.name }.toSet() 32 | ) 33 | assertEquals("Talespin", c.get("yet_another/brand", listOf("DE"))?.name) 34 | assertEquals(true, c.get("yet_another/brand", listOf("DE"))?.isSuggestion) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/ContainedMapTreeTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertTrue 6 | 7 | class ContainedMapTreeTest { 8 | @Test 9 | fun copes_with_empty_feature_collection() { 10 | assertTrue(tree().getAll(mapOf("a" to "b")).isEmpty()) 11 | } 12 | 13 | @Test 14 | fun find_single_map() { 15 | val f1 = mapOf("a" to "b") 16 | val t = tree(f1) 17 | assertEquals(listOf(f1), t.getAll(mapOf("a" to "b", "c" to "d"))) 18 | } 19 | 20 | @Test 21 | fun do_not_find_single_map() { 22 | val tree = tree(mapOf("a" to "b")) 23 | assertTrue(tree.getAll(mapOf()).isEmpty()) 24 | assertTrue(tree.getAll(mapOf("c" to "d")).isEmpty()) 25 | assertTrue(tree.getAll(mapOf("a" to "c")).isEmpty()) 26 | } 27 | 28 | @Test 29 | fun find_only_generic_map() { 30 | val f1 = mapOf("a" to "b") 31 | val f2 = mapOf("a" to "b", "c" to "d") 32 | val tree = tree(f1, f2) 33 | assertEquals(listOf(f1), tree.getAll(mapOf("a" to "b"))) 34 | } 35 | 36 | @Test 37 | fun find_map_with_one_match_and_with_several_matches() { 38 | val f1 = mapOf("a" to "b") 39 | val f2 = mapOf("a" to "b", "c" to "d") 40 | val f3 = mapOf("a" to "b", "c" to "e") 41 | val f4 = mapOf("a" to "b", "d" to "d") 42 | val tree = tree(f1, f2, f3, f4) 43 | assertEquals(setOf(f1, f2), tree.getAll(mapOf("a" to "b", "c" to "d")).toSet()) 44 | } 45 | } 46 | 47 | private fun tree(vararg items: Map) = ContainedMapTree(items.toList()) 48 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/LocalizedFeature.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Data class associated with the Feature interface. Represents a localized feature. 4 | * 5 | * I.e. the name and terms are specified in the given language. */ 6 | data class LocalizedFeature( 7 | private val p: BaseFeature, 8 | override val language: String?, 9 | override val names: List, 10 | override val terms: List 11 | ) : Feature { 12 | override val canonicalNames: List = names.map { it.canonicalize() } 13 | override val canonicalTerms: List = terms.map { it.canonicalize() } 14 | 15 | override val id: String get() = p.id 16 | override val tags: Map get() = p.tags 17 | override val geometry: List get() = p.geometry 18 | override val icon: String? get() = p.icon 19 | override val imageURL: String? get() = p.imageURL 20 | override val includeCountryCodes: List get() = p.includeCountryCodes 21 | override val excludeCountryCodes: List get() = p.excludeCountryCodes 22 | override val isSearchable: Boolean get() = p.isSearchable 23 | override val matchScore: Float get() = p.matchScore 24 | override val addTags: Map get() = p.addTags 25 | override val removeTags: Map get() = p.removeTags 26 | override val preserveTags: List get() = p.preserveTags 27 | override val isSuggestion: Boolean get() = p.isSuggestion 28 | override val tagKeys: Set get() = p.tagKeys 29 | override val addTagKeys: Set get() = p.addTagKeys 30 | override val removeTagKeys: Set get() = p.removeTagKeys 31 | 32 | override fun toString(): String = id 33 | } 34 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/FeatureTermIndexTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertTrue 6 | 7 | class FeatureTermIndexTest { 8 | @Test 9 | fun copes_with_empty_collection() { 10 | val index = index() 11 | assertTrue(index.getAll("a").isEmpty()) 12 | } 13 | 14 | @Test 15 | fun get_one_features_with_same_term() { 16 | val f1 = feature("a", "b") 17 | val f2 = feature("c") 18 | val index = index(f1, f2) 19 | assertEquals(setOf(f1), index.getAll("b")) 20 | } 21 | 22 | @Test 23 | fun get_two_features_with_same_term() { 24 | val f1 = feature("a", "b") 25 | val f2 = feature("a", "c") 26 | val index = index(f1, f2) 27 | assertEquals(setOf(f1, f2), index.getAll("a")) 28 | } 29 | 30 | @Test 31 | fun get_two_features_with_different_terms() { 32 | val f1 = feature("anything") 33 | val f2 = feature("anybody") 34 | val index = index(f1, f2) 35 | assertEquals(setOf(f1, f2), index.getAll("any")) 36 | assertEquals(setOf(f1), index.getAll("anyt")) 37 | } 38 | 39 | @Test 40 | fun do_not_get_one_feature_twice() { 41 | val f1 = feature("something", "someone") 42 | val index = index(f1) 43 | assertEquals(setOf(f1), index.getAll("some")) 44 | } 45 | } 46 | 47 | private fun index(vararg features: Feature) = FeatureTermIndex(features.toList()) { it.terms } 48 | 49 | private fun feature(vararg terms: String): Feature = BaseFeature( 50 | id = "id", 51 | tags = mapOf(), 52 | geometry = listOf(GeometryType.POINT), 53 | icon = null, 54 | imageURL = null, 55 | names = listOf("name"), 56 | terms = terms.toList(), 57 | includeCountryCodes = listOf(), 58 | excludeCountryCodes = listOf(), 59 | isSearchable = true, 60 | matchScore = 1.0f, 61 | isSuggestion = false, 62 | addTags = mapOf(), 63 | removeTags = mapOf(), 64 | preserveTags = listOf(), 65 | tagKeys = setOf(), 66 | addTagKeys = setOf(), 67 | removeTagKeys = setOf() 68 | ) -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/IDBrandPresetsFeatureCollection.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Non-localized feature collection sourcing from (NSI) iD presets defined in JSON. 4 | * 5 | * The base path is defined via the given FileAccessAdapter. In the base path, it is expected that 6 | * there is a presets.json which includes all the features. Additionally, it is possible to place 7 | * more files like e.g. presets-DE.json, presets-US-NY.json into the directory which will be loaded 8 | * lazily on demand */ 9 | internal class IDBrandPresetsFeatureCollection( 10 | private val fileAccess: ResourceAccessAdapter 11 | ) : PerCountryFeatureCollection { 12 | // countryCode -> lazy { featureId -> Feature } 13 | private val featuresByIdByCountryCode = LinkedHashMap>>(320) 14 | 15 | init { 16 | getOrLoadPerCountryFeatures(null) 17 | } 18 | 19 | override fun getAll(countryCodes: List): Collection { 20 | val result = HashMap() 21 | for (cc in countryCodes) { 22 | result.putAll(getOrLoadPerCountryFeatures(cc)) 23 | } 24 | return result.values 25 | } 26 | 27 | override fun get(id: String, countryCodes: List): Feature? { 28 | for (countryCode in countryCodes) { 29 | val result = getOrLoadPerCountryFeatures(countryCode)[id] 30 | if (result != null) return result 31 | } 32 | return null 33 | } 34 | 35 | private fun getOrLoadPerCountryFeatures(countryCode: String?): LinkedHashMap = 36 | featuresByIdByCountryCode.getOrPut(countryCode) { 37 | lazy { loadFeatures(countryCode).associateByTo(LinkedHashMap()) { it.id } } 38 | }.value 39 | 40 | private fun loadFeatures(countryCode: String?): List { 41 | val filename = getPresetsFileName(countryCode) 42 | if (!fileAccess.exists(filename)) return emptyList() 43 | return fileAccess.open(filename).use { source -> 44 | IDPresetsJsonParser(true).parse(source) 45 | } 46 | } 47 | 48 | private fun getPresetsFileName(countryCode: String?): String = 49 | if (countryCode == null) "presets.json" else "presets-$countryCode.json" 50 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/FeatureTagsIndexTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertTrue 6 | 7 | class FeatureTagsIndexTest { 8 | @Test 9 | fun copes_with_empty_collection() { 10 | val index = index() 11 | assertTrue(index.getAll(mapOf("a" to "b")).isEmpty()) 12 | } 13 | 14 | @Test 15 | fun get_two_features_with_same_tags() { 16 | val f1 = feature(mapOf("a" to "b")) 17 | val f2 = feature(mapOf("a" to "b")) 18 | val index = index(f1, f2) 19 | assertEquals( 20 | listOf(f1, f2), 21 | index.getAll(mapOf("a" to "b", "c" to "d")) 22 | ) 23 | } 24 | 25 | @Test 26 | fun get_two_features_with_different_tags() { 27 | val f1 = feature(mapOf("a" to "b")) 28 | val f2 = feature(mapOf("c" to "d")) 29 | val index = index(f1, f2) 30 | assertEquals( 31 | listOf(f1, f2), 32 | index.getAll(mapOf("a" to "b", "c" to "d")) 33 | ) 34 | } 35 | 36 | @Test 37 | fun get_feature_with_wildcard_value() { 38 | val f1 = feature(mapOf("a" to "b"), setOf("c")) 39 | val f2 = feature(mapOf("a" to "b"), setOf("d")) 40 | val index = index(f1, f2) 41 | 42 | assertEquals( 43 | listOf(), 44 | index.getAll(mapOf("a" to "b")) 45 | ) 46 | 47 | assertEquals( 48 | listOf(f1), 49 | index.getAll(mapOf("a" to "b", "c" to "anything")) 50 | ) 51 | 52 | assertEquals( 53 | listOf(f1, f2), 54 | index.getAll(mapOf("a" to "b", "c" to "anything", "d" to "x")) 55 | ) 56 | } 57 | } 58 | 59 | private fun index(vararg features: Feature) = FeatureTagsIndex(features.toList()) 60 | 61 | private fun feature( 62 | tags: Map, 63 | keys: Set = setOf() 64 | ): Feature = BaseFeature( 65 | id = "id", 66 | tags = tags, 67 | geometry = listOf(GeometryType.POINT), 68 | icon = null, 69 | imageURL = null, 70 | names = listOf("name"), 71 | terms = listOf(), 72 | includeCountryCodes = listOf(), 73 | excludeCountryCodes = listOf(), 74 | isSearchable = true, 75 | matchScore = 1.0f, 76 | isSuggestion = false, 77 | addTags = mapOf(), 78 | removeTags = mapOf(), 79 | preserveTags = listOf(), 80 | tagKeys = keys, 81 | addTagKeys = setOf(), 82 | removeTagKeys = setOf() 83 | ) -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/Feature.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Subset of a feature as defined in the iD editor 4 | * https://github.com/ideditor/schema-builder#preset-schema 5 | * with only the fields helpful for the dictionary */ 6 | interface Feature { 7 | /** unique identifier for this feature */ 8 | val id: String 9 | /** tags an element must have to match this feature */ 10 | val tags: Map 11 | /** a list of possible geometry types for this feature */ 12 | val geometry: List 13 | /** primary name */ 14 | val name: String get() = names[0] 15 | /** icon representing the feature */ 16 | val icon: String? 17 | /** url to image representing the feature. Usually used for brands features */ 18 | val imageURL: String? 19 | 20 | /** primary name + aliases */ 21 | val names: List 22 | /** Additional search terms or keywords to find this feature */ 23 | val terms: List 24 | /** A list of ISO 3166-1 alpha 2 or 3-letter country codes in which this feature is 25 | * available. Empty if it is available everywhere. */ 26 | val includeCountryCodes: List 27 | /** A list of ISO 3166-1 alpha 2 or 3-letter country codes in which this feature is 28 | * not available. Empty if it available everywhere. */ 29 | val excludeCountryCodes: List 30 | /** Whether this feature should be searchable. E.g. deprecated or generic features are not 31 | * searchable. */ 32 | val isSearchable: Boolean 33 | /** A number that ranks this preset against others that match the feature. */ 34 | val matchScore: Float 35 | /** tags that are added to the element when selecting this feature. This can differ from [tags], as those are 36 | * just the minimum tags necessary to match this feature. */ 37 | val addTags: Map 38 | /** tags that are removed from the element when deselecting this feature. */ 39 | val removeTags: Map 40 | /** Regexes for keys of tags which should not be overwritten by [addTags] when selecting a 41 | * feature. */ 42 | val preserveTags: List 43 | /** primary names + aliases in all lowercase with stripped diacritics */ 44 | val canonicalNames: List 45 | /** Additional search terms or keywords in all lowercase with stripped diacritics */ 46 | val canonicalTerms: List 47 | 48 | /** Whether this feature is a brand feature i.e. from the NSI */ 49 | val isSuggestion: Boolean 50 | /** ISO 639 language code of this feature. `null` if it isn't localized. */ 51 | val language: String? 52 | 53 | /** Keys an element must have to match this feature, regardless of what is its value. E.g. the "disused amenity" 54 | * feature matches with all elements with tags that have the `disused:amenity` key set to any value. */ 55 | val tagKeys: Set 56 | /** Keys that are added to the element when selecting this feature. This can differ from [tagKeys], as those are 57 | * just the minimum keys necessary to match this feature. If the key is not already present, the value should be set 58 | * to `"yes"`. */ 59 | val addTagKeys: Set 60 | /** Keys that are removed from the element when deselecting this feature, regardless of which value was set. */ 61 | val removeTagKeys: Set 62 | } 63 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/IDPresetsTranslationJsonParser.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlinx.io.Source 4 | import kotlinx.serialization.ExperimentalSerializationApi 5 | import kotlinx.serialization.json.* 6 | import kotlinx.serialization.json.io.decodeFromSource 7 | 8 | /** Parses a file from 9 | * https://github.com/openstreetmap/id-tagging-schema/tree/main/dist/translations 10 | * , given the base features are already parsed. 11 | */ 12 | internal class IDPresetsTranslationJsonParser { 13 | fun parse( 14 | content: String, language: String?, baseFeatures: Map 15 | ): List = 16 | parse(Json.decodeFromString(content), language, baseFeatures) 17 | 18 | @OptIn(ExperimentalSerializationApi::class) 19 | fun parse( 20 | source: Source, language: String?, baseFeatures: Map 21 | ): List = 22 | parse(Json.decodeFromSource(source), language, baseFeatures) 23 | 24 | private fun parse( 25 | json: JsonObject, language: String?, baseFeatures: Map 26 | ): List { 27 | val translations = json.jsonObject.entries.firstOrNull()?.value?.jsonObject 28 | ?.get("presets")?.jsonObject 29 | ?.get("presets")?.jsonObject 30 | ?: return emptyList() 31 | 32 | val localizedFeatures = HashMap(translations.size) 33 | translations.entries.forEach { (key, value) -> 34 | val f = parseFeature(baseFeatures[key], language, value.jsonObject) 35 | if (f != null) localizedFeatures[key] = f 36 | } 37 | 38 | for (baseFeature in baseFeatures.values) { 39 | val name = baseFeature.names.firstOrNull() ?: continue 40 | val isPlaceholder = name.startsWith("{") && name.endsWith("}") 41 | if (!isPlaceholder) continue 42 | val placeholderId = name.substring(1, name.length - 1) 43 | val localizedFeature = localizedFeatures[placeholderId] ?: continue 44 | localizedFeatures[baseFeature.id] = LocalizedFeature( 45 | p = baseFeature, 46 | language = language, 47 | names = localizedFeature.names, 48 | terms = localizedFeature.terms 49 | ) 50 | } 51 | 52 | return localizedFeatures.values.toList() 53 | } 54 | 55 | private fun parseFeature(feature: BaseFeature?, language: String?, localization: JsonObject): LocalizedFeature? { 56 | if (feature == null) return null 57 | 58 | val name = localization["name"]?.jsonPrimitive?.contentOrNull.orEmpty() 59 | 60 | val aliases = localization["aliases"]?.jsonPrimitive?.content 61 | .orEmpty() 62 | .lineSequence() 63 | 64 | val names = (sequenceOf(name) + aliases) 65 | .map { it.trim() } 66 | .filter { it.isNotEmpty() } 67 | .toList() 68 | 69 | val terms = localization["terms"]?.jsonPrimitive?.content 70 | .orEmpty() 71 | .splitToSequence(",") 72 | .map { it.trim() } 73 | .filter { it.isNotEmpty() } 74 | .toList() 75 | 76 | return LocalizedFeature( 77 | p = feature, 78 | language = language, 79 | names = names, 80 | terms = terms 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/IDPresetsTranslationJsonParserTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.assertEquals 4 | import kotlin.test.assertTrue 5 | import kotlin.test.Test 6 | import io.ktor.client.* 7 | import io.ktor.client.engine.cio.* 8 | import io.ktor.client.request.get 9 | import io.ktor.client.statement.bodyAsText 10 | import kotlinx.coroutines.runBlocking 11 | 12 | class IDPresetsTranslationJsonParserTest { 13 | @Test 14 | fun load_features_and_localization() { 15 | val features = parseResource("one_preset_min.json", "localizations.json") 16 | assertEquals(1, features.size) 17 | 18 | val feature = features[0] 19 | assertEquals("some/id", feature.id) 20 | assertEquals(mapOf("a" to "b", "c" to "d"), feature.tags) 21 | assertEquals(listOf(GeometryType.POINT), feature.geometry) 22 | assertEquals("bar", feature.name) 23 | assertEquals(listOf("bar", "one", "two", "three"), feature.names) 24 | assertEquals(listOf("a", "b"), feature.terms) 25 | } 26 | 27 | @Test 28 | fun load_features_and_localization_defaults() { 29 | val features = parseResource("one_preset_min.json", "localizations_min.json") 30 | assertEquals(1, features.size) 31 | 32 | val feature = features[0] 33 | assertEquals("some/id", feature.id) 34 | assertEquals(mapOf("a" to "b", "c" to "d"), feature.tags) 35 | assertEquals(listOf(GeometryType.POINT), feature.geometry) 36 | assertEquals("bar", feature.name) 37 | assertTrue(feature.terms.isEmpty()) 38 | } 39 | 40 | @Test 41 | fun load_features_and_localization_with_placeholder_name() { 42 | val features = parseResource("one_preset_with_placeholder_name.json", "localizations.json") 43 | val featuresById = features.associateBy { it.id } 44 | assertEquals(2, features.size) 45 | 46 | val feature = featuresById["some/id-dingsdongs"] 47 | assertEquals("some/id-dingsdongs", feature?.id) 48 | assertEquals(mapOf("a" to "b", "c" to "d"), feature?.tags) 49 | assertEquals(listOf(GeometryType.POINT), feature?.geometry) 50 | assertEquals("bar", feature?.name) 51 | assertEquals(listOf("bar", "one", "two", "three"), feature?.names) 52 | assertEquals(listOf("a", "b"), feature?.terms) 53 | } 54 | 55 | @Test 56 | fun parse_some_real_data() = runBlocking { 57 | val client = HttpClient(CIO) { expectSuccess = true } 58 | 59 | val presets = client 60 | .get("https://raw.githubusercontent.com/openstreetmap/id-tagging-schema/main/dist/presets.json") 61 | .bodyAsText() 62 | 63 | val features = IDPresetsJsonParser() 64 | .parse(presets) 65 | .associateBy { it.id } 66 | 67 | val translations = client 68 | .get("https://raw.githubusercontent.com/openstreetmap/id-tagging-schema/main/dist/translations/de.json") 69 | .bodyAsText() 70 | 71 | val translatedFeatures = IDPresetsTranslationJsonParser().parse(translations, "de-DE", features) 72 | // should not crash etc 73 | assertTrue(translatedFeatures.size > 1000) 74 | } 75 | 76 | private fun parseResource(presetsFile: String, translationsFile: String): List { 77 | val baseFeatures = useResource(presetsFile) { 78 | IDPresetsJsonParser().parse(it) 79 | }.associateBy { it.id } 80 | 81 | return useResource(translationsFile) { 82 | IDPresetsTranslationJsonParser().parse(it, "en", baseFeatures) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/StartsWithStringTree.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Index that makes finding strings that start with characters very efficient. 4 | * It sorts the strings into a tree structure with configurable depth. 5 | * 6 | * It is threadsafe because it is immutable. 7 | * 8 | * For the strings ["f", "foobar", "foo", "fu", "funicular"], the tree may internally look f.e. 9 | * like this: 10 | * 11 | * ``` 12 | * f -> 13 | * [ "f" ] 14 | * o -> 15 | * o -> 16 | * [ "foobar", "foo", ...] 17 | * u -> 18 | * [ "fu", "funicular", ... ] 19 | * ``` 20 | * */ 21 | internal class StartsWithStringTree 22 | /** Create this index with the given strings. 23 | * 24 | * The generated tree will have a max depth of maxDepth and another depth is not added to the 25 | * tree if there are less than minContainerSize strings in one tree node. 26 | */ 27 | constructor( 28 | strings: Collection, 29 | maxDepth: Int = 16, 30 | minContainerSize: Int = 16 31 | ) { 32 | private val root: Node = 33 | buildTree(strings, 0, maxDepth.coerceAtLeast(0), minContainerSize.coerceAtLeast(1)) 34 | 35 | /** Get all strings which start with the given string */ 36 | fun getAll(startsWith: String): List = 37 | root.getAll(startsWith, 0) 38 | 39 | private class Node(val children: Map, val strings: Collection) { 40 | 41 | /** Get all strings that start with the given string */ 42 | fun getAll(startsWith: String, offset: Int): List { 43 | if (startsWith.isEmpty()) return emptyList() 44 | 45 | val result = ArrayList() 46 | for ((char, childNode) in children) { 47 | if (startsWith.length <= offset || char == startsWith[offset]) { 48 | result.addAll(childNode.getAll(startsWith, offset + 1)) 49 | } 50 | } 51 | for (string in strings) { 52 | if (string.startsWith(startsWith)) result.add(string) 53 | } 54 | return result 55 | } 56 | } 57 | 58 | companion object { 59 | private fun buildTree( 60 | strings: Collection, 61 | currentDepth: Int, 62 | maxDepth: Int, 63 | minContainerSize: Int 64 | ): Node { 65 | if (currentDepth == maxDepth || strings.size < minContainerSize) { 66 | return Node(emptyMap(), strings) 67 | } 68 | 69 | val stringsByCharacter = strings.groupedByNthCharacter(currentDepth) 70 | val children = HashMap(stringsByCharacter.size) 71 | 72 | for ((char, stringsForChar) in stringsByCharacter) { 73 | val c = char ?: continue 74 | val child = buildTree(stringsForChar, currentDepth + 1, maxDepth, minContainerSize) 75 | children[c] = child 76 | } 77 | val remainingStrings = stringsByCharacter[null].orEmpty() 78 | val compactChildren = if (children.isEmpty()) emptyMap() else children 79 | return Node(compactChildren, remainingStrings) 80 | } 81 | 82 | /** returns the given strings grouped by their nth character. Strings whose length is shorter 83 | * or equal to nth go into the "null" group. */ 84 | private fun Collection.groupedByNthCharacter(nth: Int): Map> { 85 | val result = HashMap>() 86 | for (string in this) { 87 | val c = if (string.length > nth) string[nth] else null 88 | if (!result.containsKey(c)) result[c] = ArrayList() 89 | result[c]?.add(string) 90 | } 91 | return result 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 7.1.0 4 | 5 | Support finding brand features by terms (#29) 6 | 7 | # 7.0 8 | 9 | Support wildcards (`*`) for tag values (#16, #28). 10 | 11 | See the new properties of a `Feature` `tagKeys`, `addTagKeys`, `removeTagKeys`. This is a breaking change because the 12 | data class was changed to accommodate these new values. 13 | 14 | # 6.2 15 | 16 | - support proper stream parsing using (kotlinx-io's) `Source` 17 | - fix `FeatureDictionary.getById` didn't always exclude country-specific results when the `country` 18 | parameter was `null` even though it was documented as such 19 | 20 | # 6.1 21 | 22 | - Fix crash when single presets were translated only partially for a dialect (#26), by @logan12358 23 | - Aliases and terms of presets in the parent language are now merged into any dialects (#26) 24 | 25 | # 6.0 26 | 27 | The library is now a Kotlin Multiplatform library. This is a breaking API change. There is also no separate artifact for Android anymore. 28 | 29 | # 5.2 30 | 31 | - Add support placeholders for preset names (breaking change in [v5.0.0](https://github.com/ideditor/schema-builder/blob/main/CHANGELOG.md#510) of iD presets schema) 32 | - When searching by term, include results that match with the tag value at the bottom of the result list. (iD has the same behavior) 33 | 34 | ## 5.1 35 | 36 | - add property `boolean isSuggestion` to `Feature` to be able to tell if a feature is a brand feature or not 37 | - add method to get a feature by its id to `FeatureDictionary` 38 | 39 | ## 5.0 40 | 41 | Added support for aliases of presets. They are treated similarily as alternative names. 42 | 43 | ## 4.1 44 | 45 | Added support for scripts. 46 | 47 | E.g. there can be localization of presets in "bg" (Bulgarian) and also "bg-Cyrl" (Bulgarian in Cryllic). 48 | 49 | ## 4.0 50 | 51 | Brand features are becoming too big. 52 | 53 | So now, brand features are loaded lazily per-country. I.e. additionally to a `presets.json`, there can be `presets-US.json` (using ISO 3166-1 alpha2 codes) and even `presets-US-NY.json` (ISO 3166-2) in the same directory, which will be loaded on demand. This functionality is used in StreetComplete. 54 | 55 | ## 3.0 56 | 57 | Support for NSI brand names (v6.x). Pass the path to the NSI presets as second parameter to the `create` function. 58 | 59 | Note that the NSI presets are actually not on the root level of https://github.com/osmlab/name-suggestion-index/blob/main/dist/presets/nsi-id-presets.json but in the "presets" object. The library expects a presets.json to have all the presets at the root level. 60 | 61 | ## 2.0 62 | 63 | - dictionary now uses indices for both the lookup by tags and the lookup by term, which speeds up individual lookups somewhat. 64 | - added support for fallback locales. You can now specify several locales in which to search for a term 65 | - added support for presets that are available everywhere **except** in certain countries 66 | - added support for filtering results by term whether they are suggestions (brand names) or not 67 | - uses new source for presets and translations (iD presets have been outsourced into an own repository) that has a slightly different format 68 | - now, Feature objects are returned (not Match) objects in lookups which contain most informations of a preset in iD format 69 | - added possibility to add (and thus merge) presets from several directories 70 | 71 | ## 1.2 72 | 73 | - enable to also either exclusively search for presets that are suggestions (=brand names) and to exclusively search for presets that are not suggestions. (`QueryByTagBuilder::isSuggestion(Boolean)`) 74 | - when searching by tags and the search is not limited by locale, sort those matches further up in the list of search results that are also not limited by locale 75 | - when searching by tags, sort those matches further up in the list of search results of whose `addTags` match with more of the given tags 76 | - internally use LinkedHashMap. This should have no effect on pure Java. On Android API 20+, this should sort presets that were defined further up in the `presets.json` also further up in the list of results if all other sort criteria are the same for any two matches 77 | 78 | ## 1.1 79 | 80 | - correct the gradle script in the README 81 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/IDLocalizedFeatureCollection.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Localized feature collection sourcing from iD presets defined in JSON. 4 | * 5 | * The base path is defined via the given FileAccessAdapter. In the base path, it is expected that 6 | * there is a presets.json which includes all the features. The translations are expected to be 7 | * located in the same directory named like e.g. de.json, pt-BR.json etc. */ 8 | internal class IDLocalizedFeatureCollection( 9 | private val fileAccess: ResourceAccessAdapter 10 | ) : LocalizedFeatureCollection { 11 | // featureId -> Feature 12 | private val featuresById: LinkedHashMap 13 | 14 | // language -> lazy { localized features } 15 | private val localizedFeaturesList = HashMap>>() 16 | 17 | // languages -> lazy { featureId -> Feature } 18 | private val localizedFeatures = HashMap, Lazy>>() 19 | 20 | init { 21 | featuresById = loadFeatures().associateByTo(LinkedHashMap()) { it.id } 22 | } 23 | 24 | override fun getAll(languages: List): Collection { 25 | return getOrLoadLocalizedFeatures(languages).values 26 | } 27 | 28 | override fun get(id: String, languages: List): Feature? { 29 | return getOrLoadLocalizedFeatures(languages)[id] 30 | } 31 | 32 | private fun loadFeatures(): List = 33 | fileAccess.open(FEATURES_FILE).use { IDPresetsJsonParser().parse(it) } 34 | 35 | private fun getOrLoadLocalizedFeatures(languages: List): LinkedHashMap = 36 | localizedFeatures.getOrPut(languages) { lazy { loadLocalizedFeatures(languages) } }.value 37 | 38 | private fun loadLocalizedFeatures(languages: List): LinkedHashMap { 39 | val result = LinkedHashMap(featuresById.size) 40 | for (language in languages.asReversed()) { 41 | if (language != null) { 42 | for (languageComponent in language.getLanguageComponents()) { 43 | val features = getOrLoadLocalizedFeaturesList(languageComponent) 44 | for (feature in features) { 45 | result[feature.id] = feature.mergedWith(result[feature.id]) 46 | } 47 | } 48 | } else { 49 | result.putAll(featuresById) 50 | } 51 | } 52 | return result 53 | } 54 | 55 | private fun getOrLoadLocalizedFeaturesList(language: String): List = 56 | localizedFeaturesList.getOrPut(language) { lazy { loadLocalizedFeaturesList(language) } }.value 57 | 58 | private fun loadLocalizedFeaturesList(language: String?): List { 59 | val filename = if (language != null) getLocalizationFilename(language) else "en.json" 60 | if (!fileAccess.exists(filename)) return emptyList() 61 | return fileAccess.open(filename).use { source -> 62 | IDPresetsTranslationJsonParser().parse(source, language, featuresById) 63 | } 64 | } 65 | 66 | companion object { 67 | private const val FEATURES_FILE = "presets.json" 68 | 69 | private fun getLocalizationFilename(language: String): String = "$language.json" 70 | } 71 | } 72 | 73 | private fun String.getLanguageComponents(): Sequence = sequence { 74 | val components = split('-') 75 | val language = components.first() 76 | yield(language) 77 | if (components.size == 1) return@sequence 78 | 79 | val others = components.subList(1, components.size) 80 | for (other in others) { 81 | yield(listOf(language, other).joinToString("-")) 82 | } 83 | yield(this@getLanguageComponents) 84 | } 85 | 86 | private fun LocalizedFeature.mergedWith(fallback: Feature?): Feature { 87 | if (fallback == null) return this 88 | 89 | val newNames = (names + fallback.names).distinct() 90 | val newTerms = (terms + fallback.terms).distinct() 91 | 92 | if (names == newNames && terms == newTerms) return this 93 | 94 | return copy(names = newNames, terms = newTerms); 95 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osmfeatures 2 | 3 | A Kotlin multiplatform dictionary of OSM map features, accessible by terms and by tags. Supported platforms are Android, JVM and iOS. 4 | 5 | Due to heavy use of indices, it is very fast. 6 | 7 | It is currently used in [StreetComplete](https://github.com/streetcomplete/streetcomplete). 8 | 9 | ## Copyright and License 10 | 11 | © 2019-2025 Tobias Zwick. This library is released under the terms of the Apache License Version 2.0. 12 | 13 | ## Usage 14 | 15 | Add [de.westnordost:osmfeatures:7.1.0](https://mvnrepository.com/artifact/de.westnordost/osmfeatures/7.1.0) as a Maven dependency or download the jar from there. 16 | 17 | ### Get the data 18 | 19 | The data for the dictionary is not maintained in this repository. 20 | It actually uses the [preset data from iD](https://github.com/openstreetmap/id-tagging-schema/blob/main/dist/presets.json), [its translations](https://github.com/openstreetmap/id-tagging-schema/tree/main/dist/translations) 21 | and optionally additionally the brand preset data from the [name suggestion index](https://github.com/osmlab/name-suggestion-index). 22 | Each are © iD contributors, licensed under the ISC license. 23 | 24 | 25 | So, just dump all the translations and the presets.json into the same directory. To be always 26 | up-to-date, it is advisable to have an automatic build task that fetches the current version of the 27 | presets from the repository. 28 | 29 | The app for which this library was developed (StreetComplete), uses the following tasks: 30 | - [UpdatePresetsTask.kt](https://github.com/streetcomplete/StreetComplete/blob/master/buildSrc/src/main/java/UpdatePresetsTask.kt) to download presets and selected translations 31 | - [UpdateNsiPresetsTask.kt](https://github.com/streetcomplete/StreetComplete/blob/master/buildSrc/src/main/java/UpdateNsiPresetsTask.kt) to download the brand presets from the [name suggestion index](https://github.com/osmlab/name-suggestion-index) 32 | 33 | ### Initialize dictionary 34 | 35 | Point the dictionary to the directory where the data is located (see above). Use `FeatureDictionary` as a singleton, as initialization takes a moment (loading files, building indices). 36 | ```kotlin 37 | val dictionary = FeatureDictionary.create(fileSystem, "path/to/data") 38 | ``` 39 | 40 | For Android, use 41 | ```kotlin 42 | val dictionary = FeatureDictionary.create(assetManager, "path/within/assets/folder/to/data") 43 | ``` 44 | 45 | If brand features from the [name suggestion index](https://github.com/osmlab/name-suggestion-index) should be included in the dictionary, you can specify the path to these presets as a third parameter. These will be loaded on-demand depending on for which countries you search for. 46 | 47 | Translations will also be loaded on demand when first querying features using a certain language. 48 | 49 | ### Find matches by tags 50 | 51 | ```kotlin 52 | val matches = dictionary.getByTags( 53 | tags = mapOf("amenity" to "bench"), // look for features that have the given tags 54 | languages = listOf("de"), // show results in German only, don't fall back to English or unlocalized results 55 | geometry = GeometryType.POINT, // limit the search to features that may be points 56 | ) 57 | 58 | 59 | // prints "Parkbank" (or something like this) 60 | // or null if no preset for amenity=bench exists that is localized to German 61 | println(matches[0]?.getName()) 62 | ``` 63 | 64 | ### Find matches by search word 65 | 66 | ```kotlin 67 | val matches = dictionary.getByTerm( 68 | term = "Bank", // look for features matching "Bank" 69 | languages = listOf("de", null), // show results in German or fall back to unlocalized results 70 | // (brand features are usually not localized) 71 | country = "DE", // also include things (brands) that only exist in Germany 72 | geometry = GeometryType.AREA, // limit the search to features that may be areas 73 | ) 74 | // result sequence will have matches with at least amenity=bank, but not amenity=bench because it is a point-feature 75 | // if the dictionary contains also brand presets, e.g. "Deutsche Bank" will certainly also be amongst the results 76 | ``` 77 | 78 | ### Find by id 79 | 80 | ```kotlin 81 | val match = dictionary.getById( 82 | id = "amenity/bank", 83 | languages = listOf("de", "en-US", null), // show results in German, otherwise fall back to American 84 | // English or otherwise unlocalized results 85 | country = "DE", // also include things (brands) that only exist in Germany 86 | ) 87 | ``` 88 | 89 | ### Builders 90 | 91 | For a more convenient interface on Java, the above functions continue to be available as builders, 92 | e.g. 93 | 94 | ```java 95 | List matches = dictionary 96 | .byTags(Map.of("amenity", "bench")) 97 | .forGeometry(GeometryType.POINT) 98 | .inLanguage("de") 99 | .find(); 100 | ``` 101 | 102 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/IDPresetsJsonParserTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.cio.CIO 5 | import io.ktor.client.request.get 6 | import io.ktor.client.statement.bodyAsText 7 | import kotlinx.coroutines.runBlocking 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | import kotlin.test.assertFalse 11 | import kotlin.test.assertTrue 12 | 13 | class IDPresetsJsonParserTest { 14 | @Test 15 | fun load_features_only() { 16 | val features = parseResource("one_preset_full.json") 17 | assertEquals(1, features.size) 18 | 19 | val feature = features[0] 20 | assertEquals("some/id", feature.id) 21 | assertEquals(mapOf("a" to "b", "c" to "d"), feature.tags) 22 | assertEquals( 23 | listOf( 24 | GeometryType.POINT, 25 | GeometryType.VERTEX, 26 | GeometryType.LINE, 27 | GeometryType.AREA, 28 | GeometryType.RELATION 29 | ), 30 | feature.geometry 31 | ) 32 | assertEquals(listOf("DE", "GB"), feature.includeCountryCodes) 33 | assertEquals(listOf("IT"), feature.excludeCountryCodes) 34 | assertEquals("foo", feature.name) 35 | assertEquals("abc", feature.icon) 36 | assertEquals("someurl", feature.imageURL) 37 | assertEquals(listOf("foo", "one", "two"), feature.names) 38 | assertEquals(listOf("1", "2"), feature.terms) 39 | assertEquals(0.5f, feature.matchScore, 0.001f) 40 | assertFalse(feature.isSearchable) 41 | assertEquals(mapOf("e" to "f"), feature.addTags) 42 | assertEquals(mapOf("d" to "g"), feature.removeTags) 43 | assertEquals(listOf("^name"), feature.preserveTags.map { it.pattern }) 44 | } 45 | 46 | @Test 47 | fun load_features_only_defaults() { 48 | val features = parseResource("one_preset_min.json") 49 | assertEquals(1, features.size) 50 | 51 | val feature = features[0] 52 | assertEquals("some/id", feature.id) 53 | assertEquals(mapOf("a" to "b", "c" to "d"), feature.tags) 54 | assertEquals(listOf(GeometryType.POINT), feature.geometry) 55 | assertTrue(feature.includeCountryCodes.isEmpty()) 56 | assertTrue(feature.excludeCountryCodes.isEmpty()) 57 | assertEquals("", feature.name) 58 | assertEquals(null, feature.icon) 59 | assertEquals(null, feature.imageURL) 60 | assertEquals(1, feature.names.size) 61 | assertTrue(feature.terms.isEmpty()) 62 | assertEquals(1.0f, feature.matchScore, 0.001f) 63 | assertTrue(feature.isSearchable) 64 | assertEquals(feature.addTags, feature.tags) 65 | assertEquals(feature.addTags, feature.removeTags) 66 | assertEquals(emptyList(), feature.preserveTags) 67 | } 68 | 69 | @Test 70 | fun load_features_unsupported_location_set() { 71 | val features = parseResource("one_preset_unsupported_location_set.json") 72 | assertEquals(2, features.size) 73 | assertEquals("some/ok", features[0].id) 74 | assertEquals("another/ok", features[1].id) 75 | } 76 | 77 | @Test 78 | fun load_features_no_wildcards_in_keys() { 79 | val features = parseResource("preset_wildcard_in_key.json") 80 | assertTrue(features.isEmpty()) 81 | } 82 | 83 | @Test 84 | fun load_feature_with_wildcard_in_value() { 85 | val features = parseResource("preset_wildcard_in_value.json") 86 | 87 | assertEquals(1, features.size) 88 | 89 | val feature = features[0] 90 | assertEquals(mapOf("a" to "1"), feature.tags) 91 | assertEquals(mapOf("a" to "2"), feature.addTags) 92 | assertEquals(mapOf("a" to "3"), feature.removeTags) 93 | assertEquals(setOf("x"), feature.tagKeys) 94 | assertEquals(setOf("y"), feature.addTagKeys) 95 | assertEquals(setOf("z"), feature.removeTagKeys) 96 | } 97 | 98 | @Test 99 | fun parse_real_data() = runBlocking { 100 | val client = HttpClient(CIO) { expectSuccess = true } 101 | 102 | val presets = client 103 | .get("https://raw.githubusercontent.com/openstreetmap/id-tagging-schema/main/dist/presets.json") 104 | .bodyAsText() 105 | 106 | val features = IDPresetsJsonParser().parse(presets) 107 | // should not crash etc 108 | assertTrue(features.size > 1000) 109 | } 110 | 111 | @Test 112 | fun parse_real_brand_data() = runBlocking { 113 | val client = HttpClient(CIO) { expectSuccess = true } 114 | 115 | val presets = client 116 | .get("https://cdn.jsdelivr.net/npm/name-suggestion-index@latest/dist/presets/nsi-id-presets.min.json") 117 | .bodyAsText() 118 | 119 | val features = IDPresetsJsonParser().parse(presets) 120 | // should not crash etc 121 | assertTrue(features.size > 20000) 122 | } 123 | 124 | private fun parseResource(file: String): List = 125 | useResource(file) { IDPresetsJsonParser().parse(it) } 126 | } 127 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/IDPresetsJsonParser.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlinx.io.Source 4 | import kotlinx.serialization.ExperimentalSerializationApi 5 | import kotlinx.serialization.json.* 6 | import kotlinx.serialization.json.io.decodeFromSource 7 | 8 | /** Parses this file 9 | * [...](https://raw.githubusercontent.com/openstreetmap/id-tagging-schema/main/dist/presets.json) 10 | * into list of Features. */ 11 | internal class IDPresetsJsonParser(private val isSuggestion: Boolean = false) { 12 | 13 | @OptIn(ExperimentalSerializationApi::class) 14 | fun parse(source: Source): List = 15 | parse(Json.decodeFromSource(source)) 16 | 17 | fun parse(content: String): List = 18 | parse(Json.decodeFromString(content)) 19 | 20 | private fun parse(json: JsonObject): List { 21 | // the presets in the nsi presets are one level down (in preset object) 22 | val root = json["presets"]?.jsonObject ?: json 23 | return root.mapNotNull { (key, value) -> parseFeature(key, value.jsonObject) } 24 | } 25 | 26 | private fun parseFeature(id: String, p: JsonObject): BaseFeature? { 27 | val tags = p["tags"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content }.orEmpty() 28 | val addTags = p["addTags"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: tags 29 | val removeTags = p["removeTags"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: addTags 30 | // drop features with "*" in key, because they never describe an actual feature but group(s) of features 31 | if (tags.keys.any { it.contains("*") }) return null 32 | if (addTags.keys.any { it.contains("*") }) return null 33 | if (removeTags.keys.any { it.contains("*") }) return null 34 | // also dropping features with empty tags (generic point, line, relation) 35 | if (tags.isEmpty()) return null 36 | 37 | // the `if (tags.values.contains("*"))` is a memory improvement: For the vast majority of features, `keys`, 38 | // `addKeys`, `removeKeys` are empty, so they can all link to the same instance (`EmptySet`) instead of 39 | // each keeping an instance of `LinkedHashSet` around for all 40 | val keys = if (tags.values.contains("*")) tags.filterValues { it == "*" }.keys else emptySet() 41 | val addKeys = if (addTags.values.contains("*")) addTags.filterValues { it == "*" }.keys else emptySet() 42 | val removeKeys = if (removeTags.values.contains("*")) removeTags.filterValues { it == "*" }.keys else emptySet() 43 | 44 | val tagsNoWildcards = tags.filterValues { it != "*" } 45 | val addTagsNoWildcards = addTags.filterValues { it != "*" } 46 | val removeTagsNoWildcards = removeTags.filterValues { it != "*" } 47 | 48 | val geometry = p["geometry"]?.jsonArray?.map { 49 | GeometryType.valueOf(it.jsonPrimitive.content.uppercase()) 50 | }.orEmpty() 51 | 52 | val name = p["name"]?.jsonPrimitive?.contentOrNull ?: "" 53 | val icon = p["icon"]?.jsonPrimitive?.contentOrNull 54 | val imageURL = p["imageURL"]?.jsonPrimitive?.contentOrNull 55 | val names = buildList { 56 | add(name) 57 | addAll(p["aliases"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()) 58 | } 59 | val terms = p["terms"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty() 60 | 61 | val include = p["locationSet"]?.jsonObject?.get("include")?.jsonArray 62 | val exclude = p["locationSet"]?.jsonObject?.get("exclude")?.jsonArray 63 | val includeCountryCodes = 64 | if (include != null) include.parseCountryCodes() ?: return null 65 | else emptyList() 66 | val excludeCountryCodes = 67 | if (exclude != null) exclude.parseCountryCodes() ?: return null 68 | else emptyList() 69 | 70 | val searchable = p["searchable"]?.jsonPrimitive?.booleanOrNull ?: true 71 | val matchScore = p["matchScore"]?.jsonPrimitive?.floatOrNull ?: 1.0f 72 | val preserveTags = p["preserveTags"]?.jsonArray?.map { Regex(it.jsonPrimitive.content) } ?: emptyList() 73 | 74 | return BaseFeature( 75 | id = id, 76 | tags = tagsNoWildcards, 77 | geometry = geometry, 78 | icon = icon, 79 | imageURL = imageURL, 80 | names = names, 81 | terms = terms, 82 | includeCountryCodes = includeCountryCodes, 83 | excludeCountryCodes = excludeCountryCodes, 84 | isSearchable = searchable, 85 | matchScore = matchScore, 86 | isSuggestion = isSuggestion, 87 | addTags = addTagsNoWildcards, 88 | removeTags = removeTagsNoWildcards, 89 | preserveTags = preserveTags, 90 | tagKeys = keys, 91 | addTagKeys = addKeys, 92 | removeTagKeys = removeKeys, 93 | ) 94 | } 95 | } 96 | 97 | private val ISO3166_2 = Regex("[A-Z]{2}(-[A-Z0-9]{1,3})?") 98 | 99 | private fun JsonArray.parseCountryCodes(): List? { 100 | // for example a lat,lon pair to denote a location with radius. Not supported. 101 | if (any { it is JsonArray }) return null 102 | 103 | val list = map { it.jsonPrimitive.content } 104 | val result = ArrayList(list.size) 105 | for (item in list) { 106 | val cc = item.uppercase() 107 | // don't need this, 001 stands for "whole world" 108 | if (cc == "001") continue 109 | // ISO-3166-2 codes are supported but not m49 code such as "150" or geojsons like "city_national_bank_fl.geojson" 110 | if (!cc.matches(ISO3166_2)) return null 111 | result.add(cc) 112 | } 113 | return result 114 | } 115 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/ContainedMapTree.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | /** Index that makes finding which maps are completely contained by a given map very efficient. 4 | * It sorts the maps into a tree structure with configurable depth. 5 | * 6 | * It is threadsafe because it is immutable. 7 | * 8 | * For example for the string maps... 9 | * ``` 10 | * [ 11 | * #1 (amenity -> bicycle_parking), 12 | * #2 (amenity -> bicycle_parking, bicycle_parking -> shed), 13 | * #3 (amenity -> bicycle_parking, bicycle_parking -> lockers), 14 | * #4 (amenity -> taxi), 15 | * #5 (shop -> supermarket), 16 | * ] 17 | * ``` 18 | * ...the tree internally looks like this: 19 | * ``` 20 | * amenity -> 21 | * bicycle_parking -> 22 | * #1 23 | * bicycle_parking -> 24 | * shed -> 25 | * #2 26 | * lockers -> 27 | * #3 28 | * taxi -> 29 | * #4 30 | * shop -> 31 | * supermarket -> 32 | * #5 33 | * ... 34 | * ``` 35 | */ 36 | internal class ContainedMapTree 37 | /** Create this index with the given maps. 38 | * 39 | * The generated tree will have a max depth of maxDepth and another depth is not added to the 40 | * tree if there are less than minContainerSize maps in one tree node. 41 | */ 42 | constructor( 43 | maps: Collection>, 44 | maxDepth: Int = 4, 45 | minContainerSize: Int = 4 46 | ) { 47 | private val root: Node = 48 | buildTree(maps, emptyList(), maxDepth.coerceAtLeast(0), minContainerSize) 49 | 50 | /** Get all maps whose entries are completely contained by the given map */ 51 | fun getAll(map: Map): List> = 52 | root.getAll(map) 53 | 54 | private class Node( 55 | /** key -> (value -> Node) */ 56 | val children: Map>>, 57 | val maps: Collection> 58 | ) { 59 | /** Get all maps whose entries are all contained by the given map */ 60 | fun getAll(map: Map): List> { 61 | val result = ArrayList>() 62 | for ((key, nodesByValue) in children) { 63 | if (map.containsKey(key)) { 64 | for ((value, node) in nodesByValue) { 65 | if (value == map[key]) { 66 | result.addAll(node.getAll(map)) 67 | } 68 | } 69 | } 70 | } 71 | for (m in maps) { 72 | if (m.all { entry -> map[entry.key] == entry.value } ) { 73 | result.add(m) 74 | } 75 | } 76 | return result 77 | } 78 | } 79 | 80 | companion object { 81 | private fun buildTree( 82 | maps: Collection>, 83 | previousKeys: Collection, 84 | maxDepth: Int, 85 | minContainerSize: Int 86 | ): Node { 87 | if (previousKeys.size == maxDepth || maps.size < minContainerSize) { 88 | return Node(emptyMap(), maps) 89 | } 90 | 91 | val unsortedMaps = HashSet(maps) 92 | 93 | val mapsByKey = maps.groupByEachKey(previousKeys) 94 | 95 | /* the map should be categorized by frequent keys first and least frequent keys last. */ 96 | val sortedByCountDesc = ArrayList(mapsByKey.entries).sortedByDescending { it.value.size } 97 | 98 | val result = HashMap>>(mapsByKey.size) 99 | 100 | for ((key, mapsForKey) in sortedByCountDesc) { 101 | // a map already sorted in a certain node should not be sorted into another too 102 | (mapsForKey as MutableList).retainAll(unsortedMaps) 103 | if (mapsForKey.isEmpty()) continue 104 | 105 | val mapsByKeyValue = mapsForKey.groupByKeyValue(key) 106 | 107 | val nodesByValue = HashMap>(mapsByKeyValue.size) 108 | for ((value, nodes) in mapsByKeyValue) { 109 | val previousKeysNow = ArrayList(previousKeys) 110 | previousKeysNow.add(key) 111 | nodesByValue[value] = buildTree(nodes, previousKeysNow, maxDepth, minContainerSize) 112 | } 113 | 114 | result[key] = nodesByValue 115 | 116 | for (map in mapsForKey) { 117 | unsortedMaps.remove(map) 118 | } 119 | } 120 | 121 | return Node(result, ArrayList(unsortedMaps)) 122 | } 123 | 124 | /** returns these maps grouped by the map entry value of the given key. */ 125 | private fun Collection>.groupByKeyValue( 126 | key: K 127 | ): Map>> { 128 | val result = HashMap>>() 129 | for (map in this) { 130 | val value = map[key] ?: continue 131 | val group = result.getOrPut(value) { ArrayList() } 132 | group.add(map) 133 | } 134 | return result 135 | } 136 | 137 | /** returns these maps grouped by each of their keys (except the given ones). */ 138 | private fun Collection>.groupByEachKey( 139 | excludeKeys: Collection 140 | ): Map>> { 141 | val result = HashMap>>() 142 | for (map in this) { 143 | for (key in map.keys) { 144 | if (excludeKeys.contains(key)) continue 145 | val group = result.getOrPut(key) { ArrayList() } 146 | group.add(map) 147 | } 148 | } 149 | return result 150 | } 151 | } 152 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/IDLocalizedFeatureCollectionTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.* 4 | 5 | class IDLocalizedFeatureCollectionTest { 6 | @Test 7 | fun features_not_found_produces_runtime_exception() { 8 | assertFails { 9 | IDLocalizedFeatureCollection(object : ResourceAccessAdapter { 10 | override fun exists(name: String) = false 11 | override fun open(name: String) = throw Exception() 12 | }) 13 | } 14 | } 15 | 16 | @Test 17 | fun load_features_and_two_localizations() { 18 | val c = IDLocalizedFeatureCollection(object : ResourceAccessAdapter { 19 | override fun exists(name: String) = 20 | name in listOf("presets.json", "en.json", "de.json") 21 | 22 | override fun open(name: String) = when (name) { 23 | "presets.json" -> resource("some_presets_min.json") 24 | "en.json" -> resource("localizations_en.json") 25 | "de.json" -> resource("localizations_de.json") 26 | else -> throw Exception("File not found") 27 | } 28 | }) 29 | 30 | // getting non-localized features 31 | val notLocalized = listOf(null) 32 | val notLocalizedFeatures = c.getAll(notLocalized) 33 | assertEquals(setOf("test", "test", "test"), notLocalizedFeatures.map { it.name }.toSet()) 34 | assertEquals("test", c.get("some/id", notLocalized)?.name) 35 | assertEquals("test", c.get("another/id", notLocalized)?.name) 36 | assertEquals("test", c.get("yet/another/id", notLocalized)?.name) 37 | 38 | // getting English features 39 | val english = listOf("en") 40 | val englishFeatures = c.getAll(english) 41 | assertEquals(setOf("Bakery"), englishFeatures.map { it.name }.toSet()) 42 | assertEquals("Bakery", c.get("some/id", english)?.name) 43 | assertNull(c.get("another/id", english)) 44 | assertNull(c.get("yet/another/id", english)) 45 | 46 | // getting Germany features 47 | // this also tests if the fallback from de-DE to de works if de-DE.json does not exist 48 | val germany = listOf("de-DE") 49 | val germanyFeatures = c.getAll(germany) 50 | assertEquals(setOf("Bäckerei", "Gullideckel"), germanyFeatures.map { it.name }.toSet()) 51 | assertEquals("Bäckerei", c.get("some/id", germany)?.name) 52 | assertEquals("Gullideckel", c.get("another/id", germany)?.name) 53 | assertNull(c.get("yet/another/id", germany)) 54 | 55 | // getting features through fallback chain 56 | val languages = listOf("en", "de-DE", null) 57 | val fallbackFeatures = c.getAll(languages) 58 | assertEquals( 59 | setOf("Bakery", "Gullideckel", "test"), 60 | fallbackFeatures.map { it.name }.toSet() 61 | ) 62 | assertEquals("Bakery", c.get("some/id", languages)?.name) 63 | assertEquals("Gullideckel", c.get("another/id", languages)?.name) 64 | assertEquals("test", c.get("yet/another/id", languages)?.name) 65 | assertEquals("en", c.get("some/id", languages)?.language) 66 | assertEquals("de", c.get("another/id", languages)?.language) 67 | assertNull(c.get("yet/another/id", languages)?.language) 68 | } 69 | 70 | @Test 71 | fun load_features_and_merge_localizations() { 72 | val c = IDLocalizedFeatureCollection(object : ResourceAccessAdapter { 73 | override fun exists(name: String) = name in listOf( 74 | "presets.json", 75 | "de-AT.json", 76 | "de.json", 77 | "de-Cyrl.json", 78 | "de-Cyrl-AT.json" 79 | ) 80 | 81 | override fun open(name: String) = when (name) { 82 | "presets.json" -> resource("some_presets_min.json") 83 | "de-AT.json" -> resource("localizations_de-AT.json") 84 | "de.json" -> resource("localizations_de.json") 85 | "de-Cyrl-AT.json" -> resource("localizations_de-Cyrl-AT.json") 86 | "de-Cyrl.json" -> resource("localizations_de-Cyrl.json") 87 | else -> throw Exception("File not found") 88 | } 89 | }) 90 | 91 | // standard case - no merging 92 | val german = listOf("de") 93 | val germanFeatures = c.getAll(german) 94 | assertEquals(setOf("Bäckerei", "Gullideckel"), germanFeatures.map { it.name }.toSet()) 95 | assertEquals("Bäckerei", c.get("some/id", german)?.name) 96 | assertEquals("Gullideckel", c.get("another/id", german)?.name) 97 | assertNull(c.get("yet/another/id", german)) 98 | 99 | // merging de-AT and de 100 | // this exercises the case where only `name` or only `terms` is translated 101 | val austria = listOf("de-AT") 102 | val austrianFeatures = c.getAll(austria) 103 | assertEquals( 104 | setOf("Backhusl", "Gullideckel", "Brückle"), 105 | austrianFeatures.map { it.name }.toSet() 106 | ) 107 | assertEquals(listOf("Backhusl", "Bäckerei", "Konditorei"), c.get("some/id", austria)?.names) 108 | assertEquals(listOf("Gullideckel"), c.get("another/id", austria)?.names) 109 | assertEquals(listOf("Brückle", "Drüberrüber"), c.get("yet/another/id", austria)?.names) 110 | assertEquals(listOf("brot"), c.get("some/id", austria)?.terms) 111 | assertEquals(listOf("ninja", "turtles"), c.get("another/id", austria)?.terms) 112 | assertEquals(emptyList(), c.get("yet/another/id", austria)?.terms) 113 | 114 | // merging scripts 115 | val cryllic = listOf("de-Cyrl") 116 | assertEquals("бацкхаус", c.get("some/id", cryllic)?.name) 117 | val cryllicAustria = listOf("de-Cyrl-AT") 118 | assertEquals("бацкхусл", c.get("some/id", cryllicAustria)?.name) 119 | } 120 | 121 | @Test 122 | fun load_features_with_placeholder_names() { 123 | val c = IDLocalizedFeatureCollection(object : ResourceAccessAdapter { 124 | override fun exists(name: String) = 125 | name in listOf("presets.json", "en.json") 126 | 127 | override fun open(name: String) = when (name) { 128 | "presets.json" -> resource("one_preset_with_placeholder_name.json") 129 | "en.json" -> resource("localizations.json") 130 | else -> throw Exception("File not found") 131 | } 132 | }) 133 | val some = c.get("some/id", listOf("en")) 134 | assertNotNull(some) 135 | assertEquals("bar", some.name) 136 | assertEquals(listOf("bar", "one", "two", "three"), some.names) 137 | assertEquals(listOf("a", "b"), some.terms) 138 | 139 | val dingsdongs = c.get("some/id-dingsdongs", listOf("en")) 140 | assertNotNull(dingsdongs) 141 | assertEquals("bar", dingsdongs.name) 142 | assertEquals(listOf("bar", "one", "two", "three"), dingsdongs.names) 143 | assertEquals(listOf("a", "b"), dingsdongs.terms) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. -------------------------------------------------------------------------------- /src/commonMain/kotlin/de/westnordost/osmfeatures/FeatureDictionary.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlinx.io.files.FileSystem 4 | 5 | class FeatureDictionary internal constructor( 6 | private val featureCollection: LocalizedFeatureCollection, 7 | private val brandFeatureCollection: PerCountryFeatureCollection? 8 | ) { 9 | private val brandNamesIndexes = HashMap, Lazy>() 10 | private val brandTermsIndexes = HashMap, Lazy>() 11 | private val brandTagsIndexes = HashMap, Lazy>() 12 | 13 | // language list -> index 14 | private val tagsIndexes = HashMap, Lazy>() 15 | private val namesIndexes = HashMap, Lazy>() 16 | private val termsIndexes = HashMap, Lazy>() 17 | private val tagValuesIndexes = HashMap, Lazy>() 18 | 19 | init { 20 | // build indices for default language 21 | getTagsIndex(listOf(defaultLanguage(), null)) 22 | getNamesIndex(listOf(defaultLanguage(), null)) 23 | getTermsIndex(listOf(defaultLanguage(), null)) 24 | } 25 | 26 | //region Get by id 27 | 28 | /** Builder to find a feature by id. See [getById] */ 29 | fun byId(id: String) = QueryByIdBuilder(id) 30 | 31 | /** 32 | * Returns the feature associated with the given id or `null` if it does not exist 33 | * 34 | * @param id 35 | * feature id 36 | * 37 | * @param languages 38 | * Optional. List of IETF language tags of languages in which the result should be localized. 39 | * 40 | * Several languages can be specified to each fall back to if a translation does not exist in 41 | * the language before that. For example, specify `listOf("ca-ES","es", null)` if results in 42 | * Catalan are preferred, Spanish is also fine or otherwise use unlocalized results (`null`). 43 | * 44 | * Defaults to `listOf(, null)`, i.e. unlocalized results are 45 | * included by default. (Brand features are usually not localized.) 46 | * 47 | * @param country 48 | * Optional. ISO 3166-1 alpha-2 country code (e.g. "US") or the ISO 3166-2 (e.g. "US-NY") of 49 | * the country/state the element is in. 50 | * If `null`, will only return matches that are *not* country-specific. 51 | * */ 52 | fun getById( 53 | id: String, 54 | languages: List? = null, 55 | country: String? = null 56 | ): Feature? = 57 | featureCollection.get(id, languages ?: listOf(defaultLanguage(), null)) 58 | ?.takeIf { it.matches(country) } 59 | ?: brandFeatureCollection?.get(id, dissectCountryCode(country)) 60 | 61 | //endregion 62 | 63 | //region Query by tags 64 | 65 | /** Builder to find matches by a set of tags. See [getByTags] */ 66 | fun byTags(tags: Map) = QueryByTagBuilder(tags) 67 | 68 | /** 69 | * Search for features by a set of tags. 70 | * 71 | * @param tags feature tags 72 | * 73 | * @param geometry 74 | * Optional. If not `null`, only returns features that match the given geometry type. 75 | * 76 | * @param languages 77 | * Optional. List of IETF language tags of languages in which the result should be localized. 78 | * 79 | * Several languages can be specified to each fall back to if a translation does not exist in 80 | * the language before that. For example, specify `listOf("ca-ES","es", null)` if results in 81 | * Catalan are preferred, Spanish is also fine or otherwise use unlocalized results (`null`). 82 | * 83 | * Defaults to `listOf(, null)`, i.e. unlocalized results are 84 | * included by default. (Brand features are usually not localized.) 85 | * 86 | * @param country 87 | * Optional. ISO 3166-1 alpha-2 country code (e.g. "US") or the ISO 3166-2 (e.g. "US-NY") of 88 | * the country/state the element is in. 89 | * If `null`, will only return matches that are *not* country-specific. 90 | * 91 | * @param isSuggestion 92 | * Optional. `true` to *only* include suggestions, `false` to *not* include suggestions 93 | * or `null` to include any in the result. 94 | * Suggestions are brands, like 7-Eleven, Santander etc. 95 | * 96 | * @return 97 | * A list of dictionary entries that match the given tags or an empty list if nothing is found. 98 | * 99 | * For a set of tags that match a less specific feature and a more specific feature, only the 100 | * more specific feature is returned. 101 | * E.g. `amenity=doctors` + `healthcare:speciality=cardiology` matches *only* a Cardiologist, 102 | * not a Doctor's Office in general. 103 | * 104 | * In rare cases, a set of tags may match multiple primary features, such as for 105 | * tag combinations like `shop=deli` + `amenity=cafe`. This somewhat frowned upon tagging 106 | * practice is the only reason why this method returns a list. 107 | * */ 108 | fun getByTags( 109 | tags: Map, 110 | languages: List? = null, 111 | country: String? = null, 112 | geometry: GeometryType? = null, 113 | isSuggestion: Boolean? = null 114 | ): List { 115 | if (tags.isEmpty()) return emptyList() 116 | 117 | val languagesOrDefault = languages ?: listOf(defaultLanguage(), null) 118 | 119 | val foundFeatures = mutableListOf() 120 | if (isSuggestion == null || !isSuggestion) { 121 | foundFeatures.addAll(getTagsIndex(languagesOrDefault).getAll(tags)) 122 | } 123 | if (isSuggestion == null || isSuggestion) { 124 | val countryCodes = dissectCountryCode(country) 125 | foundFeatures.addAll(getBrandTagsIndex(countryCodes).getAll(tags)) 126 | } 127 | foundFeatures.removeAll { !it.matches(geometry) || !it.matches(country) } 128 | 129 | if (foundFeatures.size > 1) { 130 | // only return of each category the most specific thing. I.e. will return 131 | // McDonald's only instead of McDonald's,Fast-Food Restaurant,Amenity 132 | val removeIds = HashSet() 133 | for (feature in foundFeatures) { 134 | removeIds.addAll(getParentCategoryIds(feature.id)) 135 | } 136 | if (removeIds.isNotEmpty()) { 137 | foundFeatures.removeAll { it.id in removeIds } 138 | } 139 | } 140 | return foundFeatures.sortedWith(Comparator { a: Feature, b: Feature -> 141 | // 1. features with more matching tags first 142 | val tagOrder = (b.tags.size + b.tagKeys.size) - (a.tags.size + a.tagKeys.size) 143 | if (tagOrder != 0) { 144 | return@Comparator tagOrder 145 | } 146 | 147 | // 2. if search is not limited by language, return matches not limited by language first 148 | if (languagesOrDefault.size == 1 && languagesOrDefault[0] == null) { 149 | val languageOrder = ( 150 | (b.includeCountryCodes.isEmpty() && b.excludeCountryCodes.isEmpty()).toInt() 151 | - (a.includeCountryCodes.isEmpty() && a.excludeCountryCodes.isEmpty()).toInt() 152 | ) 153 | if (languageOrder != 0) return@Comparator languageOrder 154 | } 155 | 156 | // 3. features with more matching tags in addTags first 157 | // https://github.com/openstreetmap/iD/issues/7927 158 | val numberOfMatchedAddTags = ( 159 | tags.entries.count { it in b.addTags.entries } 160 | - tags.entries.count { it in a.addTags.entries } 161 | ) 162 | if (numberOfMatchedAddTags != 0) return@Comparator numberOfMatchedAddTags 163 | 164 | // 4. features with higher matchScore first 165 | return@Comparator (100 * b.matchScore - 100 * a.matchScore).toInt() 166 | }) 167 | } 168 | 169 | //endregion 170 | 171 | //region Query by term 172 | 173 | /** Builder to find matches by given search word. See [getByTerm] */ 174 | fun byTerm(term: String) = QueryByTermBuilder(term) 175 | 176 | /** 177 | * Search for features by a search term. 178 | * 179 | * @param search The search term 180 | * 181 | * @param geometry 182 | * Optional. If not `null`, only returns features that match the given geometry type. 183 | * 184 | * @param languages 185 | * Optional. List of IETF language tags of languages in which the result should be localized. 186 | * 187 | * Several languages can be specified to each fall back to if a translation does not exist in 188 | * the language before that. For example, specify `listOf("ca-ES","es", null)` if results in 189 | * Catalan are preferred, Spanish is also fine or otherwise use unlocalized results (`null`). 190 | * 191 | * Defaults to `listOf(, null)`, i.e. unlocalized results are 192 | * included by default. (Brand features are usually not localized.) 193 | * 194 | * @param country 195 | * Optional. ISO 3166-1 alpha-2 country code (e.g. "US") or the ISO 3166-2 (e.g. "US-NY") of 196 | * the country/state the element is in. 197 | * If `null`, will only return matches that are *not* country-specific. 198 | * 199 | * @param isSuggestion 200 | * Optional. `true` to *only* include suggestions, `false` to *not* include suggestions 201 | * or `null` to include any in the result. 202 | * Suggestions are brands, like 7-Eleven, Santander etc. 203 | * 204 | * @return 205 | * A sequence of dictionary entries that match the search, or an empty sequence list if nothing 206 | * is found. 207 | * 208 | * Results are broadly sorted in this order: Matches with names, then with brand names, then 209 | * with terms (keywords), then with tag values. 210 | * */ 211 | fun getByTerm( 212 | search: String, 213 | languages: List? = null, 214 | country: String? = null, 215 | geometry: GeometryType? = null, 216 | isSuggestion: Boolean? = null 217 | ): Sequence { 218 | val canonicalSearch = search.canonicalize() 219 | 220 | val languagesOrDefault = languages ?: listOf(defaultLanguage(), null) 221 | 222 | val sortNames = Comparator { a: Feature, b: Feature -> 223 | // 1. exact matches first 224 | val exactMatchOrder = ( 225 | (b.names.any { it == search }).toInt() 226 | - (a.names.any { it == search }).toInt() 227 | ) 228 | if (exactMatchOrder != 0) return@Comparator exactMatchOrder 229 | 230 | // 2. exact matches case and diacritics insensitive first 231 | val cExactMatchOrder = ( 232 | (b.canonicalNames.any { it == canonicalSearch }).toInt() 233 | - (a.canonicalNames.any { it == canonicalSearch }).toInt() 234 | ) 235 | if (cExactMatchOrder != 0) return@Comparator cExactMatchOrder 236 | 237 | // 3. starts-with matches in string first 238 | val startsWithOrder = ( 239 | (b.canonicalNames.any { it.startsWith(canonicalSearch) }).toInt() 240 | - (a.canonicalNames.any { it.startsWith(canonicalSearch) }).toInt() 241 | ) 242 | if (startsWithOrder != 0) return@Comparator startsWithOrder 243 | 244 | // 4. features with higher matchScore first 245 | val matchScoreOrder = (100 * b.matchScore - 100 * a.matchScore).toInt() 246 | if (matchScoreOrder != 0) return@Comparator matchScoreOrder 247 | 248 | // 5. shorter names first 249 | return@Comparator a.name.length - b.name.length 250 | } 251 | 252 | val sortMatchScore = Comparator { a: Feature, b: Feature -> 253 | (100 * b.matchScore - 100 * a.matchScore).toInt() 254 | } 255 | 256 | return sequence { 257 | if (isSuggestion == null || !isSuggestion) { 258 | // a. matches with presets first 259 | yieldAll( 260 | getNamesIndex(languagesOrDefault) 261 | .getAll(canonicalSearch) 262 | .sortedWith(sortNames) 263 | ) 264 | } 265 | if (isSuggestion == null || isSuggestion) { 266 | // b. matches with brand names second 267 | val countryCodes = dissectCountryCode(country) 268 | yieldAll( 269 | getBrandNamesIndex(countryCodes) 270 | .getAll(canonicalSearch) 271 | .sortedWith(sortNames) 272 | ) 273 | } 274 | if (isSuggestion == null || !isSuggestion) { 275 | // c. matches with terms third 276 | yieldAll( 277 | getTermsIndex(languagesOrDefault) 278 | .getAll(canonicalSearch) 279 | .sortedWith(sortMatchScore) 280 | ) 281 | } 282 | if (isSuggestion == null || isSuggestion) { 283 | // d. matches with terms of brands fourth 284 | val countryCodes = dissectCountryCode(country) 285 | yieldAll( 286 | getBrandTermsIndex(countryCodes) 287 | .getAll(canonicalSearch) 288 | .sortedWith(sortMatchScore) 289 | ) 290 | } 291 | if (isSuggestion == null || !isSuggestion) { 292 | // e. matches with tag values fifth 293 | yieldAll( 294 | getTagValuesIndex(languagesOrDefault) 295 | .getAll(canonicalSearch) 296 | .sortedWith(sortMatchScore) 297 | ) 298 | } 299 | } 300 | .distinct() 301 | .filter { it.matches(geometry) && it.matches(country) } 302 | } 303 | 304 | //endregion 305 | 306 | //region Lazily get or create Indexes 307 | 308 | /** lazily get or create tags index for given language(s) */ 309 | private fun getTagsIndex(languages: List): FeatureTagsIndex = 310 | tagsIndexes.getOrPut(languages) { lazy { createTagsIndex(languages) } }.value 311 | 312 | private fun createTagsIndex(languages: List): FeatureTagsIndex = 313 | FeatureTagsIndex(featureCollection.getAll(languages)) 314 | 315 | /** lazily get or create names index for given language(s) */ 316 | private fun getNamesIndex(languages: List): FeatureTermIndex = 317 | namesIndexes.getOrPut(languages) { lazy { createNamesIndex(languages) } }.value 318 | 319 | private fun createNamesIndex(languages: List): FeatureTermIndex = 320 | FeatureTermIndex(featureCollection.getAll(languages)) { feature -> 321 | feature.getSearchableNames().toList() 322 | } 323 | 324 | /** lazily get or create terms index for given language(s) */ 325 | private fun getTermsIndex(languages: List): FeatureTermIndex = 326 | termsIndexes.getOrPut(languages) { lazy { createTermsIndex(languages) } }.value 327 | 328 | private fun createTermsIndex(languages: List): FeatureTermIndex = 329 | FeatureTermIndex(featureCollection.getAll(languages)) { feature -> 330 | if (!feature.isSearchable) emptyList() else feature.canonicalTerms 331 | } 332 | 333 | /** lazily get or create tag values index */ 334 | private fun getTagValuesIndex(languages: List): FeatureTermIndex = 335 | tagValuesIndexes.getOrPut(languages) { lazy { createTagValuesIndex(languages) } }.value 336 | 337 | private fun createTagValuesIndex(languages: List): FeatureTermIndex = 338 | FeatureTermIndex(featureCollection.getAll(languages)) { feature -> 339 | if (!feature.isSearchable) { 340 | emptyList() 341 | } else { 342 | feature.tags.values.filter { it != "*" } 343 | } 344 | } 345 | 346 | /** lazily get or create brand names index for country */ 347 | private fun getBrandNamesIndex(countryCodes: List): FeatureTermIndex = 348 | brandNamesIndexes.getOrPut(countryCodes) { lazy { createBrandNamesIndex(countryCodes) } }.value 349 | 350 | private fun createBrandNamesIndex(countryCodes: List): FeatureTermIndex = 351 | if (brandFeatureCollection == null) { 352 | FeatureTermIndex(emptyList()) { emptyList() } 353 | } else { 354 | FeatureTermIndex(brandFeatureCollection.getAll(countryCodes)) { feature -> 355 | if (!feature.isSearchable) emptyList() else feature.canonicalNames 356 | } 357 | } 358 | 359 | /** lazily get or create brand terms index for country */ 360 | private fun getBrandTermsIndex(countryCodes: List): FeatureTermIndex = 361 | brandTermsIndexes.getOrPut(countryCodes) { lazy { createBrandTermsIndex(countryCodes) } }.value 362 | 363 | private fun createBrandTermsIndex(countryCodes: List): FeatureTermIndex = 364 | if (brandFeatureCollection == null) { 365 | FeatureTermIndex(emptyList()) { emptyList() } 366 | } else { 367 | FeatureTermIndex(brandFeatureCollection.getAll(countryCodes)) { feature -> 368 | if (!feature.isSearchable) emptyList() else feature.canonicalTerms 369 | } 370 | } 371 | 372 | /** lazily get or create tags index for the given countries */ 373 | private fun getBrandTagsIndex(countryCodes: List): FeatureTagsIndex = 374 | brandTagsIndexes.getOrPut(countryCodes) { lazy { createBrandTagsIndex(countryCodes) } }.value 375 | 376 | private fun createBrandTagsIndex(countryCodes: List): FeatureTagsIndex = 377 | if (brandFeatureCollection == null) { 378 | FeatureTagsIndex(emptyList()) 379 | } else { 380 | FeatureTagsIndex(brandFeatureCollection.getAll(countryCodes)) 381 | } 382 | 383 | //endregion 384 | 385 | //region Query builders 386 | 387 | inner class QueryByIdBuilder internal constructor(private val id: String) { 388 | private var languages: List? = null 389 | private var country: String? = null 390 | 391 | fun inLanguage(vararg languages: String?): QueryByIdBuilder = 392 | apply { this.languages = languages.toList() } 393 | 394 | fun inCountry(country: String?): QueryByIdBuilder = 395 | apply { this.country = country } 396 | 397 | fun get(): Feature? = getById(id, languages, country) 398 | } 399 | 400 | inner class QueryByTagBuilder internal constructor(private val tags: Map) { 401 | private var geometry: GeometryType? = null 402 | private var languages: List? = null 403 | private var isSuggestion: Boolean? = null 404 | private var country: String? = null 405 | 406 | fun forGeometry(geometry: GeometryType): QueryByTagBuilder = 407 | apply { this.geometry = geometry } 408 | 409 | fun inLanguage(vararg languages: String?): QueryByTagBuilder = 410 | apply { this.languages = languages.toList() } 411 | 412 | fun inCountry(country: String?): QueryByTagBuilder = 413 | apply { this.country = country } 414 | 415 | fun isSuggestion(isSuggestion: Boolean?): QueryByTagBuilder = 416 | apply { this.isSuggestion = isSuggestion } 417 | 418 | fun find(): List = getByTags(tags, languages, country, geometry, isSuggestion) 419 | } 420 | 421 | inner class QueryByTermBuilder internal constructor(private val term: String) { 422 | private var geometry: GeometryType? = null 423 | private var languages: List? = null 424 | private var isSuggestion: Boolean? = null 425 | private var country: String? = null 426 | 427 | fun forGeometry(geometryType: GeometryType): QueryByTermBuilder = 428 | apply { this.geometry = geometryType } 429 | 430 | fun inLanguage(vararg languages: String?): QueryByTermBuilder = 431 | apply { this.languages = languages.toList() } 432 | 433 | fun inCountry(countryCode: String?): QueryByTermBuilder = 434 | apply { this.country = countryCode } 435 | 436 | fun isSuggestion(suggestion: Boolean?): QueryByTermBuilder = 437 | apply { this.isSuggestion = suggestion } 438 | 439 | fun find(): Sequence = 440 | getByTerm(term, languages, country, geometry, isSuggestion) 441 | } 442 | //endregion 443 | 444 | companion object { 445 | /** Create a new FeatureDictionary which gets its data from the given directory. 446 | * Optionally, a path to brand presets can be specified. */ 447 | fun create( 448 | fileSystem: FileSystem, 449 | presetsBasePath: String, 450 | brandPresetsBasePath: String? = null 451 | ) = FeatureDictionary( 452 | featureCollection = 453 | IDLocalizedFeatureCollection(FileSystemAccess(fileSystem, presetsBasePath)), 454 | brandFeatureCollection = 455 | brandPresetsBasePath?.let { 456 | IDBrandPresetsFeatureCollection(FileSystemAccess(fileSystem, brandPresetsBasePath)) 457 | } 458 | ) 459 | } 460 | } 461 | 462 | //region Utility / Filter functions 463 | 464 | private fun Feature.matches(countryCode: String?): Boolean { 465 | if (includeCountryCodes.isNotEmpty() || excludeCountryCodes.isNotEmpty()) { 466 | if (countryCode == null) return false 467 | if ( 468 | includeCountryCodes.isNotEmpty() && 469 | !isInCountryCodes(countryCode, includeCountryCodes) 470 | ) return false 471 | if (isInCountryCodes(countryCode, excludeCountryCodes)) return false 472 | } 473 | return true 474 | } 475 | 476 | private fun Feature.matches(geometry: GeometryType?): Boolean = 477 | !(geometry != null && !this.geometry.contains(geometry)) 478 | 479 | private fun Feature.getSearchableNames(): Sequence = sequence { 480 | if (!isSearchable) return@sequence 481 | yieldAll(canonicalNames) 482 | for (name in canonicalNames) { 483 | if (name.contains(" ")) { 484 | yieldAll(name.replace("[()]", "").split(" ")) 485 | } 486 | } 487 | } 488 | 489 | private fun isInCountryCodes(countryCode: String, countryCodes: List): Boolean = 490 | countryCode in countryCodes || 491 | countryCode.substringBefore('-') in countryCodes 492 | 493 | private fun getParentCategoryIds(id: String): Sequence = sequence { 494 | var lastIndex = id.length 495 | while (true) { 496 | lastIndex = id.lastIndexOf('/', lastIndex - 1) 497 | if (lastIndex == -1) break 498 | yield(id.substring(0, lastIndex)) 499 | } 500 | } 501 | 502 | private fun dissectCountryCode(countryCode: String?): List = buildList { 503 | // add default / international 504 | add(null) 505 | if (countryCode != null) { 506 | // add ISO 3166-1 alpha2 (e.g. "US") 507 | val alpha2 = countryCode.substringBefore('-') 508 | add(alpha2) 509 | // add ISO 3166-2 (e.g. "US-NY") 510 | if (alpha2 != countryCode) add(countryCode) 511 | } 512 | } 513 | 514 | private fun Boolean.toInt(): Int = 515 | if (this) 1 else 0 516 | 517 | //endregion -------------------------------------------------------------------------------- /src/commonTest/kotlin/de/westnordost/osmfeatures/FeatureDictionaryTest.kt: -------------------------------------------------------------------------------- 1 | package de.westnordost.osmfeatures 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNull 6 | 7 | import de.westnordost.osmfeatures.GeometryType.* 8 | 9 | class FeatureDictionaryTest { 10 | 11 | private val bakery = feature( // unlocalized shop=bakery 12 | id = "shop/bakery", 13 | tags = mapOf("shop" to "bakery"), 14 | names = listOf("Bäckerei"), 15 | terms = listOf("Brot") 16 | ) 17 | private val panetteria = feature( // localized shop=bakery 18 | id = "shop/bakery", 19 | tags = mapOf("shop" to "bakery"), 20 | names = listOf("Panetteria"), 21 | language = "it" 22 | ) 23 | private val ditsch = feature( // brand in DE for shop=bakery 24 | id = "shop/bakery/Ditsch", 25 | tags = mapOf("shop" to "bakery", "name" to "Ditsch"), 26 | names = listOf("Ditsch"), 27 | includeCountryCodes = listOf("DE", "AT"), 28 | excludeCountryCodes = listOf("AT-9"), 29 | addTags = mapOf("wikipedia" to "de:Brezelb%C3%A4ckerei_Ditsch", "brand" to "Ditsch"), 30 | isSuggestion = true 31 | ) 32 | private val ditschRussian = feature( // brand in RU for shop=bakery 33 | id = "shop/bakery/Дитсч", 34 | tags = mapOf("shop" to "bakery", "name" to "Ditsch"), 35 | names = listOf("Дитсч"), 36 | includeCountryCodes = listOf("RU", "UA-43"), 37 | addTags = mapOf("wikipedia" to "de:Brezelb%C3%A4ckerei_Ditsch", "brand" to "Дитсч"), 38 | isSuggestion = true 39 | ) 40 | private val ditschInternational = feature( // brand everywhere for shop=bakery 41 | id = "shop/bakery/Ditsh", 42 | tags = mapOf("shop" to "bakery", "name" to "Ditsch"), 43 | names = listOf("Ditsh"), 44 | addTags = mapOf("wikipedia" to "de:Brezelb%C3%A4ckerei_Ditsch"), 45 | isSuggestion = true 46 | ) 47 | private val liquor_store = feature( // English localized unspecific shop=alcohol 48 | id = "shop/alcohol", 49 | tags = mapOf("shop" to "alcohol"), 50 | names = listOf("Off licence (Alcohol shop)"), 51 | language = "en-GB" 52 | ) 53 | private val car_dealer = feature( // German localized unspecific shop=car 54 | id = "shop/car", 55 | tags = mapOf("shop" to "car"), 56 | names = listOf("Autohändler"), 57 | terms = listOf("auto"), 58 | language = "de" 59 | ) 60 | private val second_hand_car_dealer = feature( // German localized shop=car with subtags 61 | id = "shop/car/second_hand", 62 | tags = mapOf("shop" to "car", "second_hand" to "only"), 63 | names = listOf("Gebrauchtwagenhändler"), 64 | terms = listOf("auto"), 65 | language = "de" 66 | ) 67 | private val scheisshaus = feature( // unsearchable feature 68 | id = "amenity/scheißhaus", 69 | tags = mapOf("amenity" to "scheißhaus"), 70 | names = listOf("Scheißhaus"), 71 | searchable = false 72 | ) 73 | private val bank = feature( // unlocalized shop=bank (Bank) 74 | id = "amenity/bank", 75 | tags = mapOf("amenity" to "bank"), 76 | names = listOf("Bank") 77 | ) 78 | private val bench = feature( // unlocalized amenity=bench (PARKbank) 79 | id = "amenity/bench", 80 | tags = mapOf("amenity" to "bench"), 81 | names = listOf("Parkbank"), 82 | terms = listOf("Bank"), 83 | matchScore = 5.0f 84 | ) 85 | private val casino = feature( // unlocalized amenity=casino (SPIELbank) 86 | id = "amenity/casino", 87 | tags = mapOf("amenity" to "casino"), 88 | names = listOf("Spielbank"), 89 | terms = listOf("Kasino") 90 | ) 91 | private val atm = feature( // unlocalized amenity=atm (BankOMAT) 92 | id = "amenity/atm", 93 | tags = mapOf("amenity" to "atm"), 94 | names = listOf("Bankomat") 95 | ) 96 | private val stock_exchange = feature( // unlocalized amenity=stock_exchange (has "Banking" as term) 97 | id = "amenity/stock_exchange", 98 | tags = mapOf("amenity" to "stock_exchange"), 99 | names = listOf("Börse"), 100 | terms = listOf("Banking"), 101 | ) 102 | private val bank_of_america = feature( // Brand of a amenity=bank (has "Bank" in name) 103 | id = "amenity/bank/Bank of America", 104 | tags = mapOf("amenity" to "bank", "name" to "Bank of America"), 105 | names = listOf("Bank of America"), 106 | isSuggestion = true 107 | ) 108 | private val bank_of_liechtenstein = feature( // Brand of a amenity=bank (has "Bank" in name), but low matchScore 109 | id = "amenity/bank/Bank of Liechtenstein", 110 | tags = mapOf("amenity" to "bank", "name" to "Bank of Liechtenstein"), 111 | names = listOf("Bank of Liechtenstein"), 112 | matchScore = 0.2f, 113 | isSuggestion = true, 114 | ) 115 | private val deutsche_bank = feature( // Brand of a amenity=bank (does not start with "Bank" in name) 116 | id = "amenity/bank/Deutsche Bank", 117 | tags = mapOf("amenity" to "bank", "name" to "Deutsche Bank"), 118 | names = listOf("Deutsche Bank"), 119 | isSuggestion = true 120 | ) 121 | private val baenk = feature( // amenity=bänk, to see if diacritics match non-strictly ("a" finds "ä") 122 | id = "amenity/bänk", 123 | tags = mapOf("amenity" to "bänk"), 124 | names = listOf("Bänk"), 125 | ) 126 | private val bad_bank = feature( // amenity=bank with subtags that has "Bank" in name but it is not the first word 127 | id = "amenity/bank/bad", 128 | tags = mapOf("amenity" to "bank", "goodity" to "bad"), 129 | names = listOf("Bad Bank") 130 | ) 131 | private val thieves_guild = feature( // only has "bank" in an alias 132 | id = "amenity/thieves_guild", 133 | tags = mapOf("amenity" to "thieves_guild"), 134 | names = listOf("Diebesgilde", "Bankräuberausbildungszentrum"), 135 | ) 136 | private val miniature_train_shop = feature( // feature whose name consists of several words 137 | id = "shop/miniature_train", 138 | tags = mapOf("shop" to "miniature_train"), 139 | names = listOf("Miniature Train Shop"), 140 | ) 141 | private val postbox = feature( 142 | id = "amenity/post_box", 143 | tags = mapOf("amenity" to "post_box"), 144 | names = listOf("Post box"), 145 | excludeCountryCodes = listOf("US"), 146 | ) 147 | private val postboxUS = feature( 148 | id = "amenity/post_box/post_box-US", 149 | tags = mapOf("amenity" to "post_box"), 150 | names = listOf("Post box in US"), 151 | includeCountryCodes = listOf("US"), 152 | ) 153 | private val amenity = feature( 154 | id = "amenity", 155 | keys = setOf("amenity"), 156 | names = listOf("Some amenity"), 157 | ) 158 | private val seven_eleven_jp = feature( // Brand with non-space word separator, terms and localized to Japan 159 | id = "shop/convenience/7-Eleven-JP", 160 | tags = mapOf("shop" to "convenience", "name" to "セブン-イレブン", "name:en" to "7-Eleven"), 161 | names = listOf("セブン-イレブン"), 162 | isSuggestion = true, 163 | terms = listOf("7-eleven", "seven-eleven"), 164 | ) 165 | 166 | //region by tags 167 | 168 | @Test 169 | fun find_no_entry_by_tags() { 170 | assertEquals( 171 | emptyList(), 172 | dictionary(bakery).getByTags(mapOf("shop" to "supermarket")) 173 | ) 174 | } 175 | 176 | @Test 177 | fun find_no_entry_because_wrong_geometry() { 178 | assertEquals( 179 | emptyList(), 180 | dictionary(bakery).getByTags(mapOf("shop" to "bakery"), geometry = RELATION) 181 | ) 182 | } 183 | 184 | @Test 185 | fun find_no_entry_because_wrong_language() { 186 | assertEquals( 187 | emptyList(), 188 | dictionary(bakery).getByTags(mapOf("shop" to "bakery"), languages = listOf("it")) 189 | ) 190 | } 191 | 192 | @Test 193 | fun find_entry_because_fallback_language() { 194 | assertEquals( 195 | listOf(bakery), 196 | dictionary(bakery).getByTags(mapOf("shop" to "bakery"), languages = listOf("it", null)) 197 | ) 198 | } 199 | 200 | @Test 201 | fun find_entry_by_tags() { 202 | assertEquals( 203 | listOf(bakery), 204 | dictionary(bakery).getByTags(mapOf("shop" to "bakery")) 205 | ) 206 | } 207 | 208 | @Test 209 | fun find_non_searchable_entry_by_tags() { 210 | assertEquals( 211 | listOf(scheisshaus), 212 | dictionary(scheisshaus).getByTags(mapOf("amenity" to "scheißhaus")) 213 | ) 214 | } 215 | 216 | @Test 217 | fun find_entry_by_tags_correct_geometry() { 218 | assertEquals( 219 | listOf(bakery), 220 | dictionary(bakery).getByTags(mapOf("shop" to "bakery"), geometry = POINT) 221 | ) 222 | } 223 | 224 | @Test 225 | fun find_brand_entry_by_tags() { 226 | assertEquals( 227 | listOf(ditsch), 228 | dictionary(bakery, ditsch) 229 | .getByTags(mapOf("shop" to "bakery", "name" to "Ditsch"), country = "DE") 230 | ) 231 | } 232 | 233 | @Test 234 | fun find_only_entries_with_given_language() { 235 | val tags = mapOf("shop" to "bakery") 236 | val dictionary = dictionary(bakery, panetteria) 237 | 238 | assertEquals( 239 | listOf(panetteria), 240 | dictionary.getByTags(tags, languages = listOf("it")) 241 | ) 242 | assertEquals( 243 | emptyList(), 244 | dictionary.getByTags(tags, languages = listOf("en")) 245 | ) 246 | assertEquals( 247 | listOf(bakery), 248 | dictionary.getByTags(tags, languages = listOf(null)) 249 | ) 250 | } 251 | 252 | @Test 253 | fun find_only_brands_finds_no_normal_entries() { 254 | assertEquals( 255 | 0, 256 | dictionary(bakery) 257 | .getByTags(mapOf("shop" to "bakery", "name" to "Ditsch"), isSuggestion = true) 258 | .size 259 | ) 260 | } 261 | 262 | @Test 263 | fun find_no_brands_finds_only_normal_entries() { 264 | assertEquals( 265 | listOf(bakery), 266 | dictionary(bakery, ditsch) 267 | .getByTags(mapOf("shop" to "bakery", "name" to "Ditsch"), isSuggestion = false) 268 | ) 269 | } 270 | 271 | @Test 272 | fun find_multiple_brands_sorts_by_language() { 273 | assertEquals( 274 | ditschInternational, 275 | dictionary(ditschRussian, ditschInternational, ditsch) 276 | .getByTags(mapOf("shop" to "bakery", "name" to "Ditsch"), languages = listOf(null)) 277 | .first() 278 | ) 279 | } 280 | 281 | @Test 282 | fun find_multiple_entries_by_tags() { 283 | assertEquals( 284 | 2, 285 | dictionary(bakery, bank).getByTags(mapOf("shop" to "bakery", "amenity" to "bank")).size 286 | ) 287 | } 288 | 289 | @Test 290 | fun do_not_find_entry_with_too_specific_tags() { 291 | assertEquals( 292 | listOf(car_dealer), 293 | dictionary(car_dealer, second_hand_car_dealer) 294 | .getByTags(mapOf("shop" to "car"), languages = listOf("de", null)) 295 | ) 296 | } 297 | 298 | @Test 299 | fun find_entry_with_specific_tags() { 300 | assertEquals( 301 | listOf(second_hand_car_dealer), 302 | dictionary(car_dealer, second_hand_car_dealer).getByTags( 303 | tags = mapOf("shop" to "car", "second_hand" to "only"), 304 | languages = listOf("de", null) 305 | ) 306 | ) 307 | } 308 | 309 | @Test 310 | fun find_country_specific_feature_by_tags() { 311 | val dictionary = dictionary(postbox, postboxUS) 312 | 313 | assertEquals( 314 | listOf(), 315 | dictionary.getByTags(mapOf("amenity" to "post_box"), country = null) 316 | ) 317 | 318 | assertEquals( 319 | listOf(postbox), 320 | dictionary.getByTags(mapOf("amenity" to "post_box"), country = "DE") 321 | ) 322 | 323 | assertEquals( 324 | listOf(postboxUS), 325 | dictionary.getByTags(mapOf("amenity" to "post_box"), country = "US") 326 | ) 327 | } 328 | 329 | @Test 330 | fun find_feature_with_wildcard() { 331 | val dictionary = dictionary(bank, amenity) 332 | 333 | assertEquals( 334 | listOf(bank), 335 | dictionary.getByTags(mapOf("amenity" to "bank")) 336 | ) 337 | assertEquals( 338 | listOf(amenity), 339 | dictionary.getByTags(mapOf("amenity" to "blubber")) 340 | ) 341 | } 342 | 343 | //endregion 344 | 345 | //region by name 346 | 347 | @Test 348 | fun find_no_entry_by_name() { 349 | assertEquals( 350 | emptyList(), 351 | dictionary(bakery).getByTerm("Supermarkt").toList() 352 | ) 353 | } 354 | 355 | @Test 356 | fun find_no_entry_by_name_because_wrong_geometry() { 357 | assertEquals( 358 | emptyList(), 359 | dictionary(bakery).getByTerm("Bäckerei", geometry = LINE).toList() 360 | ) 361 | } 362 | 363 | @Test 364 | fun find_no_entry_by_name_because_wrong_country() { 365 | val dictionary = dictionary(ditsch, ditschRussian) 366 | assertEquals( 367 | emptyList(), 368 | dictionary.getByTerm("Ditsch").toList() 369 | ) 370 | assertEquals( 371 | emptyList(), 372 | dictionary.getByTerm("Ditsch", country = "FR").toList() 373 | ) // not in France 374 | assertEquals( 375 | emptyList(), 376 | dictionary.getByTerm("Ditsch", country = "AT-9").toList() 377 | ) // in all of AT but not Vienna 378 | assertEquals( 379 | emptyList(), 380 | dictionary.getByTerm("Дитсч", country = "UA").toList() 381 | ) // only on the Krim 382 | } 383 | 384 | @Test 385 | fun find_no_non_searchable_entry_by_name() { 386 | assertEquals( 387 | emptyList(), 388 | dictionary(scheisshaus).getByTerm("Scheißhaus").toList() 389 | ) 390 | } 391 | 392 | @Test 393 | fun find_entry_by_name() { 394 | assertEquals( 395 | listOf(bakery), 396 | dictionary(bakery).getByTerm("Bäckerei", languages = listOf(null)).toList() 397 | ) 398 | } 399 | 400 | @Test 401 | fun find_entry_by_name_with_correct_geometry() { 402 | assertEquals( 403 | listOf(bakery), 404 | dictionary(bakery) 405 | .getByTerm("Bäckerei", languages = listOf(null), geometry = POINT).toList() 406 | ) 407 | } 408 | 409 | @Test 410 | fun find_entry_by_name_with_correct_country() { 411 | val dictionary = dictionary(ditsch, ditschRussian, bakery) 412 | assertEquals(listOf(ditsch), dictionary.getByTerm("Ditsch", country = "DE").toList()) 413 | assertEquals(listOf(ditsch), dictionary.getByTerm("Ditsch", country = "DE-TH").toList()) 414 | assertEquals(listOf(ditsch), dictionary.getByTerm("Ditsch", country = "AT").toList()) 415 | assertEquals(listOf(ditsch), dictionary.getByTerm("Ditsch", country = "AT-5").toList()) 416 | assertEquals(listOf(ditschRussian), dictionary.getByTerm("Дитсч", country = "UA-43").toList()) 417 | assertEquals(listOf(ditschRussian), dictionary.getByTerm("Дитсч", country = "RU-KHA").toList()) 418 | } 419 | 420 | @Test 421 | fun find_entry_by_name_case_insensitive() { 422 | assertEquals( 423 | listOf(bakery), 424 | dictionary(bakery).getByTerm("BÄCkErEI", languages = listOf(null)).toList() 425 | ) 426 | } 427 | 428 | @Test 429 | fun find_entry_by_name_diacritics_insensitive() { 430 | assertEquals( 431 | listOf(bakery), 432 | dictionary(bakery).getByTerm("Backérèi", languages = listOf(null)).toList() 433 | ) 434 | } 435 | 436 | @Test 437 | fun find_entry_by_term() { 438 | assertEquals( 439 | listOf(bakery), 440 | dictionary(bakery).getByTerm("bro", languages = listOf(null)).toList() 441 | ) 442 | } 443 | 444 | @Test 445 | fun find_entry_by_term_brackets() { 446 | val dictionary = dictionary(liquor_store) 447 | assertEquals( 448 | listOf(liquor_store), 449 | dictionary.getByTerm("Alcohol", languages = listOf("en-GB")).toList() 450 | ) 451 | assertEquals( 452 | listOf(liquor_store), 453 | dictionary.getByTerm("Off licence (Alcohol Shop)", languages = listOf("en-GB")).toList() 454 | ) 455 | assertEquals( 456 | listOf(liquor_store), 457 | dictionary.getByTerm("Off Licence", languages = listOf("en-GB")).toList() 458 | ) 459 | assertEquals( 460 | listOf(liquor_store), 461 | dictionary.getByTerm("Off Licence (Alco", languages = listOf("en-GB")).toList() 462 | ) 463 | } 464 | 465 | @Test 466 | fun find_entry_by_term_case_insensitive() { 467 | assertEquals( 468 | listOf(bakery), 469 | dictionary(bakery).getByTerm("BRO", languages = listOf(null)).toList() 470 | ) 471 | } 472 | 473 | @Test 474 | fun find_entry_by_term_diacritics_insensitive() { 475 | assertEquals( 476 | listOf(bakery), 477 | dictionary(bakery).getByTerm("bró", languages = listOf(null)).toList() 478 | ) 479 | } 480 | 481 | @Test 482 | fun find_multiple_entries_by_term() { 483 | assertEquals( 484 | setOf(second_hand_car_dealer, car_dealer), 485 | dictionary(second_hand_car_dealer, car_dealer) 486 | .getByTerm("auto", languages = listOf("de")) 487 | .toSet() 488 | ) 489 | } 490 | 491 | @Test 492 | fun find_only_brands_by_name_finds_no_normal_entries() { 493 | assertEquals( 494 | 0, 495 | dictionary(bakery) 496 | .getByTerm("Bäckerei", languages = listOf(null), isSuggestion = true) 497 | .count() 498 | ) 499 | } 500 | 501 | @Test 502 | fun find_no_brands_by_name_finds_only_normal_entries() { 503 | assertEquals( 504 | listOf(bank), 505 | dictionary(bank, bank_of_america) 506 | .getByTerm("Bank", languages = listOf(null), isSuggestion = false) 507 | .toList() 508 | ) 509 | } 510 | 511 | @Test 512 | fun find_no_entry_by_term_because_wrong_language() { 513 | assertEquals( 514 | emptyList(), 515 | dictionary(bakery).getByTerm("Bäck", languages = listOf("it")).toList() 516 | ) 517 | } 518 | 519 | @Test 520 | fun find_entry_by_term_because_fallback_language() { 521 | assertEquals( 522 | listOf(bakery), 523 | dictionary(bakery).getByTerm("Bäck", languages = listOf("it", null)).toList() 524 | ) 525 | } 526 | 527 | @Test 528 | fun find_multi_word_brand_feature() { 529 | val dictionary = dictionary(deutsche_bank) 530 | assertEquals(listOf(deutsche_bank), dictionary.getByTerm("Deutsche Ba").toList()) 531 | assertEquals(listOf(deutsche_bank), dictionary.getByTerm("Deut").toList()) 532 | // by-word only for non-brand features 533 | assertEquals(0, dictionary.getByTerm("Ban").count()) 534 | } 535 | 536 | @Test 537 | fun find_multi_word_feature() { 538 | val dictionary = dictionary(miniature_train_shop) 539 | assertEquals( 540 | listOf(miniature_train_shop), 541 | dictionary.getByTerm("mini", languages = listOf(null)).toList() 542 | ) 543 | assertEquals( 544 | listOf(miniature_train_shop), 545 | dictionary.getByTerm("train", languages = listOf(null)).toList() 546 | ) 547 | assertEquals( 548 | listOf(miniature_train_shop), 549 | dictionary.getByTerm("shop", languages = listOf(null)).toList() 550 | ) 551 | assertEquals( 552 | listOf(miniature_train_shop), 553 | dictionary.getByTerm("Miniature Trai", languages = listOf(null)).toList() 554 | ) 555 | assertEquals( 556 | listOf(miniature_train_shop), 557 | dictionary.getByTerm("Miniature Train Shop", languages = listOf(null)).toList() 558 | ) 559 | assertEquals(0, dictionary.getByTerm("Train Sho", languages = listOf(null)).count()) 560 | } 561 | 562 | @Test 563 | fun find_entry_by_tag_value() { 564 | assertEquals( 565 | listOf(panetteria), 566 | dictionary(panetteria).getByTerm("bakery", languages = listOf("it")).toList() 567 | ) 568 | } 569 | 570 | @Test 571 | fun find_country_specific_feature_by_term() { 572 | val dictionary = dictionary(postbox, postboxUS) 573 | 574 | assertEquals( 575 | listOf(), 576 | dictionary.getByTerm("Post", country = null).toList() 577 | ) 578 | 579 | assertEquals( 580 | listOf(postbox), 581 | dictionary.getByTerm("Post", country = "DE").toList() 582 | ) 583 | 584 | assertEquals( 585 | listOf(postboxUS), 586 | dictionary.getByTerm("Post", country = "US").toList() 587 | ) 588 | } 589 | 590 | @Test 591 | fun find_brand_feature_by_term() { 592 | val dictionary = dictionary(seven_eleven_jp) 593 | assertEquals( 594 | listOf(seven_eleven_jp), 595 | dictionary.getByTerm("seven").toList() 596 | ) 597 | assertEquals( 598 | listOf(seven_eleven_jp), 599 | dictionary.getByTerm("7").toList() 600 | ) 601 | assertEquals( 602 | listOf(), 603 | dictionary.getByTerm("8").toList() 604 | ) 605 | } 606 | 607 | //endregion 608 | 609 | //region by id 610 | 611 | @Test 612 | fun find_no_entry_by_id() { 613 | assertNull(dictionary(bakery).getById("amenity/hospital")) 614 | } 615 | 616 | @Test 617 | fun find_no_entry_by_id_because_unlocalized_results_are_excluded() { 618 | assertNull(dictionary(bakery).getById("shop/bakery", languages = listOf("it"))) 619 | } 620 | 621 | @Test 622 | fun find_entry_by_id() { 623 | val dictionary = dictionary(bakery) 624 | assertEquals(bakery, dictionary.getById("shop/bakery")) 625 | assertEquals(bakery, dictionary.getById("shop/bakery", languages = listOf("zh", null))) 626 | } 627 | 628 | @Test 629 | fun find_localized_entry_by_id() { 630 | val dictionary = dictionary(panetteria) 631 | assertEquals( 632 | panetteria, 633 | dictionary.getById("shop/bakery", languages = listOf("it")) 634 | ) 635 | assertEquals( 636 | panetteria, 637 | dictionary.getById("shop/bakery", languages = listOf("it", null)) 638 | ) 639 | } 640 | 641 | @Test 642 | fun find_no_entry_by_id_because_wrong_country() { 643 | val dictionary = dictionary(ditsch) 644 | assertNull(dictionary.getById("shop/bakery/Ditsch")) 645 | assertNull(dictionary.getById("shop/bakery/Ditsch", country = "IT")) 646 | assertNull(dictionary.getById("shop/bakery/Ditsch", country = "AT-9")) 647 | } 648 | 649 | @Test 650 | fun find_entry_by_id_in_country() { 651 | val dictionary = dictionary(ditsch) 652 | assertEquals(ditsch, dictionary.getById("shop/bakery/Ditsch", country = "AT")) 653 | assertEquals(ditsch, dictionary.getById("shop/bakery/Ditsch", country = "DE")) 654 | } 655 | 656 | @Test 657 | fun find_country_specific_feature_by_id() { 658 | val dictionary = dictionary(postbox, postboxUS) 659 | 660 | assertEquals( 661 | postbox, 662 | dictionary.getById("amenity/post_box", country = "DE") 663 | ) 664 | assertEquals( 665 | null, 666 | dictionary.getById("amenity/post_box", country = "US") 667 | ) 668 | 669 | assertEquals( 670 | postboxUS, 671 | dictionary.getById("amenity/post_box/post_box-US", country = "US") 672 | ) 673 | assertEquals( 674 | null, 675 | dictionary.getById("amenity/post_box/post_box-US", country = "DE") 676 | ) 677 | 678 | assertEquals( 679 | null, 680 | dictionary.getById("amenity/post_box", country = null) 681 | ) 682 | } 683 | 684 | //endregion 685 | 686 | @Test 687 | fun find_by_term_sorts_result_in_correct_order() { 688 | val dictionary = dictionary( 689 | casino, baenk, bad_bank, stock_exchange, bank_of_liechtenstein, bank, bench, atm, 690 | bank_of_america, deutsche_bank, thieves_guild 691 | ) 692 | assertEquals( 693 | listOf( 694 | bank, // exact name matches 695 | baenk, // exact name matches (diacritics and case insensitive) 696 | atm, // starts-with name matches 697 | thieves_guild, // starts-with alias matches 698 | bad_bank, // starts-with-word name matches 699 | bank_of_america, // starts-with brand name matches 700 | bank_of_liechtenstein, // starts-with brand name matches - lower matchScore 701 | bench, // found word in terms - higher matchScore 702 | stock_exchange // found word in terms - lower matchScore 703 | // casino, // not included: "Spielbank" does not start with "bank" 704 | // deutsche_bank // not included: "Deutsche Bank" does not start with "bank" and is a brand 705 | ), 706 | dictionary.getByTerm("Bank", languages = listOf(null)).toList() 707 | ) 708 | } 709 | 710 | @Test 711 | fun issue19() { 712 | val lush = feature( 713 | id = "shop/cosmetics/lush-a08666", 714 | tags = mapOf("brand:wikidata" to "Q1585448", "shop" to "cosmetics"), 715 | geometries = listOf(POINT, AREA), 716 | names = listOf("Lush"), 717 | terms = listOf("lush"), 718 | matchScore = 2.0f, 719 | addTags = mapOf( 720 | "brand" to "Lush", 721 | "brand:wikidata" to "Q1585448", 722 | "name" to "Lush", 723 | "shop" to "cosmetics" 724 | ), 725 | isSuggestion = true, 726 | ) 727 | 728 | val dictionary = dictionary(lush) 729 | 730 | val getByTags = dictionary.getByTags( 731 | tags = mapOf("brand:wikidata" to "Q1585448", "shop" to "cosmetics"), 732 | languages = listOf("de", null), 733 | country = "DE" 734 | ) 735 | assertEquals(1, getByTags.size) 736 | assertEquals(lush, getByTags[0]) 737 | 738 | val getByTerm = dictionary.getByTerm("Lush", languages = listOf("de", null), country = "DE") 739 | 740 | assertEquals(1, getByTerm.count()) 741 | assertEquals(lush, getByTerm.first()) 742 | 743 | val getById = dictionary 744 | .getById("shop/cosmetics/lush-a08666", languages = listOf("de", null), country = "DE") 745 | assertEquals(lush, getById) 746 | } 747 | 748 | @Test 749 | fun some_tests_with_real_data() { 750 | val featureCollection = IDLocalizedFeatureCollection(LivePresetDataAccessAdapter()) 751 | featureCollection.getAll(listOf("en")) 752 | 753 | val dictionary = FeatureDictionary(featureCollection, null) 754 | 755 | val matches = dictionary.getByTags(mapOf("amenity" to "studio"), languages = listOf("en")) 756 | assertEquals(1, matches.size) 757 | assertEquals("Studio", matches[0].name) 758 | 759 | val matches2 = dictionary 760 | .getByTags(mapOf("amenity" to "studio", "studio" to "audio"), languages = listOf("en")) 761 | assertEquals(1, matches2.size) 762 | assertEquals("Recording Studio", matches2[0].name) 763 | 764 | val matches3 = dictionary.getByTerm("Chinese Res", languages = listOf("en")) 765 | 766 | assertEquals(1, matches3.count()) 767 | assertEquals("Chinese Restaurant", matches3.first().name) 768 | 769 | assertEquals( 770 | "amenity/post_box", 771 | dictionary.getByTags( 772 | tags = mapOf("amenity" to "post_box"), 773 | languages = listOf("en"), 774 | country = "DE" 775 | ).single().id 776 | ) 777 | assertEquals( 778 | "amenity/post_box/post_box-US", 779 | dictionary.getByTags( 780 | tags = mapOf("amenity" to "post_box"), 781 | languages = listOf("en"), 782 | country = "US" 783 | ).single().id 784 | ) 785 | assertEquals( 786 | "amenity/post_box", 787 | dictionary.getByTerm( 788 | search = "Mail Drop Box", 789 | languages = listOf("en"), 790 | country = "DE" 791 | ).single().id 792 | ) 793 | assertEquals( 794 | "amenity/post_box/post_box-US", 795 | dictionary.getByTerm( 796 | search = "Mail Drop Box", 797 | languages = listOf("en"), 798 | country = "US" 799 | ).single().id 800 | ) 801 | } 802 | } 803 | 804 | private fun dictionary(vararg entries: Feature) = FeatureDictionary( 805 | TestLocalizedFeatureCollection(entries.filterNot { it.isSuggestion }), 806 | TestPerCountryFeatureCollection(entries.filter { it.isSuggestion }) 807 | ) 808 | 809 | private fun feature( 810 | id: String, 811 | tags: Map = mapOf(), 812 | geometries: List = listOf(POINT), 813 | names: List, 814 | terms: List = listOf(), 815 | includeCountryCodes: List = listOf(), 816 | excludeCountryCodes: List = listOf(), 817 | searchable: Boolean = true, 818 | matchScore: Float = 1.0f, 819 | addTags: Map = mapOf(), 820 | isSuggestion: Boolean = false, 821 | language: String? = null, 822 | keys: Set = setOf() 823 | ): Feature { 824 | val f = BaseFeature( 825 | id = id, 826 | tags = tags, 827 | geometry = geometries, 828 | icon = null, 829 | imageURL = null, 830 | names = names, 831 | terms = terms, 832 | includeCountryCodes = includeCountryCodes, 833 | excludeCountryCodes = excludeCountryCodes, 834 | isSearchable = searchable, 835 | matchScore = matchScore, 836 | isSuggestion = isSuggestion, 837 | addTags = addTags, 838 | removeTags = mapOf(), 839 | preserveTags = listOf(), 840 | tagKeys = keys, 841 | addTagKeys = setOf(), 842 | removeTagKeys = setOf() 843 | ) 844 | return if (language != null) LocalizedFeature(f, language, f.names, f.terms) else f 845 | } --------------------------------------------------------------------------------