forked from MapComplete/MapComplete
		
	Add more checks on parsing from JSON, fix of those issues on the builtin themes
This commit is contained in:
		
							parent
							
								
									8da0893c05
								
							
						
					
					
						commit
						97ec893479
					
				
					 9 changed files with 117 additions and 29 deletions
				
			
		|  | @ -108,6 +108,7 @@ export class FromJSON { | ||||||
|             throw `Tagrendering ${propertyName} is undefined...` |             throw `Tagrendering ${propertyName} is undefined...` | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |          | ||||||
|         if (typeof json === "string") { |         if (typeof json === "string") { | ||||||
| 
 | 
 | ||||||
|             switch (json) { |             switch (json) { | ||||||
|  | @ -137,6 +138,8 @@ export class FromJSON { | ||||||
|                 } |                 } | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|  |         // It's the question that drives us, neo
 | ||||||
|  |         const question =  FromJSON.Translation(json.question); | ||||||
| 
 | 
 | ||||||
|         let template = FromJSON.Translation(json.render); |         let template = FromJSON.Translation(json.render); | ||||||
| 
 | 
 | ||||||
|  | @ -165,28 +168,35 @@ export class FromJSON { | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const mappings = json.mappings?.map(mapping => ( |         const mappings = json.mappings?.map((mapping, i) => { | ||||||
|             { |                 const k = FromJSON.Tag(mapping.if, `IN mapping #${i} of tagrendering ${propertyName}`) | ||||||
|                 k: FromJSON.Tag(mapping.if), | 
 | ||||||
|  |                 if (question !== undefined && !mapping.hideInAnswer && !k.isUsableAsAnswer()) { | ||||||
|  |                     throw `Invalid mapping in ${propertyName}: the tags use an OR-expression or regex expression but are also assignable as answer.` | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 return { | ||||||
|  |                     k: k, | ||||||
|                     txt: FromJSON.Translation(mapping.then), |                     txt: FromJSON.Translation(mapping.then), | ||||||
|                     hideInAnswer: mapping.hideInAnswer |                     hideInAnswer: mapping.hideInAnswer | ||||||
|             }) |                 }; | ||||||
|  |             } | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         if (template === undefined && (mappings === undefined || mappings.length === 0)) { |         if (template === undefined && (mappings === undefined || mappings.length === 0)) { | ||||||
|             console.error("Empty tagrendering detected: no mappings nor template given", json) |             console.error(`Empty tagrendering detected in ${propertyName}: no mappings nor template given`, json) | ||||||
|             throw `Empty tagrendering ${propertyName} detected: no mappings nor template given` |             throw `Empty tagrendering ${propertyName} detected: no mappings nor template given` | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|         let rendering = new TagRenderingOptions({ |         let rendering = new TagRenderingOptions({ | ||||||
|             question: FromJSON.Translation(json.question), |             question: question, | ||||||
|             freeform: freeform, |             freeform: freeform, | ||||||
|             mappings: mappings |             mappings: mappings | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         if (json.condition) { |         if (json.condition) { | ||||||
|             return rendering.OnlyShowIf(FromJSON.Tag(json.condition)); |             return rendering.OnlyShowIf(FromJSON.Tag(json.condition, `In tagrendering ${propertyName}.condition`)); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return rendering; |         return rendering; | ||||||
|  | @ -197,7 +207,7 @@ export class FromJSON { | ||||||
|         return new Tag(tag[0], tag[1]); |         return new Tag(tag[0], tag[1]); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static Tag(json: AndOrTagConfigJson | string): TagsFilter { |     public static Tag(json: AndOrTagConfigJson | string, context: string): TagsFilter { | ||||||
|         if(json === undefined){ |         if(json === undefined){ | ||||||
|             throw "Error while parsing a tag: nothing defined. Make sure all the tags are defined and at least one tag is present in a complex expression" |             throw "Error while parsing a tag: nothing defined. Make sure all the tags are defined and at least one tag is present in a complex expression" | ||||||
|         } |         } | ||||||
|  | @ -206,7 +216,7 @@ export class FromJSON { | ||||||
|             if (tag.indexOf("!~") >= 0) { |             if (tag.indexOf("!~") >= 0) { | ||||||
|                 const split = Utils.SplitFirst(tag, "!~"); |                 const split = Utils.SplitFirst(tag, "!~"); | ||||||
|                 if (split[1] === "*") { |                 if (split[1] === "*") { | ||||||
|                     split[1] = "..*" |                     throw `Don't use 'key!~*' - use 'key=' instead (empty string as value (in the tag ${tag} while parsing ${context})` | ||||||
|                 } |                 } | ||||||
|                 return new RegexTag( |                 return new RegexTag( | ||||||
|                     split[0], |                     split[0], | ||||||
|  | @ -214,6 +224,16 @@ export class FromJSON { | ||||||
|                     true |                     true | ||||||
|                 ); |                 ); | ||||||
|             } |             } | ||||||
|  |             if (tag.indexOf("~~") >= 0) { | ||||||
|  |                 const split = Utils.SplitFirst(tag, "~~"); | ||||||
|  |                 if (split[1] === "*") { | ||||||
|  |                     split[1] = "..*" | ||||||
|  |                 } | ||||||
|  |                 return new RegexTag( | ||||||
|  |                         new RegExp("^" + split[0] + "$"), | ||||||
|  |                     new RegExp("^" + split[1] + "$") | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|             if (tag.indexOf("!=") >= 0) { |             if (tag.indexOf("!=") >= 0) { | ||||||
|                 const split = Utils.SplitFirst(tag, "!="); |                 const split = Utils.SplitFirst(tag, "!="); | ||||||
|                 if (split[1] === "*") { |                 if (split[1] === "*") { | ||||||
|  | @ -236,13 +256,16 @@ export class FromJSON { | ||||||
|                 ); |                 ); | ||||||
|             } |             } | ||||||
|             const split = Utils.SplitFirst(tag, "="); |             const split = Utils.SplitFirst(tag, "="); | ||||||
|  |             if(split[1] == "*"){ | ||||||
|  |                 throw `Error while parsing tag '${tag}' in ${context}: detected a wildcard on a normal value. Use a regex pattern instead` | ||||||
|  |             } | ||||||
|             return new Tag(split[0], split[1]) |             return new Tag(split[0], split[1]) | ||||||
|         } |         } | ||||||
|         if (json.and !== undefined) { |         if (json.and !== undefined) { | ||||||
|             return new And(json.and.map(FromJSON.Tag)); |             return new And(json.and.map(t => FromJSON.Tag(t, context))); | ||||||
|         } |         } | ||||||
|         if (json.or !== undefined) { |         if (json.or !== undefined) { | ||||||
|             return new Or(json.or.map(FromJSON.Tag)); |             return new Or(json.or.map(t => FromJSON.Tag(t, context))); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -265,7 +288,7 @@ export class FromJSON { | ||||||
| 
 | 
 | ||||||
|         console.log("Parsing layer", json) |         console.log("Parsing layer", json) | ||||||
|         const tr = FromJSON.Translation; |         const tr = FromJSON.Translation; | ||||||
|         const overpassTags = FromJSON.Tag(json.overpassTags); |         const overpassTags = FromJSON.Tag(json.overpassTags, "overpasstags for layer "+json.id); | ||||||
|         const icon = FromJSON.TagRenderingWithDefault(json.icon, "icon", "./assets/bug.svg"); |         const icon = FromJSON.TagRenderingWithDefault(json.icon, "icon", "./assets/bug.svg"); | ||||||
|         const iconSize = FromJSON.TagRenderingWithDefault(json.iconSize, "iconSize", "40,40,center"); |         const iconSize = FromJSON.TagRenderingWithDefault(json.iconSize, "iconSize", "40,40,center"); | ||||||
|         const color = FromJSON.TagRenderingWithDefault(json.color, "color", "#0000ff"); |         const color = FromJSON.TagRenderingWithDefault(json.color, "color", "#0000ff"); | ||||||
|  |  | ||||||
|  | @ -7,6 +7,15 @@ import BikeCafes from "../Layers/BikeCafes"; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| export default class Cyclofix extends Layout { | export default class Cyclofix extends Layout { | ||||||
|  | 
 | ||||||
|  |     private static RememberTheDead(): boolean { | ||||||
|  |         const now = new Date(); | ||||||
|  |         const m = now.getMonth() + 1; | ||||||
|  |         const day = new Date().getDay() + 1; | ||||||
|  |         const date = day + "/" + m; | ||||||
|  |         return (date === "31/10" || date === "1/11" || date === "2/11"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     constructor() { |     constructor() { | ||||||
|         super( |         super( | ||||||
|             "cyclofix", |             "cyclofix", | ||||||
|  | @ -14,7 +23,7 @@ export default class Cyclofix extends Layout { | ||||||
|             Translations.t.cyclofix.title, |             Translations.t.cyclofix.title, | ||||||
|             ["bike_repair_station", new BikeShops(), "drinking_water", "bike_parking", new BikeOtherShops(), new BikeCafes(), |             ["bike_repair_station", new BikeShops(), "drinking_water", "bike_parking", new BikeOtherShops(), new BikeCafes(), | ||||||
|                 // The first of november, we remember our dead
 |                 // The first of november, we remember our dead
 | ||||||
|                 ...(new Date().getMonth() + 1 == 11 && new Date().getDay() + 1 == 1 ? ["ghost_bike"] : [])], |                 ...(Cyclofix.RememberTheDead() ? ["ghost_bike"] : [])], | ||||||
|             16, |             16, | ||||||
|             50.8465573, |             50.8465573, | ||||||
|             4.3516970, |             4.3516970, | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ export abstract class TagsFilter { | ||||||
|     abstract matches(tags: { k: string, v: string }[]): boolean |     abstract matches(tags: { k: string, v: string }[]): boolean | ||||||
|     abstract asOverpass(): string[] |     abstract asOverpass(): string[] | ||||||
|     abstract substituteValues(tags: any) : TagsFilter; |     abstract substituteValues(tags: any) : TagsFilter; | ||||||
|  |     abstract isUsableAsAnswer() : boolean; | ||||||
| 
 | 
 | ||||||
|     matchesProperties(properties: Map<string, string>): boolean { |     matchesProperties(properties: Map<string, string>): boolean { | ||||||
|         return this.matches(TagUtils.proprtiesToKV(properties)); |         return this.matches(TagUtils.proprtiesToKV(properties)); | ||||||
|  | @ -43,6 +44,10 @@ export class RegexTag extends TagsFilter { | ||||||
|         return r.source; |         return r.source; | ||||||
|     } |     } | ||||||
|      |      | ||||||
|  |     isUsableAsAnswer(): boolean { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     matches(tags: { k: string; v: string }[]): boolean { |     matches(tags: { k: string; v: string }[]): boolean { | ||||||
|         for (const tag of tags) { |         for (const tag of tags) { | ||||||
|             if (RegexTag.doesMatch(tag.k, this.key)){ |             if (RegexTag.doesMatch(tag.k, this.key)){ | ||||||
|  | @ -58,7 +63,10 @@ export class RegexTag extends TagsFilter { | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     asHumanString() { |     asHumanString() { | ||||||
|         return `${RegexTag.source(this.key)}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}`; |         if (typeof this.key === "string") { | ||||||
|  |             return `${this.key}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}`; | ||||||
|  |         } | ||||||
|  |         return `~${this.key.source}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}` | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -78,7 +86,7 @@ export class Tag extends TagsFilter { | ||||||
|             throw "Invalid value"; |             throw "Invalid value"; | ||||||
|         } |         } | ||||||
|         if(value === "*"){ |         if(value === "*"){ | ||||||
|          console.warn(`Got suspicious tag ${key}=*   ; did you mean ${key}!~*`) |          console.warn(`Got suspicious tag ${key}=*   ; did you mean ${key}~* ?`) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -128,6 +136,10 @@ export class Tag extends TagsFilter { | ||||||
|         } |         } | ||||||
|         return this.key + "=" + v; |         return this.key + "=" + v; | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     isUsableAsAnswer(): boolean { | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -171,6 +183,10 @@ export class Or extends TagsFilter { | ||||||
|     asHumanString(linkToWiki: boolean, shorten: boolean) { |     asHumanString(linkToWiki: boolean, shorten: boolean) { | ||||||
|         return this.or.map(t => t.asHumanString(linkToWiki, shorten)).join("|"); |         return this.or.map(t => t.asHumanString(linkToWiki, shorten)).join("|"); | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     isUsableAsAnswer(): boolean { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -231,6 +247,15 @@ export class And extends TagsFilter { | ||||||
|     asHumanString(linkToWiki: boolean, shorten: boolean) { |     asHumanString(linkToWiki: boolean, shorten: boolean) { | ||||||
|         return this.and.map(t => t.asHumanString(linkToWiki, shorten)).join("&"); |         return this.and.map(t => t.asHumanString(linkToWiki, shorten)).join("&"); | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     isUsableAsAnswer(): boolean { | ||||||
|  |         for (const t of this.and) { | ||||||
|  |             if(!t.isUsableAsAnswer()){ | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -261,4 +286,5 @@ export class TagUtils { | ||||||
|         } |         } | ||||||
|         return properties; |         return properties; | ||||||
|     } |     } | ||||||
|  |      | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -19,7 +19,7 @@ export default class SharePanel extends UIElement { | ||||||
|         const proposedNameEnc = encodeURIComponent(`wiki:User:${userDetails.name}/${config.data.id}`) |         const proposedNameEnc = encodeURIComponent(`wiki:User:${userDetails.name}/${config.data.id}`) | ||||||
| 
 | 
 | ||||||
|         this._panel = new Combine([ |         this._panel = new Combine([ | ||||||
|             "<h2>share</h2>", |             "<h2>Share</h2>", | ||||||
|             "Share the following link with friends:<br/>", |             "Share the following link with friends:<br/>", | ||||||
|             new VariableUiElement(liveUrl.map(url => `<a href='${url}' target="_blank">${url}</a>`)), |             new VariableUiElement(liveUrl.map(url => `<a href='${url}' target="_blank">${url}</a>`)), | ||||||
|             "<h2>Publish on OSM Wiki</h2>", |             "<h2>Publish on OSM Wiki</h2>", | ||||||
|  |  | ||||||
|  | @ -14,6 +14,8 @@ import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; | ||||||
| import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; | import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; | ||||||
| import {UserDetails} from "../../Logic/Osm/OsmConnection"; | import {UserDetails} from "../../Logic/Osm/OsmConnection"; | ||||||
| import {State} from "../../State"; | import {State} from "../../State"; | ||||||
|  | import {VariableUiElement} from "../Base/VariableUIElement"; | ||||||
|  | import {FromJSON} from "../../Customizations/JSON/FromJSON"; | ||||||
| 
 | 
 | ||||||
| export default class TagRenderingPanel extends InputElement<TagRenderingConfigJson> { | export default class TagRenderingPanel extends InputElement<TagRenderingConfigJson> { | ||||||
| 
 | 
 | ||||||
|  | @ -24,6 +26,8 @@ export default class TagRenderingPanel extends InputElement<TagRenderingConfigJs | ||||||
|     private readonly _value: UIEventSource<TagRenderingConfigJson>; |     private readonly _value: UIEventSource<TagRenderingConfigJson>; | ||||||
|     public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; }; |     public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; }; | ||||||
| 
 | 
 | ||||||
|  |     public readonly validText : UIElement; | ||||||
|  |      | ||||||
|     constructor(languages: UIEventSource<string[]>, |     constructor(languages: UIEventSource<string[]>, | ||||||
|                 currentlySelected: UIEventSource<SingleSetting<any>>, |                 currentlySelected: UIEventSource<SingleSetting<any>>, | ||||||
|                 userDetails: UserDetails, |                 userDetails: UserDetails, | ||||||
|  | @ -95,12 +99,24 @@ export default class TagRenderingPanel extends InputElement<TagRenderingConfigJs | ||||||
|         ]; |         ]; | ||||||
| 
 | 
 | ||||||
|         this.settingsTable = new SettingsTable(settings, currentlySelected); |         this.settingsTable = new SettingsTable(settings, currentlySelected); | ||||||
|  |      | ||||||
|  |          | ||||||
|  |         this.validText = new VariableUiElement(value.map((json: TagRenderingConfigJson) => { | ||||||
|  |             try{ | ||||||
|  |                 FromJSON.TagRendering(json, options?.title ?? ""); | ||||||
|  |                 return ""; | ||||||
|  |             }catch(e){ | ||||||
|  |                 return "<span class='alert'>"+e+"</span>" | ||||||
|  |             } | ||||||
|  |         })); | ||||||
|  |      | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     InnerRender(): string { |     InnerRender(): string { | ||||||
|         return new Combine([ |         return new Combine([ | ||||||
|             this.intro, |             this.intro, | ||||||
|             this.settingsTable]).Render(); |             this.settingsTable, | ||||||
|  |             this.validText]).Render(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     GetValue(): UIEventSource<TagRenderingConfigJson> { |     GetValue(): UIEventSource<TagRenderingConfigJson> { | ||||||
|  |  | ||||||
|  | @ -4,6 +4,9 @@ import {DropDown} from "./DropDown"; | ||||||
| import {TextField} from "./TextField"; | import {TextField} from "./TextField"; | ||||||
| import Combine from "../Base/Combine"; | import Combine from "../Base/Combine"; | ||||||
| import {Utils} from "../../Utils"; | import {Utils} from "../../Utils"; | ||||||
|  | import {UIElement} from "../UIElement"; | ||||||
|  | import {VariableUiElement} from "../Base/VariableUIElement"; | ||||||
|  | import {FromJSON} from "../../Customizations/JSON/FromJSON"; | ||||||
| 
 | 
 | ||||||
| export default class SingleTagInput extends InputElement<string> { | export default class SingleTagInput extends InputElement<string> { | ||||||
| 
 | 
 | ||||||
|  | @ -13,17 +16,29 @@ export default class SingleTagInput extends InputElement<string> { | ||||||
|     private key: InputElement<string>; |     private key: InputElement<string>; | ||||||
|     private value: InputElement<string>; |     private value: InputElement<string>; | ||||||
|     private operator: DropDown<string> |     private operator: DropDown<string> | ||||||
|  |     private readonly helpMesage: UIElement; | ||||||
| 
 | 
 | ||||||
|     constructor(value: UIEventSource<string> = undefined) { |     constructor(value: UIEventSource<string> = undefined) { | ||||||
|         super(undefined); |         super(undefined); | ||||||
|         this._value = value ?? new UIEventSource<string>(""); |         this._value = value ?? new UIEventSource<string>(""); | ||||||
| 
 | 
 | ||||||
|  |         this.helpMesage = new VariableUiElement(this._value.map(tagDef => { | ||||||
|  |                 try { | ||||||
|  |                     FromJSON.Tag(tagDef, ""); | ||||||
|  |                     return ""; | ||||||
|  |                 } catch (e) { | ||||||
|  |                     return `<br/><span class='alert'>${e}</span>` | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         )); | ||||||
|  | 
 | ||||||
|         this.key = TextField.KeyInput(); |         this.key = TextField.KeyInput(); | ||||||
| 
 | 
 | ||||||
|         this.value = new TextField<string>({ |         this.value = new TextField<string>({ | ||||||
|                 placeholder: "value - if blank, matches if key is NOT present", |                 placeholder: "value - if blank, matches if key is NOT present", | ||||||
|                 fromString: str => str, |                 fromString: str => str, | ||||||
|                 toString: str => str, |                 toString: str => str, | ||||||
|  |                 value: new UIEventSource<string>("") | ||||||
|             } |             } | ||||||
|         ); |         ); | ||||||
|         this.operator = new DropDown<string>("", [ |         this.operator = new DropDown<string>("", [ | ||||||
|  | @ -85,9 +100,9 @@ export default class SingleTagInput extends InputElement<string> { | ||||||
| 
 | 
 | ||||||
|     InnerRender(): string { |     InnerRender(): string { | ||||||
|         return new Combine([ |         return new Combine([ | ||||||
|             this.key, this.operator, this.value |             this.key, this.operator, this.value, | ||||||
|         ]).SetStyle("display:flex") |             this.helpMesage | ||||||
|             .Render(); |         ]).Render(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -161,7 +161,7 @@ | ||||||
|         { |         { | ||||||
|           "if": { |           "if": { | ||||||
|             "and": [ |             "and": [ | ||||||
|               "operator~Natuurpunt" |               "operator=Natuurpunt" | ||||||
|             ] |             ] | ||||||
|           }, |           }, | ||||||
|           "then": { |           "then": { | ||||||
|  |  | ||||||
|  | @ -252,7 +252,7 @@ | ||||||
|             "en": "What is the reference number of this public bookcase?", |             "en": "What is the reference number of this public bookcase?", | ||||||
|             "nl": "Wat is het referentienummer van dit boekenruilkastje?" |             "nl": "Wat is het referentienummer van dit boekenruilkastje?" | ||||||
|           }, |           }, | ||||||
|           "condition": "brand=*", |           "condition": "brand~*", | ||||||
|           "freeform": { |           "freeform": { | ||||||
|             "key": "ref" |             "key": "ref" | ||||||
|           }, |           }, | ||||||
|  |  | ||||||
|  | @ -12,7 +12,6 @@ | ||||||
|   "maintainer": "MapComlete", |   "maintainer": "MapComlete", | ||||||
|   "widenfactor": 0.05, |   "widenfactor": 0.05, | ||||||
|   "roamingRenderings": [ |   "roamingRenderings": [ | ||||||
|     "pictures", |  | ||||||
|     { |     { | ||||||
|       "question": "Is deze straat een fietsstraat?", |       "question": "Is deze straat een fietsstraat?", | ||||||
|       "mappings": [ |       "mappings": [ | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue