diff --git a/Docs/FilterFunctionality.gif b/Docs/FilterFunctionality.gif new file mode 100644 index 0000000000..69b826dc4b Binary files /dev/null and b/Docs/FilterFunctionality.gif differ diff --git a/Docs/FilteredByDepth.gif b/Docs/FilteredByDepth.gif new file mode 100644 index 0000000000..3af61e8d3e Binary files /dev/null and b/Docs/FilteredByDepth.gif differ diff --git a/Docs/Making_Your_Own_Theme.md b/Docs/Making_Your_Own_Theme.md index dd2e8a60c1..9a4b042c6c 100644 --- a/Docs/Making_Your_Own_Theme.md +++ b/Docs/Making_Your_Own_Theme.md @@ -4,6 +4,11 @@ Making your own theme In MapComplete, it is relatively simple to make your own theme. This guide will give some information on how you can do this. +Table of contents: + +1. [Requirements](#requirements) which lists what you should know before starting to create a theme +2. [What is a good theme?](#what-is-a-good-theme) + Requirements ------------ @@ -15,10 +20,213 @@ Before you start, you should have the following qualifications: - You are in contact with your local OpenStreetMap community and do know some other members to discuss tagging and to help testing -If you do not have those qualifications, reach out to the MapComplete community channel +Please, do reach out to the MapComplete community channel on [Telegram](https://t.me/MapComplete) or [Matrix](https://app.element.io/#/room/#MapComplete:matrix.org). + +What is a good theme? +--------------------- + +A **theme** (or _layout_) is a single map showing one or more layers. +The layers should work together in such a way that they serve a certain **audience**. +You should be able to state in a few sentences whom would be the user of such a map, e.g. + +- a cyclist searching for bike repair +- a thirsty person who needs water +- someone who wants to know what their street is named after +- ... + +Some layers will be useful for many themes (e.g. _drinking water_, _toilets_, _shops_, ...). Due to this, MapComplete supports to reuse already existing official layers into a theme. + +To include an already existing layer, simply type the layer id, e.g.: + +```json +{ + "id": "my-theme", + "title": "My theme for xyz", + "...": "...", + "layers": [ + { + "id": "my super-awesome new layer" + }, + "bench", + "shops", + "drinking_water", + "toilet" + ] +} +``` + +Note that it is good practice to use an existing layer and to tweak it: + +```json +{ + "id": "my super awesome theme", + "...": "...", + "layers": [ + { + "builtin": [ + "toilet", + "bench" + ], + "override": { + "#": "Override is a section which copies all the keys here and 'pastes' them into the existing layers. For example, the 'minzoom' defined here will redifine the minzoom of 'toilet' and 'bench'", + "minzoom": 17, + "#0": "Appending to lists is supported to, e.g. to add an extra question", + "tagRenderings+": [ + { + "id": "new-question", + "question": "What is ?", + "render": "{property}", + "...": "..." + } + ], + "#1": "Note that paths will be followed: the below block will add/change the icon of the layer, without changing the other properties of the first tag rendering. (Assumption: the first mapRendering is the icon rendering)", + "mapRendering": [ + { + "icon": { + "render": "new-icon.svg" + } + } + ] + } + } + ] +} + +``` + +### What is a good layer? + +A good layer is layer which shows **all** objects of a certain type, e.g. **all** shops, **all** restaurants, ... + +It asks some relevant questions, with the most important and easiests questions first. + +#### Don't: use a layer to filter + +**Do not define a layer which filters on an attribute**, such as all restaurants with a vegetarian diet, all shops which accept bitcoin. +This makes _addition_ of new points difficult as information might not yet be known. Conser the following situation: + +1. A theme defines a layer `vegetarian restaurants`, which matches `amenity=restaurant` & `diet:vegetarian=yes`. +2. An object exists in OSM with `amenity=restaurant`;`name=Fancy Food`;`diet:vegan=yes`;`phone=...`;... +3. A contributor visits the themes and will notice that _Fancy Food_ is missing +4. The contributor will add _Fancy Food_ +5. There are now **two** Fancy Food objects in OSM. + +Instead, use the filter functionality instead. This can be used from the layer to hide some objects based on their properties. +When the contributor wants to add a new point, they'll be notified that some features might be hidden and only be allowed to add a new point when the points are shown. + +![](./FilterFunctionality.gif) + +```json +{ + "id": "my awesome layer", + "tagRenderings": "... some relevant attributes and questions ...", + "mapRenderings": "... display on the map ... ", + "filter": [ + { + "id": "vegetarian", + "options": [ + { + "question": { + "en": "Has a vegetarian menu" + }, + "osmTags": { + "or": [ + "diet:vegetarian=yes", + "diet:vegetarian=only", + "diet:vegan=yes", + "diet:vegan=only" + ] + } + } + ] + } + ] +} +``` + +If you want to show only features of a certain type, there is a workaround. +For example, the [fritures map](https://mapcomplete.osm.be/fritures.html?z=1&welcome-control-toggle=true) will show french fries shop, aka every `amenity~fast_food|restaurant` with `cuisine=friture`. +However, quite a few fritures are already mapped as fastfood but have their `cuisine`-tag missing (or misspelled). + +There is a workaround for this: show **all** food related items at zoomlevel 19 (or higher), and only show the fritures when zoomed out. + +In order to achieve this: + +1. The layer 'food' is defined in a separate file and reused +2. The layer food is imported in the theme 'fritures'. With 'override', some properties are changed, namely: + - The `osmTags` are overwritten: `cuisine=friture` is now required + - The presets are overwritten and _disabled_ + - The _id_ and _name_ of the layer are changed +3. The layer `food` is imported _a second time_, but now the minzoom is set to `19`. This will show _all_ restaurants. + +In case of a friture which is already added as fastfood, they'll see the fastfood popup instead of adding a new item: + +![](./FilteredByDepth.gif) + +```json +{ + "layers": [ + { + "builtin": "food", + "override": { + "id": "friture", + "name": { + "en": "Fries shop" + }, + "=presets": [], + "source": { + "=osmTags": { + "and": [ + "cuisine=friture", + { + "or": [ + "amenity=fast_food", + "amenity=restaurant" + ] + } + ] + } + } + } + }, + { + "builtin": "food", + "override": { + "minzoom": 19, + "filter": null, + "name": null + } + } + ] +} +``` + + +### What is a good question and tagrendering? + +A tagrendering maps an attribute onto a piece of human readable text. +These should be **full sentences**, e.g. `"render": "The maximum speed of this road is {maxspeed} km/h"` + +In some cases, there might be some predifined special values as mappings, such as `"mappings": [{"if": "maxspeed=30", "then": "The maxspeed is 30km/h"}]` + +The question then follows logically: `{"question": "What is the maximum allowed speed for this road, in km/h?"}` +At last, you'll also want to say that the user can type an answer too and that it has to be a number: `"freeform":{"key": "maxspeed","type":"pnat"}`. + +The entire tagRendering will thus be: + +```json +{ + "question": "What is the maximum allowed speed for this road, in km/h?", + "render": "The maximum speed of this road is {maxspeed} km/h", + "freeform":{"key": "maxspeed","type":"pnat"}, + "mappings": [{"if": "maxspeed=30", "then": "The maxspeed is 30km/h"}] +} +``` + + The template ------------ @@ -229,16 +437,10 @@ disregarding other properties. One should not make one layer for benches with a backrest and one layer for benches without. This is confusing for users and poses problems: what if the backrest status is unknown? What if it is some weird value? Also, it isn't possible to ' -move' an attribute to another layer. +move' a feature to another layer. Instead, make one layer for one kind of object and change the icon based on attributes. -### Using layers as filters - -Using layers as filters - this doesn't work! - -Use the `filter`-functionality instead. - ### Not reading the theme JSON specs There are a few advanced features to do fancy stuff available, which are documented only in the spec above - for diff --git a/Docs/theme-template.json b/Docs/theme-template.json index 9ead97d564..8d904d0aad 100644 --- a/Docs/theme-template.json +++ b/Docs/theme-template.json @@ -22,9 +22,8 @@ "#": "For more options and configuration, see the documentation in LayoutConfig.json", "#layers": "The list of layers is where most of the content will be. Either reuse an already existing layer by simply calling it's ID or define a whole new layer. An overview of builtin layers is at https://github.com/pietervdvn/MapComplete/blob/develop/Docs/BuiltinLayers.md#normal-layers", "layers": [ - "bench", { - "id": "a singular nound describing the feature, in english", + "id": "a singular noun describing the feature, in english", "source": { "osmTags": { "#": "For a description on which tags are possible, see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/Tags_format.md", diff --git a/Logic/Actors/GeoLocationHandler.ts b/Logic/Actors/GeoLocationHandler.ts index 8eba88e44d..24e8e5268a 100644 --- a/Logic/Actors/GeoLocationHandler.ts +++ b/Logic/Actors/GeoLocationHandler.ts @@ -6,6 +6,7 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import {QueryParameters} from "../Web/QueryParameters"; import FeatureSource from "../FeatureSource/FeatureSource"; import {BBox} from "../BBox"; +import Constants from "../../Models/Constants"; export interface GeoLocationPointProperties { id: "gps", @@ -25,13 +26,11 @@ export default class GeoLocationHandler extends VariableUiElement { /** * Wether or not the geolocation is active, aka the user requested the current location - * @private */ private readonly _isActive: UIEventSource; /** * Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user - * @private */ private readonly _isLocked: UIEventSource; @@ -54,9 +53,8 @@ export default class GeoLocationHandler extends VariableUiElement { /** * The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs - * @private */ - private _lastUserRequest: Date; + private _lastUserRequest: UIEventSource; /** * A small flag on localstorage. If the user previously granted the geolocation, it will be set. @@ -80,6 +78,8 @@ export default class GeoLocationHandler extends VariableUiElement { ) { const currentGPSLocation = new UIEventSource(undefined, "GPS-coordinate") const leafletMap = state.leafletMap + const initedAt = new Date() + let autozoomDone = false; const hasLocation = currentGPSLocation.map( (location) => location !== undefined ); @@ -97,13 +97,28 @@ export default class GeoLocationHandler extends VariableUiElement { const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000 return timeDiff <= 3 }) + + const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon") + const willFocus = lastClick.map(lastUserRequest => { + const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000 + if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) { + return true + } + if (lastUserRequest === undefined) { + return false; + } + const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000 + return timeDiff <= Constants.zoomToLocationTimeout + }) + lastClick.addCallbackAndRunD(_ => { window.setTimeout(() => { - if (lastClickWithinThreeSecs.data) { + if (lastClickWithinThreeSecs.data || willFocus.data) { lastClick.ping() } }, 500) }) + super( hasLocation.map( (hasLocationData) => { @@ -116,7 +131,8 @@ export default class GeoLocationHandler extends VariableUiElement { } if (!hasLocationData) { // Position not yet found but we are active: we spin to indicate activity - const icon = Svg.location_empty_svg() + // If will focus is active too, we indicate this differently + const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg() icon.SetStyle("animation: spin 4s linear infinite;") return icon; } @@ -130,7 +146,7 @@ export default class GeoLocationHandler extends VariableUiElement { // We have a location, so we show a dot in the center return Svg.location_svg(); }, - [isActive, isLocked, permission, lastClickWithinThreeSecs] + [isActive, isLocked, permission, lastClickWithinThreeSecs, willFocus] ) ); this.SetClass("mapcontrol") @@ -142,6 +158,7 @@ export default class GeoLocationHandler extends VariableUiElement { this._leafletMap = leafletMap; this._layoutToUse = state.layoutToUse; this._hasLocation = hasLocation; + this._lastUserRequest = lastClick const self = this; const currentPointer = this._isActive.map( @@ -183,7 +200,6 @@ export default class GeoLocationHandler extends VariableUiElement { self.init(true, true); }); - const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon") const doAutoZoomToLocation = !latLonGiven && state.featureSwitchGeolocation.data && state.selectedElement.data !== undefined this.init(false, doAutoZoomToLocation); @@ -221,8 +237,12 @@ export default class GeoLocationHandler extends VariableUiElement { self.currentLocation?.features?.setData([{feature, freshness: new Date()}]) const timeSinceRequest = - (new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000; - if (timeSinceRequest < 30) { + (new Date().getTime() - (self._lastUserRequest.data?.getTime() ?? 0)) / 1000; + + if (willFocus.data) { + console.log("Zooming to user location: willFocus is set") + willFocus.setData(false) + autozoomDone = true; self.MoveToCurrentLocation(16); } else if (self._isLocked.data) { self.MoveToCurrentLocation(); @@ -239,8 +259,8 @@ export default class GeoLocationHandler extends VariableUiElement { self.MoveToCurrentLocation(16); return; } - - if(typeof navigator === "undefined"){ + + if (typeof navigator === "undefined") { return } @@ -271,7 +291,7 @@ export default class GeoLocationHandler extends VariableUiElement { /** * Moves to the currently loaded location. - * + * * // Should move to any location * let resultingLocation = undefined * let resultingzoom = 1 @@ -321,7 +341,7 @@ export default class GeoLocationHandler extends VariableUiElement { */ private MoveToCurrentLocation(targetZoom?: number) { const location = this._currentGPSLocation.data; - this._lastUserRequest = undefined; + this._lastUserRequest.setData(undefined); if ( this._currentGPSLocation.data.latitude === 0 && @@ -341,14 +361,9 @@ export default class GeoLocationHandler extends VariableUiElement { } } if (!inRange) { - console.log( - "Not zooming to GPS location: out of bounds", - b, - location - ); + console.log("Not zooming to GPS location: out of bounds", b, location); } else { const currentZoom = this._leafletMap.data.getZoom() - this._leafletMap.data.setView([location.latitude, location.longitude], Math.max(targetZoom ?? 0, currentZoom)); } } @@ -356,7 +371,7 @@ export default class GeoLocationHandler extends VariableUiElement { private StartGeolocating(zoomToGPS = true) { const self = this; - this._lastUserRequest = zoomToGPS ? new Date() : new Date(0); + this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0)) if (self._permission.data === "denied") { self._previousLocationGrant.setData(""); self._isActive.setData(false) diff --git a/Logic/Osm/OsmPreferences.ts b/Logic/Osm/OsmPreferences.ts index 7e06bdfc64..dc9b5c50e1 100644 --- a/Logic/Osm/OsmPreferences.ts +++ b/Logic/Osm/OsmPreferences.ts @@ -76,9 +76,9 @@ export class OsmPreferences { function updateData(l: number) { - if (l === undefined) { - source.setData(undefined); - return; + if(Object.keys(self.preferences.data).length === 0){ + // The preferences are still empty - they are not yet updated, so we delay updating for now + return } const prefsCount = Number(l); if (prefsCount > 100) { @@ -86,7 +86,11 @@ export class OsmPreferences { } let str = ""; for (let i = 0; i < prefsCount; i++) { - str += self.GetPreference(allStartWith + "-" + i, "").data; + const key = allStartWith + "-" + i + if(self.preferences.data[key] === undefined){ + console.warn("Detected a broken combined preference:", key, "is undefined", self.preferences) + } + str += self.preferences.data[key] ?? ""; } source.setData(str); @@ -95,7 +99,9 @@ export class OsmPreferences { length.addCallback(l => { updateData(Number(l)); }); - updateData(Number(length.data)); + this.preferences.addCallbackAndRun(_ => { + updateData(Number(length.data)); + }) return source; } @@ -127,7 +133,8 @@ export class OsmPreferences { public ClearPreferences() { let isRunning = false; const self = this; - this.preferences.addCallbackAndRun(prefs => { + this.preferences.addCallback(prefs => { + console.log("Cleaning preferences...") if (Object.keys(prefs).length == 0) { return; } @@ -135,19 +142,17 @@ export class OsmPreferences { return } isRunning = true - const prefixes = ["mapcomplete-installed-theme", "mapcomplete-installed-themes-", "mapcomplete-current-open-changeset", "mapcomplete-personal-theme-layer"] + const prefixes = ["mapcomplete-"] for (const key in prefs) { - for (const prefix of prefixes) { - if (key.startsWith(prefix)) { - console.log("Clearing ", key) - self.GetPreference(key, "").setData("") + const matches = prefixes.some(prefix => key.startsWith(prefix)) + if (matches) { + console.log("Clearing ", key) + self.GetPreference(key, "").setData("") - } } } isRunning = false; - return true; - + return; }) } @@ -173,7 +178,6 @@ export class OsmPreferences { // For differing values, the server overrides local changes self.preferenceSources.forEach((preference, key) => { const osmValue = self.preferences.data[key] - console.log("Sending value to osm:", key," osm has: ", osmValue, " local has: ", preference.data) if(osmValue === undefined && preference.data !== undefined){ // OSM doesn't know this value yet self.UploadPreference(key, preference.data) diff --git a/Logic/State/ElementsState.ts b/Logic/State/ElementsState.ts index c7fc1dbbf4..f92891358b 100644 --- a/Logic/State/ElementsState.ts +++ b/Logic/State/ElementsState.ts @@ -43,29 +43,34 @@ export default class ElementsState extends FeatureSwitchState { constructor(layoutToUse: LayoutConfig) { super(layoutToUse); + + + function localStorageSynced(key: string, deflt: number, docs: string ): UIEventSource{ + const localStorage = LocalStorageSource.Get(key) + const previousValue = localStorage.data + const src = UIEventSource.asFloat( + QueryParameters.GetQueryParameter( + key, + "" + deflt, + docs + ).syncWith(localStorage) + ); + + if(src.data === deflt){ + const prev = Number(previousValue) + if(!isNaN(prev)){ + src.setData(prev) + } + } + + return src; + } // -- Location control initialization - const zoom = UIEventSource.asFloat( - QueryParameters.GetQueryParameter( - "z", - "" + (layoutToUse?.startZoom ?? 1), - "The initial/current zoom level" - ).syncWith(LocalStorageSource.Get("zoom")) - ); - const lat = UIEventSource.asFloat( - QueryParameters.GetQueryParameter( - "lat", - "" + (layoutToUse?.startLat ?? 0), - "The initial/current latitude" - ).syncWith(LocalStorageSource.Get("lat")) - ); - const lon = UIEventSource.asFloat( - QueryParameters.GetQueryParameter( - "lon", - "" + (layoutToUse?.startLon ?? 0), - "The initial/current longitude of the app" - ).syncWith(LocalStorageSource.Get("lon")) - ); + const zoom = localStorageSynced("z",(layoutToUse?.startZoom ?? 1),"The initial/current zoom level") + const lat = localStorageSynced("lat",(layoutToUse?.startLat ?? 0),"The initial/current latitude") + const lon = localStorageSynced("lon",(layoutToUse?.startLon ?? 0),"The initial/current longitude of the app") + this.locationControl.setData({ zoom: Utils.asFloat(zoom.data), @@ -73,7 +78,7 @@ export default class ElementsState extends FeatureSwitchState { lon: Utils.asFloat(lon.data), }) this.locationControl.addCallback((latlonz) => { - // Sync th location controls + // Sync the location controls zoom.setData(latlonz.zoom); lat.setData(latlonz.lat); lon.setData(latlonz.lon); diff --git a/Logic/Tags/And.ts b/Logic/Tags/And.ts index 5a2eecbc9c..340c2161fd 100644 --- a/Logic/Tags/And.ts +++ b/Logic/Tags/And.ts @@ -8,6 +8,13 @@ export class And extends TagsFilter { super(); this.and = and } + + public static construct(and: TagsFilter[]): TagsFilter{ + if(and.length === 1){ + return and[0] + } + return new And(and) + } private static combine(filter: string, choices: string[]): string[] { const values = []; @@ -45,7 +52,7 @@ export class And extends TagsFilter { * import {RegexTag} from "./RegexTag"; * * const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)]) - * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!~\"^98$\"]" ] + * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]" ] */ asOverpass(): string[] { let allChoices: string[] = null; @@ -87,17 +94,17 @@ export class And extends TagsFilter { * ]) * const t1 = new And([new Tag("valves", "A")]) * const t2 = new And([new Tag("valves", "B")]) - * t0.isEquivalent(t0) // => true - * t1.isEquivalent(t1) // => true - * t2.isEquivalent(t2) // => true - * t0.isEquivalent(t1) // => false - * t0.isEquivalent(t2) // => false - * t1.isEquivalent(t0) // => false - * t1.isEquivalent(t2) // => false - * t2.isEquivalent(t0) // => false - * t2.isEquivalent(t1) // => false + * t0.shadows(t0) // => true + * t1.shadows(t1) // => true + * t2.shadows(t2) // => true + * t0.shadows(t1) // => false + * t0.shadows(t2) // => false + * t1.shadows(t0) // => false + * t1.shadows(t2) // => false + * t2.shadows(t0) // => false + * t2.shadows(t1) // => false */ - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { if (!(other instanceof And)) { return false; } @@ -105,7 +112,7 @@ export class And extends TagsFilter { for (const selfTag of this.and) { let matchFound = false; for (const otherTag of other.and) { - matchFound = selfTag.isEquivalent(otherTag); + matchFound = selfTag.shadows(otherTag); if (matchFound) { break; } @@ -118,7 +125,7 @@ export class And extends TagsFilter { for (const otherTag of other.and) { let matchFound = false; for (const selfTag of this.and) { - matchFound = selfTag.isEquivalent(otherTag); + matchFound = selfTag.shadows(otherTag); if (matchFound) { break; } @@ -148,23 +155,90 @@ export class And extends TagsFilter { return result; } + /** + * IN some contexts, some expressions can be considered true, e.g. + * (X=Y | (A=B & X=Y)) + * ^---------^ + * When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise. + * This means that the entire 'AND' is considered FALSE + * + * new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value") + * new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false + * new And([ new RegexTag("key",/^..*$/) ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value") + * new And([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true + * + * // should remove 'club~*' if we know that 'club=climbing' + * const expr = TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) + * expr.removePhraseConsideredKnown(new Tag("club","climbing"), true) // => new Tag("sport","climbing") + * + * const expr = TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) + * expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr + */ + removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean { + const newAnds: TagsFilter[] = [] + for (const tag of this.and) { + if(tag instanceof And){ + throw "Optimize expressions before using removePhraseConsideredKnown" + } + if(tag instanceof Or){ + const r = tag.removePhraseConsideredKnown(knownExpression, value) + if(r === true){ + continue + } + if(r === false){ + return false; + } + newAnds.push(r) + continue + } + if(value && knownExpression.shadows(tag)){ + /** + * At this point, we do know that 'knownExpression' is true in every case + * As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true, + * we can be sure that 'tag' is true as well. + * + * "True" is the neutral element in an AND, so we can skip the tag + */ + continue + } + if(!value && tag.shadows(knownExpression)){ + + /** + * We know that knownExpression is unmet. + * if the tag shadows 'knownExpression' (which is the case when control flows gets here), + * then tag CANNOT be met too, as known expression is not met. + * + * This implies that 'tag' must be false too! + */ + + // false is the element which absorbs all + return false + } + + newAnds.push(tag) + } + if(newAnds.length === 0){ + return true + } + return And.construct(newAnds) + } + optimize(): TagsFilter | boolean { if(this.and.length === 0){ return true } - const optimized = this.and.map(t => t.optimize()) + const optimizedRaw = this.and.map(t => t.optimize()) + .filter(t => t !== true /* true is the neutral element in an AND, we drop them*/ ) + if(optimizedRaw.some(t => t === false)){ + // We have an AND with a contained false: this is always 'false' + return false; + } + const optimized = optimizedRaw; const newAnds : TagsFilter[] = [] let containedOrs : Or[] = [] for (const tf of optimized) { - if(tf === false){ - return false - } - if(tf === true){ - continue - } - if(tf instanceof And){ newAnds.push(...tf.and) }else if(tf instanceof Or){ @@ -173,27 +247,56 @@ export class And extends TagsFilter { newAnds.push(tf) } } - - containedOrs = containedOrs.filter(ca => { - for (const element of ca.or) { - if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){ - // At least one part of the 'OR' is matched by the outer or, so this means that this OR isn't needed at all - // XY & (XY | AB) === XY - return false + + { + let dirty = false; + do { + const cleanedContainedOrs : Or[] = [] + outer: for (let containedOr of containedOrs) { + for (const known of newAnds) { + // input for optimazation: (K=V & (X=Y | K=V)) + // containedOr: (X=Y | K=V) + // newAnds (and thus known): (K=V) --> true + const cleaned = containedOr.removePhraseConsideredKnown(known, true) + if (cleaned === true) { + // The neutral element within an AND + continue outer // skip addition too + } + if (cleaned === false) { + // zero element + return false + } + if (cleaned instanceof Or) { + containedOr = cleaned + continue + } + // the 'or' dissolved into a normal tag -> it has to be added to the newAnds + newAnds.push(cleaned) + dirty = true; // rerun this algo later on + continue outer; + } + cleanedContainedOrs.push(containedOr) } - } - return true; + containedOrs = cleanedContainedOrs + } while(dirty) + } + + + containedOrs = containedOrs.filter(ca => { + const isShadowed = TagUtils.containsEquivalents(newAnds, ca.or) + // If 'isShadowed', then at least one part of the 'OR' is matched by the outer and, so this means that this OR isn't needed at all + // XY & (XY | AB) === XY + return !isShadowed; }) // Extract common keys from the OR if(containedOrs.length === 1){ newAnds.push(containedOrs[0]) - } - if(containedOrs.length > 1){ + }else if(containedOrs.length > 1){ let commonValues : TagsFilter [] = containedOrs[0].or for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++){ const containedOr = containedOrs[i]; - commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.isEquivalent(cv))) + commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.shadows(cv))) } if(commonValues.length === 0){ newAnds.push(...containedOrs) @@ -201,19 +304,11 @@ export class And extends TagsFilter { const newOrs: TagsFilter[] = [] for (const containedOr of containedOrs) { const elements = containedOr.or - .filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate))) - const or = new Or(elements).optimize() - if(or === true){ - // neutral element - continue - } - if(or === false){ - return false - } - newOrs.push(or) + .filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) + newOrs.push(Or.construct(elements)) } - commonValues.push(new And(newOrs)) + commonValues.push(And.construct(newOrs)) const result = new Or(commonValues).optimize() if(result === false){ return false @@ -224,16 +319,22 @@ export class And extends TagsFilter { } } } - - if(newAnds.length === 1){ - return newAnds[0] + if(newAnds.length === 0){ + return true } + + if(TagUtils.ContainsOppositeTags(newAnds)){ + return false + } + TagUtils.sortFilters(newAnds, true) - return new And(newAnds) + return And.construct(newAnds) } isNegative(): boolean { return !this.and.some(t => !t.isNegative()); } + + } \ No newline at end of file diff --git a/Logic/Tags/ComparingTag.ts b/Logic/Tags/ComparingTag.ts index 6b790311bb..fa543350fe 100644 --- a/Logic/Tags/ComparingTag.ts +++ b/Logic/Tags/ComparingTag.ts @@ -23,7 +23,7 @@ export default class ComparingTag implements TagsFilter { throw "A comparable tag can not be used as overpass filter" } - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { return other === this; } diff --git a/Logic/Tags/Or.ts b/Logic/Tags/Or.ts index 4d3ab20e02..b2aa7ec27c 100644 --- a/Logic/Tags/Or.ts +++ b/Logic/Tags/Or.ts @@ -11,6 +11,14 @@ export class Or extends TagsFilter { this.or = or; } + public static construct(or: TagsFilter[]): TagsFilter{ + if(or.length === 1){ + return or[0] + } + return new Or(or) + } + + matchesProperties(properties: any): boolean { for (const tagsFilter of this.or) { if (tagsFilter.matchesProperties(properties)) { @@ -28,7 +36,7 @@ export class Or extends TagsFilter { * * const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)]) * const or = new Or([and, new Tag("leisure", "nature_reserve"]) - * or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!~\"^98$\"]", "[\"leisure\"=\"nature_reserve\"]" ] + * or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]", "[\"leisure\"=\"nature_reserve\"]" ] * * // should fuse nested ors into a single list * const or = new Or([new Tag("key","value"), new Or([new Tag("key1","value1"), new Tag("key2","value2")])]) @@ -51,14 +59,14 @@ export class Or extends TagsFilter { return false; } - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { if (other instanceof Or) { for (const selfTag of this.or) { let matchFound = false; for (let i = 0; i < other.or.length && !matchFound; i++) { let otherTag = other.or[i]; - matchFound = selfTag.isEquivalent(otherTag); + matchFound = selfTag.shadows(otherTag); } if (!matchFound) { return false; @@ -85,45 +93,127 @@ export class Or extends TagsFilter { return result; } + /** + * IN some contexts, some expressions can be considered true, e.g. + * (X=Y & (A=B | X=Y)) + * ^---------^ + * When the evaluation hits (A=B | X=Y), we know _for sure_ that X=Y _does match, as it would have failed the first clause otherwise. + * This means we can safely ignore this in the OR + * + * new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // =>true + * new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => new Tag("other_key","value") + * new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true + * new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false + * new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")]).removePhraseConsideredKnown(new Tag("foo","bar"), false) // => new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")]) + */ + removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean { + const newOrs: TagsFilter[] = [] + for (const tag of this.or) { + if(tag instanceof Or){ + throw "Optimize expressions before using removePhraseConsideredKnown" + } + if(tag instanceof And){ + const r = tag.removePhraseConsideredKnown(knownExpression, value) + if(r === false){ + continue + } + if(r === true){ + return true; + } + newOrs.push(r) + continue + } + if(value && knownExpression.shadows(tag)){ + /** + * At this point, we do know that 'knownExpression' is true in every case + * As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true, + * we can be sure that 'tag' is true as well. + * + * "True" is the absorbing element in an OR, so we can return true + */ + return true; + } + if(!value && tag.shadows(knownExpression)){ + + /** + * We know that knownExpression is unmet. + * if the tag shadows 'knownExpression' (which is the case when control flows gets here), + * then tag CANNOT be met too, as known expression is not met. + * + * This implies that 'tag' must be false too! + * false is the neutral element in an OR + */ + continue + } + newOrs.push(tag) + } + if(newOrs.length === 0){ + return false + } + return Or.construct(newOrs) + } + optimize(): TagsFilter | boolean { if(this.or.length === 0){ return false; } - const optimized = this.or.map(t => t.optimize()) + const optimizedRaw = this.or.map(t => t.optimize()) + .filter(t => t !== false /* false is the neutral element in an OR, we drop them*/ ) + if(optimizedRaw.some(t => t === true)){ + // We have an OR with a contained true: this is always 'true' + return true; + } + const optimized = optimizedRaw; + const newOrs : TagsFilter[] = [] - let containedAnds : And[] = [] for (const tf of optimized) { - if(tf === true){ - return true - } - if(tf === false){ - continue - } - if(tf instanceof Or){ + // expand all the nested ors... newOrs.push(...tf.or) }else if(tf instanceof And){ + // partition of all the ands containedAnds.push(tf) } else { newOrs.push(tf) } } - containedAnds = containedAnds.filter(ca => { - for (const element of ca.and) { - if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){ - // At least one part of the 'AND' is matched by the outer or, so this means that this OR isn't needed at all - // XY | (XY & AB) === XY - return false + { + let dirty = false; + do { + const cleanedContainedANds : And[] = [] + outer: for (let containedAnd of containedAnds) { + for (const known of newOrs) { + // input for optimazation: (K=V | (X=Y & K=V)) + // containedAnd: (X=Y & K=V) + // newOrs (and thus known): (K=V) --> false + const cleaned = containedAnd.removePhraseConsideredKnown(known, false) + if (cleaned === false) { + // The neutral element within an OR + continue outer // skip addition too + } + if (cleaned === true) { + // zero element + return true + } + if (cleaned instanceof And) { + containedAnd = cleaned + continue // clean up with the other known values + } + // the 'and' dissolved into a normal tag -> it has to be added to the newOrs + newOrs.push(cleaned) + dirty = true; // rerun this algo later on + continue outer; + } + cleanedContainedANds.push(containedAnd) } - } - return true; - }) - + containedAnds = cleanedContainedANds + } while(dirty) + } // Extract common keys from the ANDS if(containedAnds.length === 1){ newOrs.push(containedAnds[0]) @@ -131,40 +221,46 @@ export class Or extends TagsFilter { let commonValues : TagsFilter [] = containedAnds[0].and for (let i = 1; i < containedAnds.length && commonValues.length > 0; i++){ const containedAnd = containedAnds[i]; - commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.isEquivalent(cv))) + commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.shadows(cv))) } if(commonValues.length === 0){ newOrs.push(...containedAnds) }else{ const newAnds: TagsFilter[] = [] for (const containedAnd of containedAnds) { - const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate))) - newAnds.push(new And(elements)) + const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) + newAnds.push(And.construct(elements)) } - commonValues.push(new Or(newAnds)) + commonValues.push(Or.construct(newAnds)) const result = new And(commonValues).optimize() if(result === true){ return true }else if(result === false){ // neutral element: skip }else{ - newOrs.push(new And(commonValues)) + newOrs.push(And.construct(commonValues)) } } } - if(newOrs.length === 1){ - return newOrs[0] + if(newOrs.length === 0){ + return false } + + if(TagUtils.ContainsOppositeTags(newOrs)){ + return true + } + TagUtils.sortFilters(newOrs, false) - return new Or(newOrs) + return Or.construct(newOrs) } isNegative(): boolean { return this.or.some(t => t.isNegative()); } + } diff --git a/Logic/Tags/RegexTag.ts b/Logic/Tags/RegexTag.ts index 8a4c7ad3b8..a5db5a77f8 100644 --- a/Logic/Tags/RegexTag.ts +++ b/Logic/Tags/RegexTag.ts @@ -10,13 +10,6 @@ export class RegexTag extends TagsFilter { constructor(key: string | RegExp, value: RegExp | string, invert: boolean = false) { super(); this.key = key; - if (typeof value === "string") { - if (value.indexOf("^") < 0 && value.indexOf("$") < 0) { - value = "^" + value + "$" - } - value = new RegExp(value) - } - this.value = value; this.invert = invert; this.matchesEmpty = RegexTag.doesMatch("", this.value); @@ -79,14 +72,14 @@ export class RegexTag extends TagsFilter { /** * Checks if this tag matches the given properties * - * const isNotEmpty = new RegexTag("key","^$", true); + * const isNotEmpty = new RegexTag("key",/^$/, true); * isNotEmpty.matchesProperties({"key": "value"}) // => true * isNotEmpty.matchesProperties({"key": "other_value"}) // => true * isNotEmpty.matchesProperties({"key": ""}) // => false * isNotEmpty.matchesProperties({"other_key": ""}) // => false * isNotEmpty.matchesProperties({"other_key": "value"}) // => false * - * const isNotEmpty = new RegexTag("key","^..*$", true); + * const isNotEmpty = new RegexTag("key",/^..*$/, true); * isNotEmpty.matchesProperties({"key": "value"}) // => false * isNotEmpty.matchesProperties({"key": "other_value"}) // => false * isNotEmpty.matchesProperties({"key": ""}) // => true @@ -121,6 +114,9 @@ export class RegexTag extends TagsFilter { * importMatch.matchesProperties({"tags": "amenity=public_bookcase"}) // =>true * importMatch.matchesProperties({"tags": "name=test;amenity=public_bookcase"}) // =>true * importMatch.matchesProperties({"tags": "amenity=bench"}) // =>false + * + * new RegexTag("key","value").matchesProperties({"otherkey":"value"}) // => false + * new RegexTag("key","value",true).matchesProperties({"otherkey":"something"}) // => true */ matchesProperties(tags: any): boolean { if (typeof this.key === "string") { @@ -147,17 +143,87 @@ export class RegexTag extends TagsFilter { asHumanString() { if (typeof this.key === "string") { - return `${this.key}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}`; + const oper = typeof this.value === "string" ? "=" : "~" + return `${this.key}${this.invert ? "!" : ""}${oper}${RegexTag.source(this.value)}`; } return `${this.key.source}${this.invert ? "!" : ""}~~${RegexTag.source(this.value)}` } - isEquivalent(other: TagsFilter): boolean { + /** + * + * new RegexTag("key","value").shadows(new Tag("key","value")) // => true + * new RegexTag("key",/value/).shadows(new RegexTag("key","value")) // => true + * new RegexTag("key",/^..*$/).shadows(new Tag("key","value")) // => false + * new RegexTag("key",/^..*$/).shadows(new Tag("other_key","value")) // => false + * new RegexTag("key", /^a+$/).shadows(new Tag("key", "a")) // => false + * + * + * // should not shadow too eagerly: the first tag might match 'key=abc', the second won't + * new RegexTag("key", /^..*$/).shadows(new Tag("key", "some_value")) // => false + * + * // should handle 'invert' + * new RegexTag("key",/^..*$/, true).shadows(new Tag("key","value")) // => false + * new RegexTag("key",/^..*$/, true).shadows(new Tag("key","")) // => true + * new RegexTag("key","value", true).shadows(new Tag("key","value")) // => false + * new RegexTag("key","value", true).shadows(new Tag("key","some_other_value")) // => false + */ + shadows(other: TagsFilter): boolean { if (other instanceof RegexTag) { - return other.asHumanString() == this.asHumanString(); + if((other.key["source"] ?? other.key) !== (this.key["source"] ?? this.key) ){ + // Keys don't match, never shadowing + return false + } + if((other.value["source"] ?? other.key) === (this.value["source"] ?? this.key) && this.invert == other.invert ){ + // Values (and inverts) match + return true + } + if(typeof other.value ==="string"){ + const valuesMatch = RegexTag.doesMatch(other.value, this.value) + if(!this.invert && !other.invert){ + // this: key~value, other: key=value + return valuesMatch + } + if(this.invert && !other.invert){ + // this: key!~value, other: key=value + return !valuesMatch + } + if(!this.invert && other.invert){ + // this: key~value, other: key!=value + return !valuesMatch + } + if(!this.invert && !other.invert){ + // this: key!~value, other: key!=value + return valuesMatch + } + + } + return false; } if (other instanceof Tag) { - return RegexTag.doesMatch(other.key, this.key) && RegexTag.doesMatch(other.value, this.value); + if(!RegexTag.doesMatch(other.key, this.key)){ + // Keys don't match + return false; + } + + + if(this.value["source"] === "^..*$") { + if(this.invert){ + return other.value === "" + } + return false + } + + if (this.invert) { + /* + * this: "a!=b" + * other: "a=c" + * actual property: a=x + * In other words: shadowing will never occur here + */ + return false; + } + // Unless the values are the same, it is pretty hard to figure out if they are shadowing. This is future work + return (this.value["source"] ?? this.value) === other.value; } return false; } diff --git a/Logic/Tags/SubstitutingTag.ts b/Logic/Tags/SubstitutingTag.ts index 532c2586e1..0cb941f140 100644 --- a/Logic/Tags/SubstitutingTag.ts +++ b/Logic/Tags/SubstitutingTag.ts @@ -35,7 +35,7 @@ export default class SubstitutingTag implements TagsFilter { throw "A variable with substitution can not be used to query overpass" } - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { if (!(other instanceof SubstitutingTag)) { return false; } diff --git a/Logic/Tags/Tag.ts b/Logic/Tags/Tag.ts index 9a49196882..1294c6a539 100644 --- a/Logic/Tags/Tag.ts +++ b/Logic/Tags/Tag.ts @@ -88,14 +88,23 @@ export class Tag extends TagsFilter { return true; } - isEquivalent(other: TagsFilter): boolean { - if (other instanceof Tag) { - return this.key === other.key && this.value === other.value; + /** + * // should handle advanced regexes + * new Tag("key", "aaa").shadows(new RegexTag("key", /a+/)) // => true + * new Tag("key","value").shadows(new RegexTag("key", /^..*$/, true)) // => false + * new Tag("key","value").shadows(new Tag("key","value")) // => true + * new Tag("key","some_other_value").shadows(new RegexTag("key", "value", true)) // => true + * new Tag("key","value").shadows(new RegexTag("key", "value", true)) // => false + * new Tag("key","value").shadows(new RegexTag("otherkey", "value", true)) // => false + * new Tag("key","value").shadows(new RegexTag("otherkey", "value", false)) // => false + */ + shadows(other: TagsFilter): boolean { + if(other["key"] !== undefined){ + if(other["key"] !== this.key){ + return false + } } - if (other instanceof RegexTag) { - other.isEquivalent(this); - } - return false; + return other.matchesProperties({[this.key]: this.value}); } usedKeys(): string[] { diff --git a/Logic/Tags/TagUtils.ts b/Logic/Tags/TagUtils.ts index 9ff07534f6..064c9cab32 100644 --- a/Logic/Tags/TagUtils.ts +++ b/Logic/Tags/TagUtils.ts @@ -200,15 +200,16 @@ export class TagUtils { * * TagUtils.Tag("key=value") // => new Tag("key", "value") * TagUtils.Tag("key=") // => new Tag("key", "") - * TagUtils.Tag("key!=") // => new RegexTag("key", "^..*$") - * TagUtils.Tag("key!=value") // => new RegexTag("key", /^value$/, true) + * TagUtils.Tag("key!=") // => new RegexTag("key", /^..*$/) + * TagUtils.Tag("key~*") // => new RegexTag("key", /^..*$/) + * TagUtils.Tag("key!=value") // => new RegexTag("key", "value", true) * TagUtils.Tag("vending~.*bicycle_tube.*") // => new RegexTag("vending", /^.*bicycle_tube.*$/) * TagUtils.Tag("x!~y") // => new RegexTag("x", /^y$/, true) * TagUtils.Tag({"and": ["key=value", "x=y"]}) // => new And([new Tag("key","value"), new Tag("x","y")]) * TagUtils.Tag("name~[sS]peelbos.*") // => new RegexTag("name", /^[sS]peelbos.*$/) * TagUtils.Tag("survey:date:={_date:now}") // => new SubstitutingTag("survey:date", "{_date:now}") * TagUtils.Tag("xyz!~\\[\\]") // => new RegexTag("xyz", /^\[\]$/, true) - * TagUtils.Tag("tags~(^|.*;)amenity=public_bookcase($|;.*)") // => new RegexTag("tags", /(^|.*;)amenity=public_bookcase($|;.*)/) + * TagUtils.Tag("tags~(.*;)?amenity=public_bookcase(;.*)?") // => new RegexTag("tags", /^(.*;)?amenity=public_bookcase(;.*)?$/) * TagUtils.Tag("service:bicycle:.*~~*") // => new RegexTag(/^service:bicycle:.*$/, /^..*$/) * * TagUtils.Tag("xyz<5").matchesProperties({xyz: 4}) // => true @@ -306,7 +307,7 @@ export class TagUtils { } return new RegexTag( split[0], - split[1], + new RegExp("^"+ split[1]+"$"), true ); } @@ -338,17 +339,6 @@ export class TagUtils { split[1] = "..*" return new RegexTag(split[0], /^..*$/) } - return new RegexTag( - split[0], - new RegExp("^" + split[1] + "$"), - true - ); - } - if (tag.indexOf("!~") >= 0) { - const split = Utils.SplitFirst(tag, "!~"); - if (split[1] === "*") { - split[1] = "..*" - } return new RegexTag( split[0], split[1], @@ -357,15 +347,18 @@ export class TagUtils { } if (tag.indexOf("~") >= 0) { const split = Utils.SplitFirst(tag, "~"); + let value : string | RegExp = split[1] if (split[1] === "") { throw "Detected a regextag with an empty regex; this is not allowed. Use '" + split[0] + "='instead (at " + context + ")" } - if (split[1] === "*") { - split[1] = "..*" + if (value === "*") { + value = /^..*$/ + }else { + value = new RegExp("^"+value+"$") } return new RegexTag( split[0], - split[1] + value ); } if (tag.indexOf("=") >= 0) { @@ -431,4 +424,94 @@ export class TagUtils { return " (" + joined + ") " } + /** + * Returns 'true' is opposite tags are detected. + * Note that this method will never work perfectly + * + * // should be false for some simple cases + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key0", "value")]) // => false + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key", "value0")]) // => false + * + * // should detect simple cases + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", "value", true)]) // => true + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", /value/, true)]) // => true + */ + public static ContainsOppositeTags(tags: (TagsFilter)[]) : boolean{ + for (let i = 0; i < tags.length; i++){ + const tag = tags[i]; + if(!(tag instanceof Tag || tag instanceof RegexTag)){ + continue + } + for (let j = i + 1; j < tags.length; j++){ + const guard = tags[j]; + if(!(guard instanceof Tag || guard instanceof RegexTag)){ + continue + } + if(guard.key !== tag.key) { + // Different keys: they can _never_ be opposites + continue + } + if((guard.value["source"] ?? guard.value) !== (tag.value["source"] ?? tag.value)){ + // different values: the can _never_ be opposites + continue + } + if( (guard["invert"] ?? false) !== (tag["invert"] ?? false) ) { + // The 'invert' flags are opposite, the key and value is the same for both + // This means we have found opposite tags! + return true + } + } + } + + return false + } + + /** + * Returns a filtered version of 'listToFilter'. + * For a list [t0, t1, t2], If `blackList` contains an equivalent (or broader) match of any `t`, this respective `t` is dropped from the returned list + * Ignores nested ORS and ANDS + * + * TagUtils.removeShadowedElementsFrom([new Tag("key","value")], [new Tag("key","value"), new Tag("other_key","value")]) // => [new Tag("other_key","value")] + */ + public static removeShadowedElementsFrom(blacklist: TagsFilter[], listToFilter: TagsFilter[] ) : TagsFilter[] { + return listToFilter.filter(tf => !blacklist.some(guard => guard.shadows(tf))) + } + + /** + * Returns a filtered version of 'listToFilter', where no duplicates and no equivalents exists. + * + * TagUtils.removeEquivalents([new RegexTag("key", /^..*$/), new Tag("key","value")]) // => [new Tag("key", "value")] + */ + public static removeEquivalents( listToFilter: (Tag | RegexTag)[]) : TagsFilter[] { + const result: TagsFilter[] = [] + outer: for (let i = 0; i < listToFilter.length; i++){ + const tag = listToFilter[i]; + for (let j = 0; j < listToFilter.length; j++){ + if(i === j){ + continue + } + const guard = listToFilter[j]; + if(guard.shadows(tag)) { + // the guard 'kills' the tag: we continue the outer loop without adding the tag + continue outer; + } + } + result.push(tag) + } + return result + } + + /** + * Returns `true` if at least one element of the 'guards' shadows one element of the 'listToFilter'. + * + * TagUtils.containsEquivalents([new Tag("key","value")], [new Tag("key","value"), new Tag("other_key","value")]) // => true + * TagUtils.containsEquivalents([new Tag("key","value")], [ new Tag("other_key","value")]) // => false + * TagUtils.containsEquivalents([new Tag("key","value")], [ new Tag("key","other_value")]) // => false + */ + public static containsEquivalents( guards: TagsFilter[], listToFilter: TagsFilter[] ) : boolean { + return listToFilter.some(tf => guards.some(guard => guard.shadows(tf))) + } + + + } \ No newline at end of file diff --git a/Logic/Tags/TagsFilter.ts b/Logic/Tags/TagsFilter.ts index f3794436f5..a99251bb2a 100644 --- a/Logic/Tags/TagsFilter.ts +++ b/Logic/Tags/TagsFilter.ts @@ -4,7 +4,11 @@ export abstract class TagsFilter { abstract isUsableAsAnswer(): boolean; - abstract isEquivalent(other: TagsFilter): boolean; + /** + * Indicates some form of equivalency: + * if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties + */ + abstract shadows(other: TagsFilter): boolean; abstract matchesProperties(properties: any): boolean; @@ -30,7 +34,7 @@ export abstract class TagsFilter { * Returns an optimized version (or self) of this tagsFilter */ abstract optimize(): TagsFilter | boolean; - + /** * Returns 'true' if the tagsfilter might select all features (i.e. the filter will return everything from OSM, except a few entries). * diff --git a/Models/Constants.ts b/Models/Constants.ts index 181cb41435..e829a39de0 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import {Utils} from "../Utils"; export default class Constants { - public static vNumber = "0.18.1"; + public static vNumber = "0.18.2"; public static ImgurApiKey = '7070e7167f0a25a' public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" @@ -62,6 +62,13 @@ export default class Constants { */ static distanceToChangeObjectBins = [25, 50, 100, 500, 1000, 5000, Number.MAX_VALUE] static themeOrder = ["personal", "cyclofix", "waste" , "etymology", "food","cafes_and_pubs", "playgrounds", "hailhydrant", "toilets", "aed", "bookcases"]; + /** + * Upon initialization, the GPS will search the location. + * If the location is found within the given timout, it'll automatically fly to it. + * + * In seconds + */ + static zoomToLocationTimeout = 60; private static isRetina(): boolean { if (Utils.runningFromConsole) { diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index ce2e56f051..b7863b4274 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -127,6 +127,7 @@ export default class LayerConfig extends WithContextLoader { idKey: json.source["idKey"] }, + Constants.priviliged_layers.indexOf(this.id) > 0, json.id ); diff --git a/Models/ThemeConfig/SourceConfig.ts b/Models/ThemeConfig/SourceConfig.ts index 0edd9b7b2c..5c31bdbcf1 100644 --- a/Models/ThemeConfig/SourceConfig.ts +++ b/Models/ThemeConfig/SourceConfig.ts @@ -1,5 +1,6 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import {RegexTag} from "../../Logic/Tags/RegexTag"; +import {param} from "jquery"; export default class SourceConfig { @@ -19,7 +20,7 @@ export default class SourceConfig { isOsmCache?: boolean, geojsonSourceLevel?: number, idKey?: string - }, context?: string) { + }, isSpecialLayer: boolean, context?: string) { let defined = 0; if (params.osmTags) { @@ -43,6 +44,15 @@ export default class SourceConfig { throw `Source defines a geojson-zoomLevel, but does not specify {x} nor {y} (or equivalent), this is probably a bug (in context ${context})` } } + if(params.osmTags !== undefined && !isSpecialLayer){ + const optimized = params.osmTags.optimize() + if(optimized === false){ + throw "Error at "+context+": the specified tags are conflicting with each other: they will never match anything at all" + } + if(optimized === true){ + throw "Error at "+context+": the specified tags are very wide: they will always match everything" + } + } this.osmTags = params.osmTags ?? new RegexTag("id", /.*/); this.overpassScript = params.overpassScript; this.geojsonSource = params.geojsonSource; diff --git a/Models/ThemeConfig/TagRenderingConfig.ts b/Models/ThemeConfig/TagRenderingConfig.ts index 0f2aa3fdb9..aa9f05eb89 100644 --- a/Models/ThemeConfig/TagRenderingConfig.ts +++ b/Models/ThemeConfig/TagRenderingConfig.ts @@ -1,4 +1,4 @@ -import {Translation} from "../../UI/i18n/Translation"; +import {Translation, TypedTranslation} from "../../UI/i18n/Translation"; import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import Translations from "../../UI/i18n/Translations"; import {TagUtils} from "../../Logic/Tags/TagUtils"; @@ -22,8 +22,8 @@ export default class TagRenderingConfig { public readonly id: string; public readonly group: string; - public readonly render?: Translation; - public readonly question?: Translation; + public readonly render?: TypedTranslation; + public readonly question?: TypedTranslation; public readonly condition?: TagsFilter; public readonly configuration_warnings: string[] = [] @@ -43,7 +43,7 @@ export default class TagRenderingConfig { public readonly mappings?: { readonly if: TagsFilter, readonly ifnot?: TagsFilter, - readonly then: Translation, + readonly then: TypedTranslation, readonly icon: string, readonly iconClass: string readonly hideInAnswer: boolean | TagsFilter @@ -110,12 +110,13 @@ export default class TagRenderingConfig { } const type = json.freeform.type ?? "string" - let placeholder = Translations.T(json.freeform.placeholder) + let placeholder: Translation = Translations.T(json.freeform.placeholder) if (placeholder === undefined) { const typeDescription = Translations.t.validation[type]?.description - placeholder = Translations.T(json.freeform.key+" ("+type+")") if(typeDescription !== undefined){ - placeholder = placeholder.Subs({[type]: typeDescription}) + placeholder = Translations.T(json.freeform.key+" ("+type+")").Subs({[type]: typeDescription}) + }else{ + placeholder = Translations.T(json.freeform.key+" ("+type+")") } } @@ -383,7 +384,7 @@ export default class TagRenderingConfig { let freeformKeyDefined = this.freeform?.key !== undefined; let usedFreeformValues = new Set() // We run over all the mappings first, to check if the mapping matches - const applicableMappings: { then: Translation, img?: string }[] = Utils.NoNull((this.mappings ?? [])?.map(mapping => { + const applicableMappings: { then: TypedTranslation, img?: string }[] = Utils.NoNull((this.mappings ?? [])?.map(mapping => { if (mapping.if === undefined) { return mapping; } @@ -404,7 +405,7 @@ export default class TagRenderingConfig { const leftovers = freeformValues.filter(v => !usedFreeformValues.has(v)) for (const leftover of leftovers) { applicableMappings.push({then: - this.render.replace("{"+this.freeform.key+"}", leftover) + new TypedTranslation(this.render.replace("{"+this.freeform.key+"}", leftover).translations) }) } } @@ -412,7 +413,7 @@ export default class TagRenderingConfig { return applicableMappings } - public GetRenderValue(tags: any, defltValue: any = undefined): Translation { + public GetRenderValue(tags: any, defltValue: any = undefined): TypedTranslation { return this.GetRenderValueWithImage(tags, defltValue).then } @@ -421,7 +422,7 @@ export default class TagRenderingConfig { * Not compatible with multiAnswer - use GetRenderValueS instead in that case * @constructor */ - public GetRenderValueWithImage(tags: any, defltValue: any = undefined): { then: Translation, icon?: string } { + public GetRenderValueWithImage(tags: any, defltValue: any = undefined): { then: TypedTranslation, icon?: string } { if (this.mappings !== undefined && !this.multiAnswer) { for (const mapping of this.mappings) { if (mapping.if === undefined) { diff --git a/UI/Base/LinkToWeblate.ts b/UI/Base/LinkToWeblate.ts index d088658aba..1239409a29 100644 --- a/UI/Base/LinkToWeblate.ts +++ b/UI/Base/LinkToWeblate.ts @@ -4,6 +4,7 @@ import Link from "./Link"; import Svg from "../../Svg"; export default class LinkToWeblate extends VariableUiElement { + private static URI: any; constructor(context: string, availableTranslations: object) { super( Locale.language.map(ln => { if (Locale.showLinkToWeblate.data === false) { @@ -36,4 +37,10 @@ export default class LinkToWeblate extends VariableUiElement { const baseUrl = "https://hosted.weblate.org/translate/mapcomplete/" return baseUrl + category + "/" + language + "/?offset=1&q=context%3A%3D%22" + key + "%22" } + + public static hrefToWeblateZen(language: string, category: string, searchKey: string): string{ + const baseUrl = "https://hosted.weblate.org/zen/mapcomplete/" + // ?offset=1&q=+state%3A%3Ctranslated+context%3Acampersite&sort_by=-priority%2Cposition&checksum= + return baseUrl + category + "/" + language + "?offset=1&q=+state%3A%3Ctranslated+context%3A"+encodeURIComponent(searchKey)+"&sort_by=-priority%2Cposition&checksum=" + } } \ No newline at end of file diff --git a/UI/BaseUIElement.ts b/UI/BaseUIElement.ts index 2157fb2f12..d01b728679 100644 --- a/UI/BaseUIElement.ts +++ b/UI/BaseUIElement.ts @@ -49,7 +49,7 @@ export default abstract class BaseUIElement { */ public SetClass(clss: string) { if (clss == undefined) { - return + return this } const all = clss.split(" ").map(clsName => clsName.trim()); let recordedChange = false; diff --git a/UI/BigComponents/AddNewMarker.ts b/UI/BigComponents/AddNewMarker.ts index 9bd89e3f53..479098beab 100644 --- a/UI/BigComponents/AddNewMarker.ts +++ b/UI/BigComponents/AddNewMarker.ts @@ -14,7 +14,7 @@ export default class AddNewMarker extends Combine { let last = undefined; for (const filteredLayer of filteredLayers) { const layer = filteredLayer.layerDef; - if(layer.name === undefined){ + if(layer.name === undefined && !filteredLayer.isDisplayed.data){ continue } for (const preset of filteredLayer.layerDef.presets) { diff --git a/UI/BigComponents/CopyrightPanel.ts b/UI/BigComponents/CopyrightPanel.ts index c1cadcf8b4..1b196f02b5 100644 --- a/UI/BigComponents/CopyrightPanel.ts +++ b/UI/BigComponents/CopyrightPanel.ts @@ -22,7 +22,7 @@ import {OsmConnection} from "../../Logic/Osm/OsmConnection"; import Constants from "../../Models/Constants"; import ContributorCount from "../../Logic/ContributorCount"; import Img from "../Base/Img"; -import {Translation} from "../i18n/Translation"; +import {TypedTranslation} from "../i18n/Translation"; import TranslatorsPanel from "./TranslatorsPanel"; export class OpenIdEditor extends VariableUiElement { @@ -198,7 +198,7 @@ export default class CopyrightPanel extends Combine { this.SetStyle("max-width:100%; width: 40rem; margin-left: 0.75rem; margin-right: 0.5rem") } - private static CodeContributors(contributors, translation: Translation): BaseUIElement { + private static CodeContributors(contributors, translation: TypedTranslation<{contributors, hiddenCount}>): BaseUIElement { const total = contributors.contributors.length; let filtered = [...contributors.contributors] diff --git a/UI/BigComponents/MoreScreen.ts b/UI/BigComponents/MoreScreen.ts index 4fa8d418a3..d53a5b9a25 100644 --- a/UI/BigComponents/MoreScreen.ts +++ b/UI/BigComponents/MoreScreen.ts @@ -90,10 +90,10 @@ export default class MoreScreen extends Combine { } let hash = "" - if(layout.definition !== undefined){ - hash = "#"+btoa(JSON.stringify(layout.definition)) + if (layout.definition !== undefined) { + hash = "#" + btoa(JSON.stringify(layout.definition)) } - + const linkText = currentLocation?.map(currentLocation => { const params = [ ["z", currentLocation?.zoom], @@ -106,11 +106,10 @@ export default class MoreScreen extends Combine { }) ?? new UIEventSource(`${linkPrefix}`) - return new SubtleButton(layout.icon, new Combine([ `
`, - new Translation(layout.title, !isCustom && !layout.mustHaveLanguage ? "themes:"+layout.id+".title" : undefined), + new Translation(layout.title, !isCustom && !layout.mustHaveLanguage ? "themes:" + layout.id + ".title" : undefined), `
`, `
`, new Translation(layout.shortDescription)?.SetClass("subtle") ?? "", @@ -128,15 +127,13 @@ export default class MoreScreen extends Combine { } private static createUnofficialButtonFor(state: UserRelatedState, id: string): BaseUIElement { - const allPreferences = state.osmConnection.preferencesHandler.preferences.data; - const length = Number(allPreferences[id + "-length"]) - let str = ""; - for (let i = 0; i < length; i++) { - str += allPreferences[id + "-" + i] - } - if(str === undefined || str === "undefined"){ + const pref = state.osmConnection.GetLongPreference(id) + const str = pref.data + if (str === undefined || str === "undefined" || str === "") { + pref.setData(null) return undefined } + try { const value: { id: string @@ -149,7 +146,8 @@ export default class MoreScreen extends Combine { value.isOfficial = false return MoreScreen.createLinkButton(state, value, true) } catch (e) { - console.debug("Could not parse unofficial theme information for " + id, "The json is: ", str, e) + console.warn("Removing theme " + id + " as it could not be parsed from the preferences") + pref.setData(null) return undefined } } @@ -163,16 +161,14 @@ export default class MoreScreen extends Combine { for (const key in allPreferences) { if (key.startsWith(prefix) && key.endsWith("-combined-length")) { - const id = key.substring(0, key.length - "-length".length) + const id = key.substring("mapcomplete-".length, key.length - "-combined-length".length) ids.push(id) } } - return ids }); var stableIds = UIEventSource.ListStabilized(currentIds) - return new VariableUiElement( stableIds.map(ids => { const allThemes: BaseUIElement[] = [] @@ -182,12 +178,11 @@ export default class MoreScreen extends Combine { allThemes.push(link.SetClass(buttonClass)) } } - if (allThemes.length <= 0) { return undefined; } return new Combine([ - Translations.t.general.customThemeIntro.Clone(), + Translations.t.general.customThemeIntro, new Combine(allThemes).SetClass(themeListClasses) ]); })); diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 2e8371a0a9..5bc1d17009 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -208,15 +208,20 @@ export default class SimpleAddUI extends Toggle { const allButtons = []; for (const layer of state.filteredLayers.data) { - if (layer.isDisplayed.data === false && !state.featureSwitchFilter.data) { - // The layer is not displayed and we cannot enable the layer control -> we skip - continue; + if (layer.isDisplayed.data === false) { + // The layer is not displayed... + if(!state.featureSwitchFilter.data){ + // ...and we cannot enable the layer control -> we skip, as these presets can never be shown anyway + continue; + } + + if (layer.layerDef.name === undefined) { + // this layer can never be toggled on in any case, so we skip the presets + continue; + } } - if (layer.layerDef.name === undefined) { - // this is a parlty hidden layer - continue; - } + const presets = layer.layerDef.presets; for (const preset of presets) { diff --git a/UI/BigComponents/TranslatorsPanel.ts b/UI/BigComponents/TranslatorsPanel.ts index a036a1d546..37e80eadb8 100644 --- a/UI/BigComponents/TranslatorsPanel.ts +++ b/UI/BigComponents/TranslatorsPanel.ts @@ -14,7 +14,9 @@ import Title from "../Base/Title"; import {UIEventSource} from "../../Logic/UIEventSource"; import {SubtleButton} from "../Base/SubtleButton"; import Svg from "../../Svg"; - +import * as native_languages from "../../assets/language_native.json" +import * as used_languages from "../../assets/generated/used_languages.json" +import BaseUIElement from "../BaseUIElement"; class TranslatorsPanelContent extends Combine { constructor(layout: LayoutConfig, isTranslator: UIEventSource) { @@ -24,36 +26,53 @@ class TranslatorsPanelContent extends Combine { const seed = t.completeness for (const ln of Array.from(completeness.keys())) { - if(ln === "*"){ + if (ln === "*") { continue } if (seed.translations[ln] === undefined) { seed.translations[ln] = seed.translations["en"] } } - + const completenessTr = {} const completenessPercentage = {} seed.SupportedLanguages().forEach(ln => { - completenessTr[ln] = ""+(completeness.get(ln) ?? 0) - completenessPercentage[ln] = ""+Math.round(100 * (completeness.get(ln) ?? 0) / total) + completenessTr[ln] = "" + (completeness.get(ln) ?? 0) + completenessPercentage[ln] = "" + Math.round(100 * (completeness.get(ln) ?? 0) / total) }) - const missingTranslationsFor = (ln: string) => Utils.NoNull(untranslated.get(ln) ?? []) - .filter(ctx => ctx.indexOf(":") >= 0) - .map(ctx => ctx.replace(/note_import_[a-zA-Z0-9_]*/, "note_import")) - .map(context => new Link(context, LinkToWeblate.hrefToWeblate(ln, context), true)) + function missingTranslationsFor(language: string): BaseUIElement[] { + // e.g. "themes:.layers.0.tagRenderings..., or "layers:.description + const missingKeys = Utils.NoNull(untranslated.get(language) ?? []) + .filter(ctx => ctx.indexOf(":") >= 0) + .map(ctx => ctx.replace(/note_import_[a-zA-Z0-9_]*/, "note_import")) + + const hasMissingTheme = missingKeys.some(k => k.startsWith("themes:")) + const missingLayers = Utils.Dedup( missingKeys.filter(k => k.startsWith("layers:")) + .map(k => k.slice("layers:".length).split(".")[0])) + + console.log("Getting untranslated string for",language,"raw:",missingKeys,"hasMissingTheme:",hasMissingTheme,"missingLayers:",missingLayers) + return [ + hasMissingTheme ? new Link("themes:" + layout.id + ".* (zen mode)", LinkToWeblate.hrefToWeblateZen(language, "themes", layout.id), true) : undefined, + ...missingLayers.map(id => new Link("layer:" + id + ".* (zen mode)", LinkToWeblate.hrefToWeblateZen(language, "layers", id), true)), + ...missingKeys.map(context => new Link(context, LinkToWeblate.hrefToWeblate(language, context), true)) + ] + } + // + // // "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}", - const translated = seed.Subs({total, theme: layout.title, + const translated = seed.Subs({ + total, theme: layout.title, percentage: new Translation(completenessPercentage), - translated: new Translation(completenessTr) + translated: new Translation(completenessTr), + language: seed.OnEveryLanguage((_, lng) => native_languages[lng] ?? lng) }) - + super([ new Title( - Translations.t.translations.activateButton, + Translations.t.translations.activateButton, ), new Toggle(t.isTranslator.SetClass("thanks block"), undefined, isTranslator), t.help, @@ -63,15 +82,18 @@ class TranslatorsPanelContent extends Combine { .onClick(() => { Locale.showLinkToWeblate.setData(false) }), - - new VariableUiElement(Locale.language.map(ln => { + new VariableUiElement(Locale.language.map(ln => { const missing = missingTranslationsFor(ln) if (missing.length === 0) { return undefined } + let title = Translations.t.translations.allMissing; + if(untranslated.get(ln) !== undefined){ + title = Translations.t.translations.missing.Subs({count: untranslated.get(ln).length}) + } return new Toggleable( - new Title(Translations.t.translations.missing.Subs({count: missing.length})), + new Title(title), new Combine(missing).SetClass("flex flex-col") ) })) @@ -83,38 +105,37 @@ class TranslatorsPanelContent extends Combine { export default class TranslatorsPanel extends Toggle { - + constructor(state: { layoutToUse: LayoutConfig, isTranslator: UIEventSource }, iconStyle?: string) { const t = Translations.t.translations super( - new Lazy(() => new TranslatorsPanelContent(state.layoutToUse, state.isTranslator) + new Lazy(() => new TranslatorsPanelContent(state.layoutToUse, state.isTranslator) ).SetClass("flex flex-col"), new SubtleButton(Svg.translate_ui().SetStyle(iconStyle), t.activateButton).onClick(() => Locale.showLinkToWeblate.setData(true)), - Locale.showLinkToWeblate + Locale.showLinkToWeblate ) this.SetClass("hidden-on-mobile") - + } - public static MissingTranslationsFor(layout: LayoutConfig) : {completeness: Map, untranslated: Map, total: number} { + public static MissingTranslationsFor(layout: LayoutConfig): { completeness: Map, untranslated: Map, total: number } { let total = 0 const completeness = new Map() const untranslated = new Map() + Utils.WalkObject(layout, (o, path) => { const translation = o; - if(translation.translations["*"] !== undefined){ + if (translation.translations["*"] !== undefined) { return } - if(translation.context === undefined || translation.context.indexOf(":") < 0){ + if (translation.context === undefined || translation.context.indexOf(":") < 0) { // no source given - lets ignore return } - - for (const lang of translation.SupportedLanguages()) { - completeness.set(lang, 1 + (completeness.get(lang) ?? 0)) - } - layout.title.SupportedLanguages().forEach(ln => { + + total ++ + used_languages.languages.forEach(ln => { const trans = translation.translations if (trans["*"] !== undefined) { return; @@ -124,11 +145,11 @@ export default class TranslatorsPanel extends Toggle { untranslated.set(ln, []) } untranslated.get(ln).push(translation.context) + }else{ + completeness.set(ln, 1 + (completeness.get(ln) ?? 0)) } }) - if(translation.translations["*"] === undefined){ - total++ - } + }, o => { if (o === undefined || o === null) { return false; diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index a20019d997..0348bccb9e 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -248,7 +248,7 @@ export default class TagRenderingQuestion extends Combine { const inputEl = new InputElementMap( checkBoxes, (t0, t1) => { - return t0?.isEquivalent(t1) ?? false + return t0?.shadows(t1) ?? false }, (indices) => { if (indices.length === 0) { @@ -370,7 +370,7 @@ export default class TagRenderingQuestion extends Combine { return new FixedInputElement( TagRenderingQuestion.GenerateMappingContent(mapping, tagsSource, state), tagging, - (t0, t1) => t1.isEquivalent(t0)); + (t0, t1) => t1.shadows(t0)); } private static GenerateMappingContent(mapping: { @@ -450,7 +450,7 @@ export default class TagRenderingQuestion extends Combine { }) let inputTagsFilter: InputElement = new InputElementMap( - input, (a, b) => a === b || (a?.isEquivalent(b) ?? false), + input, (a, b) => a === b || (a?.shadows(b) ?? false), pickString, toString ); diff --git a/UI/Reviews/ReviewElement.ts b/UI/Reviews/ReviewElement.ts index 526160b110..79fe40132c 100644 --- a/UI/Reviews/ReviewElement.ts +++ b/UI/Reviews/ReviewElement.ts @@ -25,7 +25,7 @@ export default class ReviewElement extends VariableUiElement { SingleReview.GenStars(avg), new Link( revs.length === 1 ? Translations.t.reviews.title_singular.Clone() : - Translations.t.reviews.title.Clone() + Translations.t.reviews.title .Subs({count: "" + revs.length}), `https://mangrove.reviews/search?sub=${encodeURIComponent(subject)}`, true diff --git a/UI/Wikipedia/WikidataPreviewBox.ts b/UI/Wikipedia/WikidataPreviewBox.ts index a5f125e5cc..1e6db1ca33 100644 --- a/UI/Wikipedia/WikidataPreviewBox.ts +++ b/UI/Wikipedia/WikidataPreviewBox.ts @@ -1,7 +1,7 @@ import {VariableUiElement} from "../Base/VariableUIElement"; import {UIEventSource} from "../../Logic/UIEventSource"; import Wikidata, {WikidataResponse} from "../../Logic/Web/Wikidata"; -import {Translation} from "../i18n/Translation"; +import {Translation, TypedTranslation} from "../i18n/Translation"; import {FixedUiElement} from "../Base/FixedUiElement"; import Loading from "../Base/Loading"; import Translations from "../i18n/Translations"; @@ -22,7 +22,7 @@ export default class WikidataPreviewBox extends VariableUiElement { private static extraProperties: { requires?: { p: number, q?: number }[], property: string, - display: Translation | Map BaseUIElement) /*If translation: Subs({value: * }) */> + display: TypedTranslation<{value}> | Map BaseUIElement) /*If translation: Subs({value: * }) */> }[] = [ { requires: WikidataPreviewBox.isHuman, diff --git a/UI/i18n/Translation.ts b/UI/i18n/Translation.ts index e9c8157081..677878acc3 100644 --- a/UI/i18n/Translation.ts +++ b/UI/i18n/Translation.ts @@ -1,9 +1,6 @@ import Locale from "./Locale"; import {Utils} from "../../Utils"; import BaseUIElement from "../BaseUIElement"; -import Link from "../Base/Link"; -import Svg from "../../Svg"; -import {VariableUiElement} from "../Base/VariableUIElement"; import LinkToWeblate from "../Base/LinkToWeblate"; export class Translation extends BaseUIElement { @@ -164,25 +161,7 @@ export class Translation extends BaseUIElement { public AllValues(): string[] { return this.SupportedLanguages().map(lng => this.translations[lng]); } - - /** - * Substitutes text in a translation. - * If a translation is passed, it'll be fused - * - * // Should replace simple keys - * new Translation({"en": "Some text {key}"}).Subs({key: "xyz"}).textFor("en") // => "Some text xyz" - * - * // Should fuse translations - * const subpart = new Translation({"en": "subpart","nl":"onderdeel"}) - * const tr = new Translation({"en": "Full sentence with {part}", nl: "Volledige zin met {part}"}) - * const subbed = tr.Subs({part: subpart}) - * subbed.textFor("en") // => "Full sentence with subpart" - * subbed.textFor("nl") // => "Volledige zin met onderdeel" - */ - public Subs(text: any, context?: string): Translation { - return this.OnEveryLanguage((template, lang) => Utils.SubstituteKeys(template, text, lang), context) - } - + public OnEveryLanguage(f: (s: string, language: string) => string, context?: string): Translation { const newTranslations = {}; for (const lang in this.translations) { @@ -278,5 +257,28 @@ export class Translation extends BaseUIElement { return this.txt } +} +export class TypedTranslation extends Translation { + constructor(translations: object, context?: string) { + super(translations, context); + } + + /** + * Substitutes text in a translation. + * If a translation is passed, it'll be fused + * + * // Should replace simple keys + * new TypedTranslation({"en": "Some text {key}"}).Subs({key: "xyz"}).textFor("en") // => "Some text xyz" + * + * // Should fuse translations + * const subpart = new Translation({"en": "subpart","nl":"onderdeel"}) + * const tr = new TypedTranslation({"en": "Full sentence with {part}", nl: "Volledige zin met {part}"}) + * const subbed = tr.Subs({part: subpart}) + * subbed.textFor("en") // => "Full sentence with subpart" + * subbed.textFor("nl") // => "Volledige zin met onderdeel" + */ + Subs(text: T, context?: string): Translation { + return this.OnEveryLanguage((template, lang) => Utils.SubstituteKeys(template, text, lang), context) + } } \ No newline at end of file diff --git a/UI/i18n/Translations.ts b/UI/i18n/Translations.ts index 482cc7eeb4..8186226fbd 100644 --- a/UI/i18n/Translations.ts +++ b/UI/i18n/Translations.ts @@ -1,5 +1,5 @@ import {FixedUiElement} from "../Base/FixedUiElement"; -import {Translation} from "./Translation"; +import {Translation, TypedTranslation} from "./Translation"; import BaseUIElement from "../BaseUIElement"; import * as known_languages from "../../assets/generated/used_languages.json" import CompiledTranslations from "../../assets/generated/CompiledTranslations"; @@ -22,7 +22,7 @@ export default class Translations { return s; } - static T(t: string | any, context = undefined): Translation { + static T(t: string | any, context = undefined): TypedTranslation { if (t === undefined || t === null) { return undefined; } @@ -30,17 +30,17 @@ export default class Translations { t = "" + t } if (typeof t === "string") { - return new Translation({"*": t}, context); + return new TypedTranslation({"*": t}, context); } if (t.render !== undefined) { const msg = "Creating a translation, but this object contains a 'render'-field. Use the translation directly" console.error(msg, t); throw msg } - if (t instanceof Translation) { + if (t instanceof TypedTranslation) { return t; } - return new Translation(t, context); + return new TypedTranslation(t, context); } /** diff --git a/assets/layers/charging_station/csvToJson.ts b/assets/layers/charging_station/csvToJson.ts index 5bd1e956a9..04aedcfe31 100644 --- a/assets/layers/charging_station/csvToJson.ts +++ b/assets/layers/charging_station/csvToJson.ts @@ -1,6 +1,5 @@ import {readFileSync, writeFileSync} from "fs"; import {Utils} from "../../../Utils"; -import {TagRenderingConfigJson} from "../../../Models/ThemeConfig/Json/TagRenderingConfigJson"; import ScriptUtils from "../../../scripts/ScriptUtils"; import {LayerConfigJson} from "../../../Models/ThemeConfig/Json/LayerConfigJson"; import FilterConfigJson from "../../../Models/ThemeConfig/Json/FilterConfigJson"; @@ -8,7 +7,7 @@ import {QuestionableTagRenderingConfigJson} from "../../../Models/ThemeConfig/Js function colonSplit(value: string): string[] { - return value.split(";").map(v => v.replace(/"/g, '').trim().toLowerCase()).filter(s => s !== ""); + return value.split(";").map(v => v.replace(/"/g, '').trim()).filter(s => s !== ""); } function loadCsv(file): { diff --git a/assets/themes/climbing/climbing.json b/assets/themes/climbing/climbing.json index 606bc681fa..3723d6b518 100644 --- a/assets/themes/climbing/climbing.json +++ b/assets/themes/climbing/climbing.json @@ -51,7 +51,8 @@ ], "overrideAll": { "allowMove": { - "improveAccuracy": true + "enableRelocation": false, + "enableImproveAccuracy": true }, "+titleIcons": [ { diff --git a/langs/ca.json b/langs/ca.json index fafb1f2e91..1e596ce6ec 100644 --- a/langs/ca.json +++ b/langs/ca.json @@ -276,17 +276,17 @@ "willBePublished": "La teva foto serà publicada: " }, "importHelper": { - "allAttributesSame": "Totes les funcions a importar tenen aquesta etiqueta", - "description": "L'ajudant d'importació converteix un conjunt de dades extern en notes. El conjunt de dades extern ha de coincidir amb una de les capes MapComplete existents. Per a cada article que introdueixes a l'importador, es crearà una nota única. Aquestes notes es mostraran juntament amb les característiques rellevants en aquests mapes per afegir-les fàcilment.", - "importFormat": "Un text d'una nota ha de tenir el format següent per poder ser recollit:
[Una petita introducció]
https://mapcomplete.osm.be /[themename].html?[paràmetres com ara lat i lon]#import
[totes les etiquetes de la funció]
", - "inspectDataTitle": "Inspecciona les dades de {count} funcions per importar", - "inspectDidAutoDected": "La capa es va seleccionar automàticament", - "inspectLooksCorrect": "Aquests valors semblen correctes", - "lockNotice": "Aquesta pàgina està bloquejada. Necessites {importHelperUnlock} conjunts de canvis per poder accedir aquí.", - "locked": "Necessites almenys {importHelperUnlock} per utilitzar l'ajudant d'importació", - "loggedInWith": "Actualment has entrat com a {name} i has fet {csCount} conjunts de canvis", - "loginIsCorrect": "{name} és el compte correcte per crear les notes d'importació.", - "loginRequired": "Has d'entrar per continuar", + "introduction": { + "description": "L'ajudant d'importació converteix un conjunt de dades extern en notes. El conjunt de dades extern ha de coincidir amb una de les capes MapComplete existents. Per a cada article que introdueixes a l'importador, es crearà una nota única. Aquestes notes es mostraran juntament amb les característiques rellevants en aquests mapes per afegir-les fàcilment.", + "importFormat": "Un text d'una nota ha de tenir el format següent per poder ser recollit:
[Una petita introducció]
https://mapcomplete.osm.be /[themename].html?[paràmetres com ara lat i lon]#import
[totes les etiquetes de la funció]
" + }, + "login": { + "lockNotice": "Aquesta pàgina està bloquejada. Necessites {importHelperUnlock} conjunts de canvis per poder accedir aquí.", + "loggedInWith": "Actualment has entrat com a {name} i has fet {csCount} conjunts de canvis", + "loginIsCorrect": "{name} és el compte correcte per crear les notes d'importació.", + "loginRequired": "Has d'entrar per continuar", + "userAccountTitle": "Seleccionar compte d'usuari" + }, "mapPreview": { "autodetected": "La capa es va deduir automàticament en funció de les propietats", "confirm": "Les característiques es troben a la ubicació correcta del mapa", @@ -294,6 +294,12 @@ "selectLayer": "Amb quina capa coincideix aquesta importació?", "title": "Vista prèvia del mapa" }, + "previewAttributes": { + "allAttributesSame": "Totes les funcions a importar tenen aquesta etiqueta", + "inspectDataTitle": "Inspecciona les dades de {count} funcions per importar", + "inspectLooksCorrect": "Aquests valors semblen correctes", + "someHaveSame": "{count} característiques per importar tenen aquesta etiqueta, això és un {percentage}% del total" + }, "selectFile": { "description": "Seleccionar un fitxer .csv o .geojson per començar", "errDuplicate": "Algunes columnes tenen el mateix nom", @@ -308,11 +314,7 @@ "noFilesLoaded": "No s'ha carregat cap arxiu", "title": "Seleccionar arxiu" }, - "selectLayer": "Seleccionar capa...", - "someHaveSame": "{count} característiques per importar tenen aquesta etiqueta, això és un {percentage}% del total", - "title": "Ajuda de l'importador", - "userAccountTitle": "Seleccionar compte d'usuari", - "validateDataTitle": "Validar dades" + "title": "Ajuda de l'importador" }, "importInspector": { "title": "Inspeccionar i controlar notes d'importació" diff --git a/langs/de.json b/langs/de.json index e020e6e1fe..5a2e359109 100644 --- a/langs/de.json +++ b/langs/de.json @@ -408,11 +408,10 @@ "title": "Thema auswählen", "unmatchedTitle": "Die folgenden Elemente stimmen mit keiner Voreinstellung überein" }, - "someHaveSame": "{count} der zu importierenden Objekte haben dieses Tag, das sind {percentage}% der Gesamtzahl", "testMode": "Testmodus - Notizen werden nicht importiert", - "title": "Import-Helfer", "userAccountTitle": "Wähle einen Benutzeraccount", - "validateDataTitle": "Bestätige Daten" + "validateDataTitle": "Bestätige Daten", + "title": "Import-Helfer" }, "importInspector": { "title": "Importhinweise überprüfen und verwalten" diff --git a/langs/en.json b/langs/en.json index d96568f832..c7ab07ad6b 100644 --- a/langs/en.json +++ b/langs/en.json @@ -619,6 +619,7 @@ }, "translations": { "activateButton": "Help to translate MapComplete", + "allMissing": "No translations yet", "completeness": "Translations for {theme} in {language} are at {percentage}%: {translated} strings out of {total} are translated", "deactivate": "Disable translation buttons", "help": "Click the 'translate'-icon next to a string to enter or update a piece of text. You need a Weblate-account for this. Create one with your OSM-username to automatically unlock translation mode.", diff --git a/langs/es.json b/langs/es.json index 07f8fba182..6419b54c30 100644 --- a/langs/es.json +++ b/langs/es.json @@ -275,8 +275,7 @@ "selectFile": { "title": "Seleccionar archivo" }, - "title": "Ayudante de importación", - "validateDataTitle": "Validar datos" + "title": "Ayudante de importación" }, "importLayer": { "layerName": "Posible {title}", diff --git a/langs/gl.json b/langs/gl.json index ce0e6ed5e3..42dc16620a 100644 --- a/langs/gl.json +++ b/langs/gl.json @@ -42,11 +42,6 @@ "getStartedLogin": "Entra no OpenStreetMap para comezar", "getStartedNewAccount": " ou crea unha nova conta", "goToInbox": "Abrir mensaxes", - "index": { - "intro": "O MapComplete é un visor e editor do OpenStreetMap, que te amosa información sobre un tema específico.", - "pickTheme": "Escolle un tema para comezar.", - "title": "Benvido ao MapComplete" - }, "layerSelection": { "title": "Seleccionar capas", "zoomInToSeeThisLayer": "Achégate para ver esta capa" diff --git a/langs/hu.json b/langs/hu.json index a5750aa364..915c5e98d9 100644 --- a/langs/hu.json +++ b/langs/hu.json @@ -267,8 +267,12 @@ "willBePublished": "A képed így lesz közzétéve: " }, "importHelper": { - "allAttributesSame": "Ez a címke minden importálandó objektumon szerepel", - "description": "Az importálási segédprogram egy külső adatkészletet konvertál OSM-jegyzetekké. A külső adatkészletnek meg kell felelnie a MapComplete egyik meglévő rétegének. Az importálóba helyezett minden egyes elemhez egyetlen jegyzet fog létrejönni. Ezek a jegyzetek a megfelelő objektumokkal együtt fognak megjelenni ezeken a térképekben, hogy könnyen fel lehessen rajzolni őket a térképre." + "introduction": { + "description": "Az importálási segédprogram egy külső adatkészletet konvertál OSM-jegyzetekké. A külső adatkészletnek meg kell felelnie a MapComplete egyik meglévő rétegének. Az importálóba helyezett minden egyes elemhez egyetlen jegyzet fog létrejönni. Ezek a jegyzetek a megfelelő objektumokkal együtt fognak megjelenni ezeken a térképekben, hogy könnyen fel lehessen rajzolni őket a térképre." + }, + "previewAttributes": { + "allAttributesSame": "Ez a címke minden importálandó objektumon szerepel" + } }, "index": { "#": "Ezek a szövegek akkor jelennek meg a témagombok felett, ha nincs betöltve téma", diff --git a/langs/nl.json b/langs/nl.json index 064e7d0a4a..92573b6ba1 100644 --- a/langs/nl.json +++ b/langs/nl.json @@ -97,12 +97,6 @@ "backToMapcomplete": "Terug naar het themaoverzicht", "backgroundMap": "Achtergrondkaart", "cancel": "Annuleren", - "centerMessage": { - "loadingData": "Data wordt geladen…", - "ready": "Klaar!", - "retrying": "Data inladen mislukt. Opnieuw proberen over {count} seconden…", - "zoomIn": "Zoom in om de data te zien en te bewerken" - }, "confirm": "Bevestigen", "customThemeIntro": "

Onofficiële thema's

De onderstaande thema's heb je eerder bezocht en zijn gemaakt door andere OpenStreetMappers.", "download": { @@ -134,12 +128,6 @@ "histogram": { "error_loading": "Kan het histogram niet laden" }, - "index": { - "#": "Deze teksten worden getoond boven de themaknoppen als er geen thema is geladen", - "intro": "MapComplete is een OpenStreetMap applicatie waar informatie over een specifiek thema bekeken en aangepast kan worden.", - "pickTheme": "Kies hieronder een thema om te beginnen.", - "title": "Welkom bij MapComplete" - }, "layerSelection": { "title": "Selecteer lagen", "zoomInToSeeThisLayer": "Vergroot de kaart om deze laag te zien" @@ -202,22 +190,6 @@ "readYourMessages": "Gelieve eerst je berichten op OpenStreetMap te lezen alvorens nieuwe punten toe te voegen.", "removeLocationHistory": "Verwijder de geschiedenis aan locaties", "returnToTheMap": "Ga terug naar de kaart", - "reviews": { - "affiliated_reviewer_warning": "(Review door betrokkene)", - "attribution": "De beoordelingen worden voorzien door Mangrove Reviews en zijn beschikbaar onder deCC-BY 4.0-licentie.", - "i_am_affiliated": "Ik ben persoonlijk betrokken
Vink aan indien je de oprichter, maker, werknemer, ... of dergelijke bent", - "name_required": "De naam van dit object moet gekend zijn om een review te kunnen maken", - "no_rating": "Geen score bekend", - "no_reviews_yet": "Er zijn nog geen beoordelingen. Wees de eerste om een beoordeling te schrijven en help open data en het bedrijf!", - "plz_login": "Meld je aan om een beoordeling te geven", - "posting_as": "Ingelogd als", - "saved": "Bedankt om je beoordeling te delen!", - "saving_review": "Opslaan…", - "title": "{count} beoordelingen", - "title_singular": "Eén beoordeling", - "tos": "Als je je review publiceert, ga je akkoord met de de gebruiksvoorwaarden en privacy policy van Mangrove.reviews", - "write_a_comment": "Schrijf een beoordeling…" - }, "save": "Opslaan", "screenToSmall": "Open {theme} in een nieuw venster", "search": { @@ -304,17 +276,17 @@ "willBePublished": "Jouw foto wordt gepubliceerd " }, "importHelper": { - "allAttributesSame": "Alle kaart-objecten om te importeren hebben deze tag", - "description": "De importeer-helper converteert een externe dataset in OSM-kaartnotas. De externe data moet overeenkomen met een bestaande MapComplete-laag. Voor elk item wordt er een kaartnota gemaakt. Deze notas worden dan samen met de relevante POI getoond en kunnen dan (via MapComplete) snel en eenvoudig toegevoegd worden.", - "importFormat": "Een kaartnota moet het volgende formaat hebben om gedetecteerd te worden binnen een laag:
[Een introductietekst]
https://mapcomplete.osm.be/[themename].html?[parameters waaronder lon en lat]#import
[alle tags van het te importeren object]
", - "inspectDataTitle": "Bekijk de data van {count} te importeren objecten", - "inspectDidAutoDected": "Deze laag werd automatisch gekozen", - "inspectLooksCorrect": "Deze waardes zien er correct uit", - "lockNotice": "Deze pagina is afgeschermd. Je hebt minstens {importHelperUnlock} changesets nodig voor je deze pagina mag gebruiken.", - "locked": "Je hebt minstens {importHelperUnlock} changesets nodig om de import helper te gebruiken", - "loggedInWith": "Je bent momenteel aangemeld als {name} and maakte {csCount} eerdere wijzigingen", - "loginIsCorrect": "{name} is de correcte account om de import-nota's mee te maken.", - "loginRequired": "Je moet ingelogd zijn om verder te gaan", + "introduction": { + "description": "De importeer-helper converteert een externe dataset in OSM-kaartnotas. De externe data moet overeenkomen met een bestaande MapComplete-laag. Voor elk item wordt er een kaartnota gemaakt. Deze notas worden dan samen met de relevante POI getoond en kunnen dan (via MapComplete) snel en eenvoudig toegevoegd worden.", + "importFormat": "Een kaartnota moet het volgende formaat hebben om gedetecteerd te worden binnen een laag:
[Een introductietekst]
https://mapcomplete.osm.be/[themename].html?[parameters waaronder lon en lat]#import
[alle tags van het te importeren object]
" + }, + "login": { + "lockNotice": "Deze pagina is afgeschermd. Je hebt minstens {importHelperUnlock} changesets nodig voor je deze pagina mag gebruiken.", + "loggedInWith": "Je bent momenteel aangemeld als {name} and maakte {csCount} eerdere wijzigingen", + "loginIsCorrect": "{name} is de correcte account om de import-nota's mee te maken.", + "loginRequired": "Je moet ingelogd zijn om verder te gaan", + "userAccountTitle": "Selecteer een account" + }, "mapPreview": { "autodetected": "Deze laag was automatisch gekozen gebaseerd op de aanwezige eigenschappen", "confirm": "De objecten bevinden zich op de juiste locatie", @@ -322,6 +294,12 @@ "selectLayer": "Met welke laag komt deze te importeren dataset overeen?", "title": "Voorbeeldkaart" }, + "previewAttributes": { + "allAttributesSame": "Alle kaart-objecten om te importeren hebben deze tag", + "inspectDataTitle": "Bekijk de data van {count} te importeren objecten", + "inspectLooksCorrect": "Deze waardes zien er correct uit", + "someHaveSame": "{count} te importeren objecten hebben dit attribuut, dit is {percentage}% van het totaal" + }, "selectFile": { "description": "Selecteer een .csv of .geojson-bestand", "errDuplicate": "Sommige kolommen hebben dezelfde naam", @@ -336,11 +314,7 @@ "noFilesLoaded": "Geen bestand ingeladen op dit moment", "title": "Selecteer bestand" }, - "selectLayer": "Selecteer een laag...", - "someHaveSame": "{count} te importeren objecten hebben dit attribuut, dit is {percentage}% van het totaal", - "title": "Importeer-helper", - "userAccountTitle": "Selecteer een account", - "validateDataTitle": "Valideer data" + "title": "Importeer-helper" }, "importInspector": { "title": "Inspecteer en beheer importeer-notas" diff --git a/langs/pl.json b/langs/pl.json index 2f7a400ad5..507e993164 100644 --- a/langs/pl.json +++ b/langs/pl.json @@ -35,121 +35,9 @@ "cancel": "Anuluj", "customThemeIntro": "

Motywy własne

Są to wcześniej odwiedzone motywy stworzone przez użytkowników.", "fewChangesBefore": "Proszę odpowiedzieć na kilka pytań dotyczących istniejących punktów przed dodaniem nowego punktu.", - "general": { - "about": "Łatwo edytuj i dodaj OpenStreetMap dla określonego motywu", - "aboutMapcomplete": "

O MapComplete

Dzięki MapComplete możesz wzbogacić OpenStreetMap o informacje na pojedynczy temat. Odpowiedz na kilka pytań, a w ciągu kilku minut Twój wkład będzie dostępny na całym świecie! Opiekun tematu definiuje elementy, pytania i języki dla tematu.

Dowiedz się więcej

MapComplete zawsze oferuje następny krok, by dowiedzieć się więcej o OpenStreetMap.

  • Po osadzeniu na stronie internetowej, element iframe łączy się z pełnoekranowym MapComplete
  • Wersja pełnoekranowa oferuje informacje o OpenStreetMap
  • Przeglądanie działa bez logowania, ale edycja wymaga loginu OSM.
  • Jeżeli nie jesteś zalogowany, zostaniesz poproszony o zalogowanie się
  • Po udzieleniu odpowiedzi na jedno pytanie, możesz dodać nowe punkty do mapy
  • Po chwili wyświetlane są rzeczywiste tagi OSM, które później linkują do wiki


Zauważyłeś problem? Czy masz prośbę o dodanie jakiejś funkcji? Chcesz pomóc w tłumaczeniu? Udaj się do kodu źródłowego lub issue trackera.

Chcesz zobaczyć swoje postępy? Śledź liczbę edycji na OsmCha.

", - "add": { - "addNew": "Dodaj nową {category} tutaj", - "confirmButton": "Dodaj tutaj {category}.
Twój dodatek jest widoczny dla wszystkich
", - "confirmIntro": "

Czy dodać tutaj {title}?

Punkt, który tutaj utworzysz, będzie widoczny dla wszystkich. Proszę, dodawaj rzeczy do mapy tylko wtedy, gdy naprawdę istnieją. Wiele aplikacji korzysta z tych danych.", - "intro": "Kliknąłeś gdzieś, gdzie nie są jeszcze znane żadne dane.
", - "layerNotEnabled": "Warstwa {layer} nie jest włączona. Włącz tę warstwę, aby dodać punkt", - "openLayerControl": "Otwórz okno sterowania warstwą", - "pleaseLogin": "Zaloguj się, aby dodać nowy punkt", - "stillLoading": "Dane wciąż się ładują. Poczekaj chwilę, zanim dodasz nowy punkt.", - "title": "Czy dodać nowy punkt?", - "zoomInFurther": "Powiększ jeszcze bardziej, aby dodać punkt." - }, - "backgroundMap": "Tło mapy", - "cancel": "Anuluj", - "customThemeIntro": "

Motywy własne

Są to wcześniej odwiedzone motywy stworzone przez użytkowników.", - "fewChangesBefore": "Proszę odpowiedzieć na kilka pytań dotyczących istniejących punktów przed dodaniem nowego punktu.", - "getStartedLogin": "Zaloguj się za pomocą OpenStreetMap, aby rozpocząć", - "getStartedNewAccount": " lub utwórz nowe konto", - "goToInbox": "Otwórz skrzynkę odbiorczą", - "layerSelection": { - "title": "Wybierz warstwy", - "zoomInToSeeThisLayer": "Powiększ, aby zobaczyć tę warstwę" - }, - "loginToStart": "Zaloguj się, aby odpowiedzieć na to pytanie", - "loginWithOpenStreetMap": "Zaloguj z OpenStreetMap", - "nameInlineQuestion": "Nazwa tej {category} to $$$", - "noNameCategory": "{category} bez nazwy", - "noTagsSelected": "Nie wybrano tagów", - "number": "numer", - "oneSkippedQuestion": "Jedno pytanie zostało pominięte", - "opening_hours": { - "closed_permanently": "Zamknięte na nieokreślony czas", - "closed_until": "Zamknięte do {date}", - "error_loading": "Błąd: nie można zwizualizować tych godzin otwarcia.", - "not_all_rules_parsed": "Godziny otwarcia tego sklepu są skomplikowane. Następujące reguły są ignorowane w elemencie wejściowym:", - "openTill": "do", - "open_24_7": "Otwarte przez całą dobę", - "open_during_ph": "W czasie świąt państwowych udogodnienie to jest", - "opensAt": "z", - "ph_closed": "zamknięte", - "ph_not_known": " ", - "ph_open": "otwarte" - }, - "osmLinkTooltip": "Zobacz ten obiekt na OpenStreetMap, aby uzyskać historię i więcej opcji edycji", - "pickLanguage": "Wybierz język: ", - "questions": { - "emailIs": "Adres e-mail {category} to {email}", - "emailOf": "Jaki jest adres e-mail {category}?", - "phoneNumberIs": "Numer telefonu {category} to {phone}", - "phoneNumberOf": "Jaki jest numer telefonu do {category}?", - "websiteIs": "Strona internetowa: {website}", - "websiteOf": "Jaka jest strona internetowa {category}?" - }, - "readYourMessages": "Przeczytaj wszystkie wiadomości OpenStreetMap przed dodaniem nowego punktu.", - "returnToTheMap": "Wróć do mapy", - "save": "Zapisz", - "search": { - "error": "Coś poszło nie tak…", - "nothing": "Nic nie znaleziono…", - "search": "Wyszukaj lokalizację", - "searching": "Szukanie…" - }, - "sharescreen": { - "addToHomeScreen": "

Dodaj do ekranu głównego

Możesz łatwo dodać tę stronę do ekranu głównego smartfona, aby poczuć się jak w domu. Kliknij przycisk \"Dodaj do ekranu głównego\" na pasku adresu URL, aby to zrobić.", - "copiedToClipboard": "Link został skopiowany do schowka", - "editThemeDescription": "Dodaj lub zmień pytania do tego motywu mapy", - "editThisTheme": "Edytuj ten motyw", - "embedIntro": "

Umieść na swojej stronie internetowej

Proszę, umieść tę mapę na swojej stronie internetowej.
Zachęcamy cię do tego - nie musisz nawet pytać o zgodę.
Jest ona darmowa i zawsze będzie. Im więcej osób jej używa, tym bardziej staje się wartościowa.", - "fsAddNew": "Włącz przycisk \"Dodaj nowe POI\"", - "fsGeolocation": "Włącz przycisk „Zlokalizuj mnie” (tylko na urządzeniach mobilnych)", - "fsIncludeCurrentBackgroundMap": "Dołącz bieżący wybór tła {name}", - "fsIncludeCurrentLayers": "Uwzględnij wybór bieżącej warstwy", - "fsIncludeCurrentLocation": "Uwzględnij bieżącą lokalizację", - "fsLayerControlToggle": "Zacznij od rozwiniętej kontroli warstw", - "fsLayers": "Włącz kontrolę warstw", - "fsSearch": "Włącz pasek wyszukiwania", - "fsUserbadge": "Włącz przycisk logowania", - "fsWelcomeMessage": "Pokaż wyskakujące okienko wiadomości powitalnej i powiązane zakładki", - "intro": "

Udostępnij tę mapę

Udostępnij tę mapę, kopiując poniższy link i wysyłając ją do przyjaciół i rodziny:", - "thanksForSharing": "Dzięki za udostępnienie!" - }, - "skip": "Pomiń to pytanie", - "skippedQuestions": "Niektóre pytania są pominięte", - "weekdays": { - "abbreviations": { - "friday": "Pt", - "monday": "Pn", - "saturday": "Sob", - "sunday": "Niedz", - "thursday": "Czw", - "tuesday": "Wt", - "wednesday": "Śr" - }, - "friday": "Piątek", - "monday": "Poniedziałek", - "saturday": "Sobota", - "sunday": "Niedziela", - "thursday": "Czwartek", - "tuesday": "Wtorek", - "wednesday": "Środa" - }, - "welcomeBack": "Jesteś zalogowany, witaj z powrotem!" - }, "getStartedLogin": "Zaloguj się za pomocą OpenStreetMap, aby rozpocząć", "getStartedNewAccount": " lub utwórz nowe konto", "goToInbox": "Otwórz skrzynkę odbiorczą", - "index": { - "#": "Te teksty są wyświetlane nad przyciskami motywu, gdy nie jest załadowany żaden motyw", - "intro": "MapComplete to przeglądarka i edytor OpenStreetMap, który pokazuje informacje podzielone według tematu.", - "pickTheme": "Wybierz temat poniżej, aby rozpocząć.", - "title": "Witaj w MapComplete" - }, "layerSelection": { "title": "Wybierz warstwy", "zoomInToSeeThisLayer": "Powiększ, aby zobaczyć tę warstwę" diff --git a/langs/zh_Hant.json b/langs/zh_Hant.json index c7be5a7539..e0916e9bf4 100644 --- a/langs/zh_Hant.json +++ b/langs/zh_Hant.json @@ -90,130 +90,6 @@ "title": "下載可視的資料" }, "fewChangesBefore": "請先回答有關既有節點的問題再來新增新節點。", - "general": { - "about": "相當容易編輯,而且能為開放街圖新增特定主題", - "aboutMapcomplete": "

關於 MapComplete

使用 MapComplete 你可以藉由單一主題豐富開放街圖的圖資。回答幾個問題,然後幾分鐘之內你的貢獻立刻就傳遍全球!主題維護者定議主題的元素、問題與語言。

發現更多

MapComplete 總是提供學習更多開放街圖下一步的知識

  • 當你內嵌網站,網頁內嵌會連結到全螢幕的 MapComplete
  • 全螢幕的版本提供關於開放街圖的資訊
  • 不登入檢視成果,但是要編輯則需登入 OSM。
  • 如果你沒有登入,你會被要求先登入
  • 當你回答單一問題時,你可以在地圖新增新的節點
  • 過了一陣子,實際的 OSM-標籤會顯示,之後會連結到 wiki


你有注意到問題嗎?你想請求功能嗎?想要幫忙翻譯嗎?來到原始碼或是問題追蹤器。

想要看到你的進度嗎?到OsmCha追蹤編輯數。

", - "add": { - "addNew": "在這裡新增新的 {category}", - "confirmButton": "在此新增 {category}。
大家都可以看到您新增的內容
", - "confirmIntro": "

在這裡新增 {title} ?

你在這裡新增的節點所有人都看得到。請只有在確定有物件存在的情形下才新增上去,許多應用程式都使用這份資料。", - "intro": "您點擊處目前未有已知的資料。
", - "layerNotEnabled": "圖層 {layer} 目前無法使用,請先啟用這圖層再加新的節點", - "openLayerControl": "開啟圖層控制框", - "pleaseLogin": "請先登入來新增節點", - "stillLoading": "目前仍在載入資料,請稍後再來新增節點。", - "title": "新增新的節點?", - "zoomInFurther": "放大來新增新的節點。" - }, - "attribution": { - "attributionContent": "

所有資料由開放街圖提供,在開放資料庫授權條款之下自由再利用。

", - "attributionTitle": "署名通知", - "codeContributionsBy": "MapComplete 是由 {contributors} 和其他 {hiddenCount} 位貢獻者構建而成", - "iconAttribution": { - "title": "使用的圖示" - }, - "mapContributionsBy": "目前檢視的資料由 {contributors} 貢獻編輯", - "mapContributionsByAndHidden": "目前顯到的資料是由 {contributors} 和其他 {hiddenCount} 位貢獻者編輯貢獻", - "themeBy": "由 {author} 維護主題" - }, - "backgroundMap": "背景地圖", - "cancel": "取消", - "customThemeIntro": "

客製化主題

觀看這些先前使用者創造的主題。", - "fewChangesBefore": "請先回答有關既有節點的問題再來新增新節點。", - "getStartedLogin": "登入開放街圖帳號來開始", - "getStartedNewAccount": " 或是 註冊新帳號", - "goToInbox": "開啟訊息框", - "layerSelection": { - "title": "選擇圖層", - "zoomInToSeeThisLayer": "放大來看這個圖層" - }, - "loginToStart": "登入之後來回答這問題", - "loginWithOpenStreetMap": "用開放街圖帳號登入", - "morescreen": { - "createYourOwnTheme": "從零開始建立你的 MapComplete 主題", - "intro": "

看更多主題地圖?

您喜歡蒐集地理資料嗎?
還有更多主題。", - "requestATheme": "如果你有客製化要求,請到問題追踪器那邊提出要求", - "streetcomplete": "行動裝置另有類似的應用程式 StreetComplete。" - }, - "nameInlineQuestion": "這個 {category} 的名稱是 $$$", - "noNameCategory": "{category} 沒有名稱", - "noTagsSelected": "沒有選取標籤", - "number": "號碼", - "oneSkippedQuestion": "跳過一個問題", - "openStreetMapIntro": "

開放的地圖

如果有一份地圖,任何人都能自由使用與編輯,單一的地圖能夠儲存所有地理相關資訊?這樣不就很酷嗎?接著,所有的網站使用不同的、範圍小的,不相容的地圖 (通常也都過時了),也就不再需要了。

開放街圖就是這樣的地圖,人人都能免費這些圖資 (只要署名與公開變動這資料)。只要遵循這些,任何人都能自由新增新資料與修正錯誤,這些網站也都使用開放街圖,資料也都來自開放街圖,你的答案與修正也會加到開放街圖上面。

許多人與應用程式已經採用開放街圖了:Organic MapsOsmAnd,還有 Facebook、Instagram,蘋果地圖、Bing 地圖(部分)採用開放街圖。如果你在開放街圖上變動資料,也會同時影響這些應用 - 在他們下次更新資料之後!

", - "opening_hours": { - "closed_permanently": "不清楚關閉多久了", - "closed_until": "{date} 起關閉", - "error_loading": "錯誤:無法視覺化開放時間。", - "not_all_rules_parsed": "這間店的開放時間相當複雜,在輸入元素時忽略接下來的規則:", - "openTill": "結束時間", - "open_24_7": "24小時營業", - "open_during_ph": "國定假日的時候,這個場所是", - "opensAt": "開始時間", - "ph_closed": "無營業", - "ph_not_known": " ", - "ph_open": "有營業" - }, - "osmLinkTooltip": "在開放街圖歷史和更多編輯選項下面來檢視這物件", - "pickLanguage": "選擇語言: ", - "questions": { - "emailIs": "{category} 的電子郵件地址是{email}", - "emailOf": "{category} 的電子郵件地址是?", - "phoneNumberIs": "此 {category} 的電話號碼為 {phone}", - "phoneNumberOf": "{category} 的電話號碼是?", - "websiteIs": "網站:{website}", - "websiteOf": "{category} 的網站網址是?" - }, - "readYourMessages": "請先閱讀開放街圖訊息之前再來新增新節點。", - "returnToTheMap": "回到地圖", - "save": "儲存", - "search": { - "error": "有狀況發生了…", - "nothing": "沒有找到…", - "search": "搜尋地點", - "searching": "搜尋中…" - }, - "sharescreen": { - "addToHomeScreen": "

新增到您的主畫面

您可以輕易將這網站新增到您智慧型手機的主畫面,在網址列點選「新增到主畫面按鈕」來做這件事情。", - "copiedToClipboard": "複製連結到簡貼簿", - "editThemeDescription": "新增或改變這個地圖的問題", - "editThisTheme": "編輯這個主題", - "embedIntro": "

嵌入到你的網站

請考慮將這份地圖嵌入您的網站。
地圖毋須額外授權,非常歡迎您多加利用。
一切都是免費的,而且之後也是免費的,越有更多人使用,則越顯得它的價值。", - "fsAddNew": "啟用'新增新的興趣點'按鈕", - "fsGeolocation": "啟用'地理定位自身'按鈕 (只有行動版本)", - "fsIncludeCurrentBackgroundMap": "包含目前背景選擇{name}", - "fsIncludeCurrentLayers": "包含目前選擇圖層", - "fsIncludeCurrentLocation": "包含目前位置", - "fsLayerControlToggle": "開始時擴展圖層控制", - "fsLayers": "啟用圖層控制", - "fsSearch": "啟用搜尋列", - "fsUserbadge": "啟用登入按鈕", - "fsWelcomeMessage": "顯示歡迎訊息以及相關頁籤", - "intro": "

分享這地圖

複製下面的連結來向朋友與家人分享這份地圖:", - "thanksForSharing": "感謝分享!" - }, - "skip": "跳過這問題", - "skippedQuestions": "有些問題已經跳過了", - "weekdays": { - "abbreviations": { - "friday": "星期五", - "monday": "星期一", - "saturday": "星期六", - "sunday": "星期日", - "thursday": "星期四", - "tuesday": "星期二", - "wednesday": "星期三" - }, - "friday": "星期五", - "monday": "星期一", - "saturday": "星期六", - "sunday": "星期日", - "thursday": "星期四", - "tuesday": "星期二", - "wednesday": "星期三" - }, - "welcomeBack": "你已經登入了,歡迎回來!" - }, "getStartedLogin": "登入開放街圖帳號來開始", "getStartedNewAccount": " 或是 註冊新帳號", "goToInbox": "開啟訊息框", diff --git a/package-lock.json b/package-lock.json index 34bf3624d1..e64e522b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "libphonenumber-js": "^1.7.55", "lz-string": "^1.4.4", "mangrove-reviews": "^0.1.3", - "moment": "^2.29.0", + "moment": "^2.29.2", "opening_hours": "^3.6.0", "osm-auth": "^1.0.2", "osmtogeojson": "^3.0.0-beta.4", @@ -9738,9 +9738,9 @@ } }, "node_modules/moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "version": "2.29.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", + "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==", "engines": { "node": "*" } @@ -24294,9 +24294,9 @@ "integrity": "sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==" }, "moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + "version": "2.29.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", + "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==" }, "monotone-convex-hull-2d": { "version": "1.0.1", diff --git a/package.json b/package.json index 8a84395bcf..cd3536ab8c 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "libphonenumber-js": "^1.7.55", "lz-string": "^1.4.4", "mangrove-reviews": "^0.1.3", - "moment": "^2.29.0", + "moment": "^2.29.2", "opening_hours": "^3.6.0", "osm-auth": "^1.0.2", "osmtogeojson": "^3.0.0-beta.4", diff --git a/scripts/automoveTranslations.ts b/scripts/automoveTranslations.ts new file mode 100644 index 0000000000..662b91fb0f --- /dev/null +++ b/scripts/automoveTranslations.ts @@ -0,0 +1,61 @@ +import * as languages from "../assets/generated/used_languages.json" +import {readFileSync, writeFileSync} from "fs"; + +/** + * Moves values around in 'section'. Section will be changed + * @param section + * @param referenceSection + * @param language + */ +function fixSection(section, referenceSection, language: string) { + if(section === undefined){ + return + } + outer: for (const key of Object.keys(section)) { + const v = section[key] + if(typeof v ==="string" && referenceSection[key] === undefined){ + // Not found in reference, search for a subsection with this key + for (const subkey of Object.keys(referenceSection)) { + const subreference = referenceSection[subkey] + if(subreference[key] !== undefined){ + if(section[subkey] !== undefined && section[subkey][key] !== undefined) { + console.log(`${subkey}${key} is already defined... Looking furhter`) + continue + } + if(typeof section[subkey] === "string"){ + console.log(`NOT overwriting '${section[subkey]}' for ${subkey} (needed for ${key})`) + }else{ + // apply fix + if(section[subkey] === undefined){ + section[subkey] = {} + } + section[subkey][key] = section[key] + delete section[key] + console.log(`Rewritten key: ${key} --> ${subkey}.${key} in language ${language}`) + continue outer + } + } + } + console.log("No solution found for "+key) + } + } +} + + +function main(args:string[]):void{ + const sectionName = args[0] + const l = args[1] + if(sectionName === undefined){ + console.log("Tries to automatically move translations to a new subsegment. Usage: 'sectionToCheck' 'language'") + return + } + const reference = JSON.parse( readFileSync("./langs/en.json","UTF8")) + const path = `./langs/${l}.json` + const file = JSON.parse( readFileSync(path,"UTF8")) + fixSection(file[sectionName], reference[sectionName], l) + writeFileSync(path, JSON.stringify(file, null, " ")+"\n") + + +} + +main(process.argv.slice(2)) \ No newline at end of file diff --git a/scripts/generateTranslations.ts b/scripts/generateTranslations.ts index d94873d99d..a046eabbd3 100644 --- a/scripts/generateTranslations.ts +++ b/scripts/generateTranslations.ts @@ -297,7 +297,20 @@ function transformTranslation(obj: any, path: string[] = [], languageWhitelist : } value = nv; } - values += `${Utils.Times((_) => " ", path.length + 1)}get ${key}() { return new Translation(${JSON.stringify(value)}, "core:${path.join(".")}.${key}") }, + + + if(value["en"] === undefined){ + throw `At ${path.join(".")}: Missing 'en' translation at path ${path.join(".")}.${key}\n\tThe translations in other languages are ${JSON.stringify(value)}` + } + const subParts : string[] = value["en"].match(/{[^}]*}/g) + let expr = `return new Translation(${JSON.stringify(value)}, "core:${path.join(".")}.${key}")` + if(subParts !== null){ + // convert '{to_substitute}' into 'to_substitute' + const types = Utils.Dedup( subParts.map(tp => tp.substring(1, tp.length - 1))) + expr = `return new TypedTranslation<{ ${types.join(", ")} }>(${JSON.stringify(value)}, "core:${path.join(".")}.${key}")` + } + + values += `${Utils.Times((_) => " ", path.length + 1)}get ${key}() { ${expr} }, ` } else { values += (Utils.Times((_) => " ", path.length + 1)) + key + ": " + transformTranslation(value, [...path, key], languageWhitelist) + ",\n" @@ -340,7 +353,7 @@ function genTranslations() { const translations = JSON.parse(fs.readFileSync("./assets/generated/translations.json", "utf-8")) const transformed = transformTranslation(translations); - let module = `import {Translation} from "../../UI/i18n/Translation"\n\nexport default class CompiledTranslations {\n\n`; + let module = `import {Translation, TypedTranslation} from "../../UI/i18n/Translation"\n\nexport default class CompiledTranslations {\n\n`; module += " public static t = " + transformed; module += "\n }" diff --git a/test/Logic/Tags/OptimizeTags.spec.ts b/test/Logic/Tags/OptimizeTags.spec.ts new file mode 100644 index 0000000000..5229c94a08 --- /dev/null +++ b/test/Logic/Tags/OptimizeTags.spec.ts @@ -0,0 +1,347 @@ +import {describe} from 'mocha' +import {expect} from 'chai' +import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; +import {And} from "../../../Logic/Tags/And"; +import {Tag} from "../../../Logic/Tags/Tag"; +import {TagUtils} from "../../../Logic/Tags/TagUtils"; +import {Or} from "../../../Logic/Tags/Or"; +import {RegexTag} from "../../../Logic/Tags/RegexTag"; + +describe("Tag optimalization", () => { + + describe("And", () => { + it("with condition and nested and should be flattened", () => { + const t = new And( + [ + new And([ + new Tag("x", "y") + ]), + new Tag("a", "b") + ] + ) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq(`a=b&x=y`) + }) + + it("should be 'true' if no conditions are given", () => { + const t = new And( + [] + ) + const opt = t.optimize() + expect(opt).eq(true) + }) + + it("with nested ors and common property should be extracted", () => { + + // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) + const t = new And([ + new Tag("foo", "bar"), + new Or([ + new Tag("x", "y"), + new Tag("a", "b") + ]), + new Or([ + new Tag("x", "y"), + new Tag("c", "d") + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar& (x=y| (a=b&c=d) )") + }) + + it("with nested ors and common regextag should be extracted", () => { + + // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) + const t = new And([ + new Tag("foo", "bar"), + new Or([ + new RegexTag("x", "y"), + new RegexTag("a", "b") + ]), + new Or([ + new RegexTag("x", "y"), + new RegexTag("c", "d") + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar& ( (a=b&c=d) |x=y)") + }) + + it("with nested ors and inverted regextags should _not_ be extracted", () => { + + // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) + const t = new And([ + new Tag("foo", "bar"), + new Or([ + new RegexTag("x", "y"), + new RegexTag("a", "b") + ]), + new Or([ + new RegexTag("x", "y", true), + new RegexTag("c", "d") + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar& (a=b|x=y) & (c=d|x!=y)") + }) + + it("should move regextag to the end", () => { + const t = new And([ + new RegexTag("x", "y"), + new Tag("a", "b") + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("a=b&x=y") + + }) + + it("should sort tags by their popularity (least popular first)", () => { + const t = new And([ + new Tag("bicycle", "yes"), + new Tag("amenity", "binoculars") + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("amenity=binoculars&bicycle=yes") + + }) + + it("should optimize nested ORs", () => { + const filter = TagUtils.Tag({ + or: [ + "X=Y", "FOO=BAR", + { + "and": [ + { + "or": ["X=Y", "FOO=BAR"] + }, + "bicycle=yes" + ] + } + ] + }) + // (X=Y | FOO=BAR | (bicycle=yes & (X=Y | FOO=BAR)) ) + // This is equivalent to (X=Y | FOO=BAR) + const opt = filter.optimize() + console.log(opt) + }) + + it("should optimize an advanced, real world case", () => { + const filter = TagUtils.Tag({ + or: [ + { + "and": [ + { + "or": ["amenity=charging_station", "disused:amenity=charging_station", "planned:amenity=charging_station", "construction:amenity=charging_station"] + }, + "bicycle=yes" + ] + }, + { + "and": [ + { + "or": ["amenity=charging_station", "disused:amenity=charging_station", "planned:amenity=charging_station", "construction:amenity=charging_station"] + }, + ] + }, + "amenity=toilets", + "amenity=bench", + "leisure=picnic_table", + { + "and": [ + "tower:type=observation" + ] + }, + { + "and": [ + "amenity=bicycle_repair_station" + ] + }, + { + "and": [ + { + "or": [ + "amenity=bicycle_rental", + "bicycle_rental~*", + "service:bicycle:rental=yes", + "rental~.*bicycle.*" + ] + }, + "bicycle_rental!=docking_station" + ] + }, + { + "and": [ + "leisure=playground", + "playground!=forest" + ] + } + ] + }); + const opt = filter.optimize() + const expected = ["amenity=charging_station", + "amenity=toilets", + "amenity=bench", + "amenity=bicycle_repair_station", + "construction:amenity=charging_station", + "disused:amenity=charging_station", + "leisure=picnic_table", + "planned:amenity=charging_station", + "tower:type=observation", + "(amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!=docking_station", + "leisure=playground&playground!=forest"] + + expect((opt).or.map(f => TagUtils.toString(f))).deep.eq( + expected + ) + }) + + it("should detect conflicting tags", () => { + const q = new And([new Tag("key", "value"), new RegexTag("key", "value", true)]) + expect(q.optimize()).eq(false) + }) + + it("should detect conflicting tags with a regex", () => { + const q = new And([new Tag("key", "value"), new RegexTag("key", /value/, true)]) + expect(q.optimize()).eq(false) + }) + + }) + + describe("Or", () => { + + + it("with nested And which has a common property should be dropped", () => { + + const t = new Or([ + new Tag("foo", "bar"), + new And([ + new Tag("foo", "bar"), + new Tag("x", "y"), + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar") + + }) + + it("should flatten nested ors", () => { + const t = new Or([ + new Or([ + new Tag("x", "y") + ]) + ]).optimize() + expect(t).deep.eq(new Tag("x", "y")) + }) + + it("should flatten nested ors", () => { + const t = new Or([ + new Tag("a", "b"), + new Or([ + new Tag("x", "y") + ]) + ]).optimize() + expect(t).deep.eq(new Or([new Tag("a", "b"), new Tag("x", "y")])) + }) + + }) + + it("should not generate a conflict for climbing tags", () => { + const club_tags = TagUtils.Tag( + { + "or": [ + "club=climbing", + { + "and": [ + "sport=climbing", + { + "or": [ + "office~*", + "club~*" + ] + } + ] + } + ] + }) + const gym_tags = TagUtils.Tag({ + "and": [ + "sport=climbing", + "leisure=sports_centre" + ] + }) + const other_climbing = TagUtils.Tag({ + "and": [ + "sport=climbing", + "climbing!~route", + "leisure!~sports_centre", + "climbing!=route_top", + "climbing!=route_bottom" + ] + }) + const together = new Or([club_tags, gym_tags, other_climbing]) + const opt = together.optimize() + + /* + club=climbing | (sport=climbing&(office~* | club~*)) + OR + sport=climbing & leisure=sports_centre + OR + sport=climbing & climbing!~route & leisure!~sports_centre + */ + + /* + > When the first OR is written out, this becomes + club=climbing + OR + (sport=climbing&(office~* | club~*)) + OR + (sport=climbing & leisure=sports_centre) + OR + (sport=climbing & climbing!~route & leisure!~sports_centre & ...) + */ + + /* + > We can join the 'sport=climbing' in the last 3 phrases + club=climbing + OR + (sport=climbing AND + (office~* | club~*)) + OR + (leisure=sports_centre) + OR + (climbing!~route & leisure!~sports_centre & ...) + ) + */ + + + expect(opt).deep.eq( + TagUtils.Tag({ + or: [ + "club=climbing", + { + and: ["sport=climbing", + {or: ["club~*", "office~*"]}] + }, + { + and: ["sport=climbing", + { + or: [ + "leisure=sports_centre", + { + and: [ + "climbing!~route", + "climbing!=route_top", + "climbing!=route_bottom", + "leisure!~sports_centre" + ] + } + ] + }] + } + + ], + + }) + ) + }) +}) \ No newline at end of file diff --git a/test/Logic/Tags/OptimzeTags.spec.ts b/test/Logic/Tags/OptimzeTags.spec.ts deleted file mode 100644 index 46870ff532..0000000000 --- a/test/Logic/Tags/OptimzeTags.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {describe} from 'mocha' -import {expect} from 'chai' -import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; -import {And} from "../../../Logic/Tags/And"; -import {Tag} from "../../../Logic/Tags/Tag"; -import {TagUtils} from "../../../Logic/Tags/TagUtils"; -import {Or} from "../../../Logic/Tags/Or"; -import {RegexTag} from "../../../Logic/Tags/RegexTag"; - -describe("Tag optimalization", () => { - - describe("And", () => { - it("with condition and nested and should be flattened", () => { - const t = new And( - [ - new And([ - new Tag("x", "y") - ]), - new Tag("a", "b") - ] - ) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq(`a=b&x=y`) - }) - - it("with nested ors and commons property should be extracted", () => { - - // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) - const t = new And([ - new Tag("foo","bar"), - new Or([ - new Tag("x", "y"), - new Tag("a", "b") - ]), - new Or([ - new Tag("x", "y"), - new Tag("c", "d") - ]) - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("foo=bar& (x=y| (a=b&c=d) )") - }) - - it("should move regextag to the end", () => { - const t = new And([ - new RegexTag("x","y"), - new Tag("a","b") - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("a=b&x~^y$") - - }) - - it("should sort tags by their popularity (least popular first)", () => { - const t = new And([ - new Tag("bicycle","yes"), - new Tag("amenity","binoculars") - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("amenity=binoculars&bicycle=yes") - - }) - - it("should optimize an advanced, real world case", () => { - const filter = TagUtils.Tag( {or: [ - { - "and": [ - { - "or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"] - }, - "bicycle=yes" - ] - }, - { - "and": [ - { - "or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"] - }, - ] - }, - "amenity=toilets", - "amenity=bench", - "leisure=picnic_table", - { - "and": [ - "tower:type=observation" - ] - }, - { - "and": [ - "amenity=bicycle_repair_station" - ] - }, - { - "and": [ - { - "or": [ - "amenity=bicycle_rental", - "bicycle_rental~*", - "service:bicycle:rental=yes", - "rental~.*bicycle.*" - ] - }, - "bicycle_rental!=docking_station" - ] - }, - { - "and": [ - "leisure=playground", - "playground!=forest" - ] - } - ]}); - const opt = filter.optimize() - const expected = "amenity=charging_station|" + - "amenity=toilets|" + - "amenity=bench|" + - "amenity=bicycle_repair_station" + - "|construction:amenity=charging_station|" + - "disused:amenity=charging_station|" + - "leisure=picnic_table|" + - "planned:amenity=charging_station|" + - "tower:type=observation| " + - "( (amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!~^docking_station$) |" + - " (leisure=playground&playground!~^forest$)" - - expect(TagUtils.toString(opt).replace(/ /g, "")) - .eq(expected.replace(/ /g, "")) - - }) - - }) - - describe("Or", () => { - it("with nested And which has a common property should be dropped", () => { - - const t = new Or([ - new Tag("foo","bar"), - new And([ - new Tag("foo", "bar"), - new Tag("x", "y"), - ]) - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("foo=bar") - - }) - - }) -}) \ No newline at end of file diff --git a/test/Models/ThemeConfig/SourceConfig.spec.ts b/test/Models/ThemeConfig/SourceConfig.spec.ts new file mode 100644 index 0000000000..97c590e87a --- /dev/null +++ b/test/Models/ThemeConfig/SourceConfig.spec.ts @@ -0,0 +1,19 @@ +import {describe} from 'mocha' +import {expect} from 'chai' +import SourceConfig from "../../../Models/ThemeConfig/SourceConfig"; +import {TagUtils} from "../../../Logic/Tags/TagUtils"; + +describe("SourceConfig", () => { + + it("should throw an error on conflicting tags", () => { + expect(() => { + new SourceConfig( + { + osmTags: TagUtils.Tag({ + and: ["x=y", "a=b", "x!=y"] + }) + }, false + ) + }).to.throw(/tags are conflicting/) + }) +}) diff --git a/test/scripts/GenerateCache.spec.ts b/test/scripts/GenerateCache.spec.ts index e5be78431b..51558bcd8c 100644 --- a/test/scripts/GenerateCache.spec.ts +++ b/test/scripts/GenerateCache.spec.ts @@ -34,7 +34,7 @@ describe("GenerateCache", () => { } mkdirSync("/tmp/np-cache") initDownloads( - "(nwr%5B%22amenity%22%3D%22toilets%22%5D%3Bnwr%5B%22amenity%22%3D%22parking%22%5D%3Bnwr%5B%22amenity%22%3D%22bench%22%5D%3Bnwr%5B%22id%22%3D%22location_track%22%5D%3Bnwr%5B%22id%22%3D%22gps%22%5D%3Bnwr%5B%22information%22%3D%22board%22%5D%3Bnwr%5B%22leisure%22%3D%22picnic_table%22%5D%3Bnwr%5B%22man_made%22%3D%22watermill%22%5D%3Bnwr%5B%22user%3Ahome%22%3D%22yes%22%5D%3Bnwr%5B%22user%3Alocation%22%3D%22yes%22%5D%3Bnwr%5B%22leisure%22%3D%22nature_reserve%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22boundary%22%3D%22protected_area%22%5D%5B%22protect_class%22!~%22%5E98%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22visitor_centre%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22office%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*foot.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*hiking.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*bycicle.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*horse.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22leisure%22%3D%22bird_hide%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22amenity%22%3D%22drinking_water%22%5D%5B%22access%22!~%22%5Epermissive%24%22%5D%5B%22access%22!~%22%5Eprivate%24%22%5D%3B)%3Bout%20body%3Bout%20meta%3B%3E%3Bout%20skel%20qt%3B" + "(nwr%5B%22amenity%22%3D%22toilets%22%5D%3Bnwr%5B%22amenity%22%3D%22parking%22%5D%3Bnwr%5B%22amenity%22%3D%22bench%22%5D%3Bnwr%5B%22id%22%3D%22location_track%22%5D%3Bnwr%5B%22id%22%3D%22gps%22%5D%3Bnwr%5B%22information%22%3D%22board%22%5D%3Bnwr%5B%22leisure%22%3D%22picnic_table%22%5D%3Bnwr%5B%22man_made%22%3D%22watermill%22%5D%3Bnwr%5B%22user%3Ahome%22%3D%22yes%22%5D%3Bnwr%5B%22user%3Alocation%22%3D%22yes%22%5D%3Bnwr%5B%22leisure%22%3D%22nature_reserve%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22boundary%22%3D%22protected_area%22%5D%5B%22protect_class%22!%3D%2298%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22visitor_centre%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22office%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*foot.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*hiking.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*bycicle.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*horse.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22leisure%22%3D%22bird_hide%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22amenity%22%3D%22drinking_water%22%5D%5B%22access%22!%3D%22permissive%22%5D%5B%22access%22!%3D%22private%22%5D%3B)%3Bout%20body%3Bout%20meta%3B%3E%3Bout%20skel%20qt%3B" ); await main([ "natuurpunt",