forked from MapComplete/MapComplete
		
	Wikidata language picker
This commit is contained in:
		
							parent
							
								
									b75581405e
								
							
						
					
					
						commit
						325e30666b
					
				
					 14 changed files with 335 additions and 213 deletions
				
			
		|  | @ -12,10 +12,12 @@ import {AddContextToTranslations} from "./AddContextToTranslations"; | |||
| 
 | ||||
| class ExpandTagRendering extends Conversion<string | TagRenderingConfigJson | { builtin: string | string[], override: any }, TagRenderingConfigJson[]> { | ||||
|     private readonly _state: DesugaringContext; | ||||
|     private readonly _self: LayerConfigJson; | ||||
| 
 | ||||
|     constructor(state: DesugaringContext) { | ||||
|     constructor(state: DesugaringContext, self: LayerConfigJson) { | ||||
|         super("Converts a tagRenderingSpec into the full tagRendering, e.g. by substituting the tagRendering by the shared-question", [], "ExpandTagRendering"); | ||||
|         this._state = state; | ||||
|         this._self = self; | ||||
|     } | ||||
| 
 | ||||
|     convert(json: string | TagRenderingConfigJson | { builtin: string | string[]; override: any }, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } { | ||||
|  | @ -33,42 +35,50 @@ class ExpandTagRendering extends Conversion<string | TagRenderingConfigJson | { | |||
|         if (state.tagRenderings.has(name)) { | ||||
|             return [state.tagRenderings.get(name)] | ||||
|         } | ||||
|         if (name.indexOf(".") >= 0) { | ||||
|             const spl = name.split("."); | ||||
|             const layer = state.sharedLayers.get(spl[0]) | ||||
|             if (spl.length === 2 && layer !== undefined) { | ||||
|                 const id = spl[1]; | ||||
|         if (name.indexOf(".") < 0) { | ||||
|             return undefined; | ||||
|         } | ||||
|          | ||||
|         const spl = name.split("."); | ||||
|         let layer = state.sharedLayers.get(spl[0]) | ||||
|         if (spl[0] === this._self.id) { | ||||
|             layer = this._self | ||||
|         } | ||||
| 
 | ||||
|                 const layerTrs = <TagRenderingConfigJson[]>layer.tagRenderings.filter(tr => tr["id"] !== undefined) | ||||
|                 let matchingTrs: TagRenderingConfigJson[] | ||||
|                 if (id === "*") { | ||||
|                     matchingTrs = layerTrs | ||||
|                 } else if (id.startsWith("*")) { | ||||
|                     const id_ = id.substring(1) | ||||
|                     matchingTrs = layerTrs.filter(tr => tr.group === id_ || tr.labels?.indexOf(id_) >= 0) | ||||
|                 } else { | ||||
|                     matchingTrs = layerTrs.filter(tr => tr.id === id) | ||||
|                 } | ||||
|         if (spl.length !== 2 || layer === undefined) { | ||||
|             return undefined | ||||
|         } | ||||
|          | ||||
|         const id = spl[1]; | ||||
| 
 | ||||
|         const layerTrs = <TagRenderingConfigJson[]>layer.tagRenderings.filter(tr => tr["id"] !== undefined) | ||||
|         let matchingTrs: TagRenderingConfigJson[] | ||||
|         if (id === "*") { | ||||
|             matchingTrs = layerTrs | ||||
|         } else if (id.startsWith("*")) { | ||||
|             const id_ = id.substring(1) | ||||
|             matchingTrs = layerTrs.filter(tr => tr.group === id_ || tr.labels?.indexOf(id_) >= 0) | ||||
|         } else { | ||||
|             matchingTrs = layerTrs.filter(tr => tr.id === id) | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|                 const contextWriter = new AddContextToTranslations<TagRenderingConfigJson>("layers:") | ||||
|                 for (let i = 0; i < matchingTrs.length; i++) { | ||||
|                     // The matched tagRenderings are 'stolen' from another layer. This means that they must match the layer condition before being shown
 | ||||
|                     let found : TagRenderingConfigJson = Utils.Clone(matchingTrs[i]); | ||||
|                     if (found.condition === undefined) { | ||||
|                         found.condition = layer.source.osmTags | ||||
|                     } else { | ||||
|                         found.condition = {and: [found.condition, layer.source.osmTags]} | ||||
|                     } | ||||
|                      | ||||
|                     found = contextWriter.convertStrict(found, layer.id+ ".tagRenderings."+found["id"]) | ||||
|                     matchingTrs[i] = found | ||||
|                 } | ||||
| 
 | ||||
|                 if (matchingTrs.length !== 0) { | ||||
|                     return matchingTrs | ||||
|                 } | ||||
|         const contextWriter = new AddContextToTranslations<TagRenderingConfigJson>("layers:") | ||||
|         for (let i = 0; i < matchingTrs.length; i++) { | ||||
|             // The matched tagRenderings are 'stolen' from another layer. This means that they must match the layer condition before being shown
 | ||||
|             let found: TagRenderingConfigJson = Utils.Clone(matchingTrs[i]); | ||||
|             if (found.condition === undefined) { | ||||
|                 found.condition = layer.source.osmTags | ||||
|             } else { | ||||
|                 found.condition = {and: [found.condition, layer.source.osmTags]} | ||||
|             } | ||||
| 
 | ||||
|             found = contextWriter.convertStrict(found, layer.id + ".tagRenderings." + found["id"]) | ||||
|             matchingTrs[i] = found | ||||
|         } | ||||
| 
 | ||||
|         if (matchingTrs.length !== 0) { | ||||
|             return matchingTrs | ||||
|         } | ||||
|         return undefined; | ||||
|     } | ||||
|  | @ -86,7 +96,7 @@ class ExpandTagRendering extends Conversion<string | TagRenderingConfigJson | { | |||
|             const lookup = this.lookup(tr); | ||||
|             if (lookup === undefined) { | ||||
|                 const isTagRendering = ctx.indexOf("On(mapRendering") < 0 | ||||
|                 if(isTagRendering){ | ||||
|                 if (isTagRendering) { | ||||
|                     warnings.push(ctx + "A literal rendering was detected: " + tr) | ||||
|                 } | ||||
|                 return [{ | ||||
|  | @ -114,13 +124,26 @@ class ExpandTagRendering extends Conversion<string | TagRenderingConfigJson | { | |||
|             for (const name of names) { | ||||
|                 const lookup = this.lookup(name) | ||||
|                 if (lookup === undefined) { | ||||
|                     let candidates =  Array.from(state.tagRenderings.keys()) | ||||
|                     if(name.indexOf(".") > 0){ | ||||
|                         const [layer, search] = name.split(".") | ||||
|                         candidates = Utils.NoNull( state.sharedLayers.get(layer).tagRenderings.map(tr => tr["id"])).map(id => layer+"."+id) | ||||
|                     let candidates = Array.from(state.tagRenderings.keys()) | ||||
|                     if (name.indexOf(".") > 0) { | ||||
|                         const [layerName, search] = name.split(".") | ||||
|                         let layer = state.sharedLayers.get(layerName) | ||||
|                         if (layerName === this._self.id) { | ||||
|                             layer = this._self; | ||||
|                         } | ||||
|                         if (layer === undefined) { | ||||
|                             const candidates = Utils.sortedByLevenshteinDistance(layerName, Array.from(state.sharedLayers.keys()), s => s) | ||||
|                            if(state.sharedLayers.size === 0){ | ||||
|                                warnings.push(ctx + ": BOOTSTRAPPING. Rerun generate layeroverview. While reusing tagrendering: " + name + ": layer " + layerName + " not found. Maybe you meant on of " + candidates.slice(0, 3).join(", ")) | ||||
|                            }else{ | ||||
|                                errors.push(ctx + ": While reusing tagrendering: " + name + ": layer " + layerName + " not found. Maybe you meant on of " + candidates.slice(0, 3).join(", ")) | ||||
|                            } | ||||
|                             continue | ||||
|                         } | ||||
|                         candidates = Utils.NoNull(layer.tagRenderings.map(tr => tr["id"])).map(id => layerName + "." + id) | ||||
|                     } | ||||
|                     candidates = Utils.sortedByLevenshteinDistance(name, candidates, i => i); | ||||
|                     errors.push(ctx + ": The tagRendering with identifier " + name + " was not found.\n\tDid you mean one of " +candidates.join(", ") + "?") | ||||
|                     errors.push(ctx + ": The tagRendering with identifier " + name + " was not found.\n\tDid you mean one of " + candidates.join(", ") + "?") | ||||
|                     continue | ||||
|                 } | ||||
|                 for (let foundTr of lookup) { | ||||
|  | @ -168,7 +191,7 @@ export class ExpandRewrite<T> extends Conversion<T | RewritableConfigJson<T>, T[ | |||
|      *   "someKey": "somevalue {xyz}" | ||||
|      * } | ||||
|      * ExpandRewrite.RewriteParts("{xyz}", "rewritten", spec) // => {"someKey": "somevalue rewritten"}
 | ||||
|      *  | ||||
|      * | ||||
|      * // should substitute all occurances in strings
 | ||||
|      * const spec = { | ||||
|      *   "someKey": "The left|right side has {key:left|right}" | ||||
|  | @ -187,8 +210,8 @@ export class ExpandRewrite<T> extends Conversion<T | RewritableConfigJson<T>, T[ | |||
| 
 | ||||
|             if (typeof obj === "string") { | ||||
|                 // This is a simple string - we do a simple replace
 | ||||
|                 while(obj.indexOf(keyToRewrite) >= 0){ | ||||
|                    obj = obj.replace(keyToRewrite, target) | ||||
|                 while (obj.indexOf(keyToRewrite) >= 0) { | ||||
|                     obj = obj.replace(keyToRewrite, target) | ||||
|                 } | ||||
|                 return obj | ||||
|             } | ||||
|  | @ -199,17 +222,17 @@ export class ExpandRewrite<T> extends Conversion<T | RewritableConfigJson<T>, T[ | |||
| 
 | ||||
|             if (typeof obj === "object") { | ||||
|                 obj = {...obj} | ||||
|                  | ||||
| 
 | ||||
|                 const isTr = targetIsTranslation && Translations.isProbablyATranslation(obj) | ||||
|                  | ||||
| 
 | ||||
|                 for (const key in obj) { | ||||
|                     let subtarget = target | ||||
|                     if(isTr && target[key] !== undefined){ | ||||
|                     if (isTr && target[key] !== undefined) { | ||||
|                         // The target is a translation AND the current object is a translation
 | ||||
|                         // This means we should recursively replace with the translated value
 | ||||
|                         subtarget = target[key] | ||||
|                     } | ||||
|                      | ||||
| 
 | ||||
|                     obj[key] = replaceRecursive(obj[key], subtarget) | ||||
|                 } | ||||
|                 return obj | ||||
|  | @ -233,7 +256,7 @@ export class ExpandRewrite<T> extends Conversion<T | RewritableConfigJson<T>, T[ | |||
|      *     renderings: "The value of xyz is abc" | ||||
|      * } | ||||
|      * new ExpandRewrite().convertStrict(spec, "test") // => ["The value of X is A", "The value of Y is B", "The value of Z is C"]
 | ||||
|      *  | ||||
|      * | ||||
|      * // should rewrite with translations
 | ||||
|      * const spec = <RewritableConfigJson<any>>{ | ||||
|      *     rewrite: { | ||||
|  | @ -287,9 +310,9 @@ export class ExpandRewrite<T> extends Conversion<T | RewritableConfigJson<T>, T[ | |||
|         {// sanity check: {rewrite: ["a", "b"] should have the right amount of 'intos' in every case
 | ||||
|             for (let i = 0; i < rewrite.rewrite.into.length; i++) { | ||||
|                 const into = keysToRewrite.into[i] | ||||
|                 if(into.length !== rewrite.rewrite.sourceString.length){ | ||||
|                 throw `${context}.into.${i} Error in rewrite: there are ${rewrite.rewrite.sourceString.length} keys to rewrite, but entry ${i} has only ${into.length} values` | ||||
|                      | ||||
|                 if (into.length !== rewrite.rewrite.sourceString.length) { | ||||
|                     throw `${context}.into.${i} Error in rewrite: there are ${rewrite.rewrite.sourceString.length} keys to rewrite, but entry ${i} has only ${into.length} values` | ||||
| 
 | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | @ -315,50 +338,50 @@ export class ExpandRewrite<T> extends Conversion<T | RewritableConfigJson<T>, T[ | |||
|  */ | ||||
| export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | ||||
|     constructor() { | ||||
|         super("Converts a 'special' translation into a regular translation which uses parameters", ["special"],"RewriteSpecial"); | ||||
|         super("Converts a 'special' translation into a regular translation which uses parameters", ["special"], "RewriteSpecial"); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Does the heavy lifting and conversion | ||||
|      *  | ||||
|      * | ||||
|      * // should not do anything if no 'special'-key is present
 | ||||
|      * RewriteSpecial.convertIfNeeded({"en": "xyz", "nl": "abc"}, [], "test") // => {"en": "xyz", "nl": "abc"}
 | ||||
|      *  | ||||
|      * | ||||
|      * // should handle a simple special case
 | ||||
|      * RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel"}}, [], "test") // => {'*': "{image_carousel()}"}
 | ||||
|      *  | ||||
|      * | ||||
|      * // should handle special case with a parameter
 | ||||
|      * RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel", "image_key": "some_image_key"}}, [], "test") // =>  {'*': "{image_carousel(some_image_key)}"}
 | ||||
|      *  | ||||
|      * | ||||
|      * // should handle special case with a translated parameter
 | ||||
|      * const spec = {"special": {"type":"image_upload", "label": {"en": "Add a picture to this object", "nl": "Voeg een afbeelding toe"}}} | ||||
|      * const r = RewriteSpecial.convertIfNeeded(spec, [], "test") | ||||
|      * r // => {"en": "{image_upload(,Add a picture to this object)}", "nl": "{image_upload(,Voeg een afbeelding toe)}" }
 | ||||
|      *  | ||||
|      * | ||||
|      * // should handle special case with a prefix and postfix
 | ||||
|      * const spec = {"special": {"type":"image_upload" }, before: {"en": "PREFIX "}, after: {"en": " POSTFIX", nl: " Achtervoegsel"} } | ||||
|      * const r = RewriteSpecial.convertIfNeeded(spec, [], "test") | ||||
|      * r // => {"en": "PREFIX {image_upload(,)} POSTFIX", "nl": "PREFIX {image_upload(,)} Achtervoegsel" }
 | ||||
|      *  | ||||
|      * | ||||
|      * // should warn for unexpected keys
 | ||||
|      * const errors = [] | ||||
|      * RewriteSpecial.convertIfNeeded({"special": {type: "image_carousel"}, "en": "xyz"}, errors, "test") // =>  {'*': "{image_carousel()}"}
 | ||||
|      * errors // => ["At test: Unexpected key in a special block: en"]
 | ||||
|      *  | ||||
|      * | ||||
|      * // should give an error on unknown visualisations
 | ||||
|      * const errors = [] | ||||
|      * RewriteSpecial.convertIfNeeded({"special": {type: "qsdf"}}, errors, "test") // => undefined
 | ||||
|      * errors.length // => 1
 | ||||
|      * errors[0].indexOf("Special visualisation 'qsdf' not found") >= 0 // => true
 | ||||
|      *  | ||||
|      * | ||||
|      * // should give an error is 'type' is missing
 | ||||
|      * const errors = [] | ||||
|      * RewriteSpecial.convertIfNeeded({"special": {}}, errors, "test") // => undefined
 | ||||
|      * errors // => ["A 'special'-block should define 'type' to indicate which visualisation should be used"]
 | ||||
|      */ | ||||
|     private static convertIfNeeded(input: (object & {special : {type: string}}) | any, errors: string[], context: string): any { | ||||
|     private static convertIfNeeded(input: (object & { special: { type: string } }) | any, errors: string[], context: string): any { | ||||
|         const special = input["special"] | ||||
|         if(special === undefined){ | ||||
|         if (special === undefined) { | ||||
|             return input | ||||
|         } | ||||
| 
 | ||||
|  | @ -367,17 +390,17 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | |||
|         } | ||||
| 
 | ||||
|         const type = special["type"] | ||||
|         if(type === undefined){ | ||||
|         if (type === undefined) { | ||||
|             errors.push("A 'special'-block should define 'type' to indicate which visualisation should be used") | ||||
|             return undefined | ||||
|         } | ||||
|         const vis = SpecialVisualizations.specialVisualizations.find(sp => sp.funcName === type) | ||||
|         if(vis === undefined){ | ||||
|         if (vis === undefined) { | ||||
|             const options = Utils.sortedByLevenshteinDistance(type, SpecialVisualizations.specialVisualizations, sp => sp.funcName) | ||||
|             errors.push(`Special visualisation '${type}' not found. Did you perhaps mean ${options[0].funcName}, ${options[1].funcName} or ${options[2].funcName}?\n\tFor all known special visualisations, please see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialRenderings.md`) | ||||
|             return undefined | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const argNamesList = vis.args.map(a => a.name) | ||||
|         const argNames = new Set<string>(argNamesList) | ||||
|         // Check for obsolete and misspelled arguments
 | ||||
|  | @ -385,21 +408,21 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | |||
|             .filter(k => !argNames.has(k)) | ||||
|             .filter(k => k !== "type") | ||||
|             .map(wrongArg => { | ||||
|             const byDistance = Utils.sortedByLevenshteinDistance(wrongArg, argNamesList, x => x) | ||||
|             return `Unexpected argument with name '${wrongArg}'. Did you mean ${byDistance[0]}?\n\tAll known arguments are ${ argNamesList.join(", ")}` ; | ||||
|         })) | ||||
|          | ||||
|                 const byDistance = Utils.sortedByLevenshteinDistance(wrongArg, argNamesList, x => x) | ||||
|                 return `Unexpected argument with name '${wrongArg}'. Did you mean ${byDistance[0]}?\n\tAll known arguments are ${argNamesList.join(", ")}`; | ||||
|             })) | ||||
| 
 | ||||
|         // Check that all obligated arguments are present. They are obligated if they don't have a preset value
 | ||||
|         for (const arg of vis.args) { | ||||
|             if (arg.required !== true) { | ||||
|                 continue; | ||||
|             } | ||||
|             const param = special[arg.name] | ||||
|             if(param === undefined){ | ||||
|             if (param === undefined) { | ||||
|                 errors.push(`Obligated parameter '${arg.name}' not found`) | ||||
|             } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const foundLanguages = new Set<string>() | ||||
|         const translatedArgs = argNamesList.map(nm => special[nm]) | ||||
|             .filter(v => v !== undefined) | ||||
|  | @ -407,25 +430,26 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | |||
|         for (const translatedArg of translatedArgs) { | ||||
|             for (const ln of Object.keys(translatedArg)) { | ||||
|                 foundLanguages.add(ln) | ||||
|             }   | ||||
|             } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const before = Translations.T(input.before) | ||||
|         const after = Translations.T(input.after) | ||||
| 
 | ||||
|         for (const ln of Object.keys(before?.translations??{})) { | ||||
|         for (const ln of Object.keys(before?.translations ?? {})) { | ||||
|             foundLanguages.add(ln) | ||||
|         } | ||||
|         for (const ln of Object.keys(after?.translations??{})) { | ||||
|         for (const ln of Object.keys(after?.translations ?? {})) { | ||||
|             foundLanguages.add(ln) | ||||
|         } | ||||
| 
 | ||||
|         if(foundLanguages.size === 0){ | ||||
|            const args=   argNamesList.map(nm => special[nm] ?? "").join(",") | ||||
|             return {'*': `{${type}(${args})}` | ||||
|         if (foundLanguages.size === 0) { | ||||
|             const args = argNamesList.map(nm => special[nm] ?? "").join(",") | ||||
|             return { | ||||
|                 '*': `{${type}(${args})}` | ||||
|             } | ||||
|         } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const result = {} | ||||
|         const languages = Array.from(foundLanguages) | ||||
|         languages.sort() | ||||
|  | @ -433,9 +457,9 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | |||
|             const args = [] | ||||
|             for (const argName of argNamesList) { | ||||
|                 const v = special[argName] ?? "" | ||||
|                 if(Translations.isProbablyATranslation(v)){ | ||||
|                 if (Translations.isProbablyATranslation(v)) { | ||||
|                     args.push(new Translation(v).textFor(ln)) | ||||
|                 }else{ | ||||
|                 } else { | ||||
|                     args.push(v) | ||||
|                 } | ||||
|             } | ||||
|  | @ -459,7 +483,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | |||
|      * const result = new RewriteSpecial().convert(tr,"test").result | ||||
|      * const expected = {render:  {'*': "{image_carousel(image)}"}, mappings: [{if: "other_image_key", then:  {'*': "{image_carousel(other_image_key)}"}} ]} | ||||
|      * result // => expected
 | ||||
|      *  | ||||
|      * | ||||
|      * const tr = { | ||||
|      *     render: {special: {type: "image_carousel", image_key: "image"}, before: {en: "Some introduction"} }, | ||||
|      * } | ||||
|  | @ -470,20 +494,20 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> { | |||
|     convert(json: TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson; errors?: string[]; warnings?: string[]; information?: string[] } { | ||||
|         const errors = [] | ||||
|         json = Utils.Clone(json) | ||||
|         const paths : {path: string[], type?: any, typeHint?: string}[] = tagrenderingconfigmeta["default"] ?? tagrenderingconfigmeta | ||||
|         const paths: { path: string[], type?: any, typeHint?: string }[] = tagrenderingconfigmeta["default"] ?? tagrenderingconfigmeta | ||||
|         for (const path of paths) { | ||||
|             if(path.typeHint !== "rendered"){ | ||||
|             if (path.typeHint !== "rendered") { | ||||
|                 continue | ||||
|             } | ||||
|             Utils.WalkPath(path.path, json, ((leaf, travelled) => RewriteSpecial.convertIfNeeded(leaf, errors, travelled.join(".")))) | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         return { | ||||
|             result:json, | ||||
|             result: json, | ||||
|             errors | ||||
|         }; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export class PrepareLayer extends Fuse<LayerConfigJson> { | ||||
|  | @ -492,11 +516,11 @@ export class PrepareLayer extends Fuse<LayerConfigJson> { | |||
|             "Fully prepares and expands a layer for the LayerConfig.", | ||||
|             new On("tagRenderings", new Each(new RewriteSpecial())), | ||||
|             new On("tagRenderings", new Concat(new ExpandRewrite()).andThenF(Utils.Flatten)), | ||||
|             new On("tagRenderings", new Concat(new ExpandTagRendering(state))), | ||||
|             new On("tagRenderings", layer => new Concat(new ExpandTagRendering(state, layer))), | ||||
|             new On("mapRendering", new Concat(new ExpandRewrite()).andThenF(Utils.Flatten)), | ||||
|             new On("mapRendering",new Each( new On("icon", new FirstOf(new ExpandTagRendering(state))))), | ||||
|             new On("mapRendering", layer => new Each(new On("icon", new FirstOf(new ExpandTagRendering(state, layer))))), | ||||
|             new SetDefault("titleIcons", ["defaults"]), | ||||
|             new On("titleIcons", new Concat(new ExpandTagRendering(state))) | ||||
|             new On("titleIcons", layer => new Concat(new ExpandTagRendering(state, layer))) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue