import { Bypass, Conversion, DesugaringStep, Each, Fuse, On } from "./Conversion" import { LayerConfigJson } from "../Json/LayerConfigJson" import LayerConfig from "../LayerConfig" import { Utils } from "../../../Utils" import Constants from "../../Constants" import { Translation } from "../../../UI/i18n/Translation" import { LayoutConfigJson } from "../Json/LayoutConfigJson" import LayoutConfig from "../LayoutConfig" import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson" import { TagUtils } from "../../../Logic/Tags/TagUtils" import { ExtractImages } from "./FixImages" import { And } from "../../../Logic/Tags/And" import Translations from "../../../UI/i18n/Translations" import FilterConfigJson from "../Json/FilterConfigJson" import DeleteConfig from "../DeleteConfig" import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" import Validators from "../../../UI/InputElement/Validators" import TagRenderingConfig from "../TagRenderingConfig" import { parse as parse_html } from "node-html-parser" import PresetConfig from "../PresetConfig" import { TagsFilter } from "../../../Logic/Tags/TagsFilter" import { Translatable } from "../Json/Translatable" import { ConversionContext } from "./ConversionContext" class ValidateLanguageCompleteness extends DesugaringStep { private readonly _languages: string[] constructor(...languages: string[]) { super( "Checks that the given object is fully translated in the specified languages", [], "ValidateLanguageCompleteness" ) this._languages = languages ?? ["en"] } convert(obj: LayoutConfig, context: ConversionContext): LayoutConfig { const origLayers = obj.layers obj.layers = [...obj.layers].filter((l) => l["id"] !== "favourite") const translations = Translation.ExtractAllTranslationsFrom(obj) for (const neededLanguage of this._languages) { translations .filter( (t) => t.tr.translations[neededLanguage] === undefined && t.tr.translations["*"] === undefined ) .forEach((missing) => { context .enter(missing.context.split(".")) .err( `The theme ${obj.id} should be translation-complete for ` + neededLanguage + ", but it lacks a translation for " + missing.context + ".\n\tThe known translation is " + missing.tr.textFor("en") ) }) } obj.layers = origLayers return obj } } export class DoesImageExist extends DesugaringStep { private readonly _knownImagePaths: Set private readonly _ignore?: Set private readonly doesPathExist: (path: string) => boolean = undefined constructor( knownImagePaths: Set, checkExistsSync: (path: string) => boolean = undefined, ignore?: Set ) { super("Checks if an image exists", [], "DoesImageExist") this._ignore = ignore this._knownImagePaths = knownImagePaths this.doesPathExist = checkExistsSync } convert(image: string, context: ConversionContext): string { if (this._ignore?.has(image)) { return image } if (image.indexOf("{") >= 0) { context.debug("Ignoring image with { in the path: " + image) return image } if (image === "assets/SocialImage.png") { return image } if (image.match(/[a-z]*/)) { if (Constants.defaultPinIcons.indexOf(image) >= 0) { // This is a builtin img, e.g. 'checkmark' or 'crosshair' return image } } if (image.startsWith("<") && image.endsWith(">")) { // This is probably HTML, you're on your own here return image } if (!this._knownImagePaths.has(image)) { if (this.doesPathExist === undefined) { context.err( `Image with path ${image} not found or not attributed; it is used in ${context}` ) } else if (!this.doesPathExist(image)) { context.err( `Image with path ${image} does not exist.\n Check for typo's and missing directories in the path.` ) } else { context.err( `Image with path ${image} is not attributed (but it exists); execute 'npm run query:licenses' to add the license information and/or run 'npm run generate:licenses' to compile all the license info` ) } } return image } } export class ValidateTheme extends DesugaringStep { /** * The paths where this layer is originally saved. Triggers some extra checks * @private */ private readonly _path?: string private readonly _isBuiltin: boolean //private readonly _sharedTagRenderings: Map private readonly _validateImage: DesugaringStep private readonly _extractImages: ExtractImages = undefined constructor( doesImageExist: DoesImageExist, path: string, isBuiltin: boolean, sharedTagRenderings?: Set ) { super("Doesn't change anything, but emits warnings and errors", [], "ValidateTheme") this._validateImage = doesImageExist this._path = path this._isBuiltin = isBuiltin if (sharedTagRenderings) { this._extractImages = new ExtractImages(this._isBuiltin, sharedTagRenderings) } } convert(json: LayoutConfigJson, context: ConversionContext): LayoutConfigJson { const theme = new LayoutConfig(json, this._isBuiltin) { // Legacy format checks if (this._isBuiltin) { if (json["units"] !== undefined) { context.err( "The theme " + json.id + " has units defined - these should be defined on the layer instead. (Hint: use overrideAll: { '+units': ... }) " ) } if (json["roamingRenderings"] !== undefined) { context.err( "Theme " + json.id + " contains an old 'roamingRenderings'. Use an 'overrideAll' instead" ) } } } if (!json.title) { context.enter("title").err(`The theme ${json.id} does not have a title defined.`) } if (this._isBuiltin && this._extractImages !== undefined) { // Check images: are they local, are the licenses there, is the theme icon square, ... const images = this._extractImages.convert(json, context.inOperation("ValidateTheme")) const remoteImages = images.filter((img) => img.path.indexOf("http") == 0) for (const remoteImage of remoteImages) { context.err( "Found a remote image: " + remoteImage.path + " in theme " + json.id + ", please download it." ) } for (const image of images) { this._validateImage.convert(image.path, context.enters(image.context)) } } try { if (this._isBuiltin) { if (theme.id !== theme.id.toLowerCase()) { context.err("Theme ids should be in lowercase, but it is " + theme.id) } const filename = this._path.substring( this._path.lastIndexOf("/") + 1, this._path.length - 5 ) if (theme.id !== filename) { context.err( "Theme ids should be the same as the name.json, but we got id: " + theme.id + " and filename " + filename + " (" + this._path + ")" ) } this._validateImage.convert(theme.icon, context.enter("icon")) } const dups = Utils.Duplicates(json.layers.map((layer) => layer["id"])) if (dups.length > 0) { context.err( `The theme ${json.id} defines multiple layers with id ${dups.join(", ")}` ) } if (json["mustHaveLanguage"] !== undefined) { new ValidateLanguageCompleteness(...json["mustHaveLanguage"]).convert( theme, context ) } if (!json.hideFromOverview && theme.id !== "personal" && this._isBuiltin) { // The first key in the the title-field must be english, otherwise the title in the loading page will be the different language const targetLanguage = theme.title.SupportedLanguages()[0] if (targetLanguage !== "en") { context.err( `TargetLanguage is not 'en' for public theme ${theme.id}, it is ${targetLanguage}. Move 'en' up in the title of the theme and set it as the first key` ) } // Official, public themes must have a full english translation new ValidateLanguageCompleteness("en").convert(theme, context) } } catch (e) { context.err(e) } if (theme.id !== "personal") { new DetectDuplicatePresets().convert(theme, context) } if (!theme.title) { context.enter("title").err("A theme must have a title") } if (!theme.description) { context.enter("description").err("A theme must have a description") } if (theme.overpassUrl && typeof theme.overpassUrl === "string") { context .enter("overpassUrl") .err("The overpassURL is a string, use a list of strings instead. Wrap it with [ ]") } return json } } export class ValidateThemeAndLayers extends Fuse { constructor( doesImageExist: DoesImageExist, path: string, isBuiltin: boolean, sharedTagRenderings?: Set ) { super( "Validates a theme and the contained layers", new ValidateTheme(doesImageExist, path, isBuiltin, sharedTagRenderings), new On( "layers", new Each( new Bypass( (layer) => Constants.added_by_default.indexOf(layer.id) < 0, new ValidateLayerConfig(undefined, isBuiltin, doesImageExist, false, true) ) ) ) ) } } class OverrideShadowingCheck extends DesugaringStep { constructor() { super( "Checks that an 'overrideAll' does not override a single override", [], "OverrideShadowingCheck" ) } convert(json: LayoutConfigJson, context: ConversionContext): LayoutConfigJson { const overrideAll = json.overrideAll if (overrideAll === undefined) { return json } const withOverride = json.layers.filter((l) => l["override"] !== undefined) for (const layer of withOverride) { for (const key in overrideAll) { if (key.endsWith("+") || key.startsWith("+")) { // This key will _add_ to the list, not overwrite it - so no warning is needed continue } if ( layer["override"][key] !== undefined || layer["override"]["=" + key] !== undefined ) { const w = "The override of layer " + JSON.stringify(layer["builtin"]) + " has a shadowed property: " + key + " is overriden by overrideAll of the theme" context.err(w) } } } return json } } class MiscThemeChecks extends DesugaringStep { constructor() { super("Miscelleanous checks on the theme", [], "MiscThemesChecks") } convert(json: LayoutConfigJson, context: ConversionContext): LayoutConfigJson { if (json.id !== "personal" && (json.layers === undefined || json.layers.length === 0)) { context.err("The theme " + json.id + " has no 'layers' defined") } if (json.socialImage === "") { context.warn("Social image for theme " + json.id + " is the emtpy string") } return json } } export class PrevalidateTheme extends Fuse { constructor() { super( "Various consistency checks on the raw JSON", new MiscThemeChecks(), new OverrideShadowingCheck() ) } } export class DetectConflictingAddExtraTags extends DesugaringStep { constructor() { super( "The `if`-part in a mapping might set some keys. Those keys are not allowed to be set in the `addExtraTags`, as this might result in conflicting values", [], "DetectConflictingAddExtraTags" ) } convert(json: TagRenderingConfigJson, context: ConversionContext): TagRenderingConfigJson { if (!(json.mappings?.length > 0)) { return json } try { const tagRendering = new TagRenderingConfig(json) for (let i = 0; i < tagRendering.mappings.length; i++) { const mapping = tagRendering.mappings[i] if (!mapping.addExtraTags) { continue } const keysInMapping = new Set(mapping.if.usedKeys()) const keysInAddExtraTags = mapping.addExtraTags.map((t) => t.key) const duplicateKeys = keysInAddExtraTags.filter((k) => keysInMapping.has(k)) if (duplicateKeys.length > 0) { context .enters("mappings", i) .err( "AddExtraTags overrides a key that is set in the `if`-clause of this mapping. Selecting this answer might thus first set one value (needed to match as answer) and then override it with a different value, resulting in an unsaveable question. The offending `addExtraTags` is " + duplicateKeys.join(", ") ) } } return json } catch (e) { context.err(e) return undefined } } } export class DetectNonErasedKeysInMappings extends DesugaringStep { constructor() { super( "A tagRendering might set a freeform key (e.g. `name` and have an option that _should_ erase this name, e.g. `noname=yes`). Under normal circumstances, every mapping/freeform should affect all touched keys", [], "DetectNonErasedKeysInMappings" ) } convert( json: QuestionableTagRenderingConfigJson, context: ConversionContext ): QuestionableTagRenderingConfigJson { if (json.multiAnswer) { // No need to check this here, this has its own validation return json } if (!json.question) { // No need to check the writable tags, as this cannot write return json } function addAll(keys: { forEach: (f: (s: string) => void) => void }, addTo: Set) { keys?.forEach((k) => addTo.add(k)) } const freeformKeys: Set = new Set() if (json.freeform) { freeformKeys.add(json.freeform.key) for (const tag of json.freeform.addExtraTags ?? []) { const tagParsed = TagUtils.Tag(tag) addAll(tagParsed.usedKeys(), freeformKeys) } } const mappingKeys: Set[] = [] for (const mapping of json.mappings ?? []) { if (mapping.hideInAnswer === true) { mappingKeys.push(undefined) continue } const thisMappingKeys: Set = new Set() addAll(TagUtils.Tag(mapping.if).usedKeys(), thisMappingKeys) for (const tag of mapping.addExtraTags ?? []) { addAll(TagUtils.Tag(tag).usedKeys(), thisMappingKeys) } mappingKeys.push(thisMappingKeys) } const neededKeys = new Set() addAll(freeformKeys, neededKeys) for (const mappingKey of mappingKeys) { addAll(mappingKey, neededKeys) } neededKeys.delete("fixme") // fixme gets a free pass if (json.freeform) { for (const neededKey of neededKeys) { if (!freeformKeys.has(neededKey)) { context .enters("freeform") .warn( "The freeform block does not modify the key `" + neededKey + "` which is set in a mapping. Use `addExtraTags` to overwrite it" ) } } } for (let i = 0; i < json.mappings?.length; i++) { const mapping = json.mappings[i] if (mapping.hideInAnswer === true) { continue } const keys = mappingKeys[i] for (const neededKey of neededKeys) { if (!keys.has(neededKey)) { context .enters("mappings", i) .warn( "This mapping does not modify the key `" + neededKey + "` which is set in a mapping or by the freeform block. Use `addExtraTags` to overwrite it" ) } } } return json } } export class DetectShadowedMappings extends DesugaringStep { private readonly _calculatedTagNames: string[] constructor(layerConfig?: LayerConfigJson) { super("Checks that the mappings don't shadow each other", [], "DetectShadowedMappings") this._calculatedTagNames = DetectShadowedMappings.extractCalculatedTagNames(layerConfig) } /** * * DetectShadowedMappings.extractCalculatedTagNames({calculatedTags: ["_abc:=js()"]}) // => ["_abc"] * DetectShadowedMappings.extractCalculatedTagNames({calculatedTags: ["_abc=js()"]}) // => ["_abc"] */ private static extractCalculatedTagNames( layerConfig?: LayerConfigJson | { calculatedTags: string[] } ) { return ( layerConfig?.calculatedTags?.map((ct) => { if (ct.indexOf(":=") >= 0) { return ct.split(":=")[0] } return ct.split("=")[0] }) ?? [] ) } /** * * // should detect a simple shadowed mapping * const tr = {mappings: [ * { * if: {or: ["key=value", "x=y"]}, * then: "Case A" * }, * { * if: "key=value", * then: "Shadowed" * } * ] * } * const context = ConversionContext.test() * const r = new DetectShadowedMappings().convert(tr, context); * context.getAll("error").length // => 1 * context.getAll("error")[0].message.indexOf("The mapping key=value is fully matched by a previous mapping (namely 0)") >= 0 // => true * * const tr = {mappings: [ * { * if: {or: ["key=value", "x=y"]}, * then: "Case A" * }, * { * if: {and: ["key=value", "x=y"]}, * then: "Shadowed" * } * ] * } * const context = ConversionContext.test() * const r = new DetectShadowedMappings().convert(tr, context); * context.getAll("error").length // => 1 * context.getAll("error")[0].message.indexOf("The mapping key=value & x=y is fully matched by a previous mapping (namely 0)") >= 0 // => true */ convert(json: TagRenderingConfigJson, context: ConversionContext): TagRenderingConfigJson { if (json.mappings === undefined || json.mappings.length === 0) { return json } const defaultProperties = {} for (const calculatedTagName of this._calculatedTagNames) { defaultProperties[calculatedTagName] = "some_calculated_tag_value_for_" + calculatedTagName } const parsedConditions = json.mappings.map((m, i) => { const c = context.enters("mappings", i) const ifTags = TagUtils.Tag(m.if, c.enter("if")) const hideInAnswer = m["hideInAnswer"] if (hideInAnswer !== undefined && hideInAnswer !== false && hideInAnswer !== true) { let conditionTags = TagUtils.Tag(hideInAnswer) // Merge the condition too! return new And([conditionTags, ifTags]) } return ifTags }) for (let i = 0; i < json.mappings.length; i++) { if (!parsedConditions[i]?.isUsableAsAnswer()) { // There is no straightforward way to convert this mapping.if into a properties-object, so we simply skip this one // Yes, it might be shadowed, but running this check is to difficult right now continue } const keyValues = parsedConditions[i].asChange(defaultProperties) const properties = {} keyValues.forEach(({ k, v }) => { properties[k] = v }) for (let j = 0; j < i; j++) { const doesMatch = parsedConditions[j].matchesProperties(properties) if ( doesMatch && json.mappings[j]["hideInAnswer"] === true && json.mappings[i]["hideInAnswer"] !== true ) { context.warn( `Mapping ${i} is shadowed by mapping ${j}. However, mapping ${j} has 'hideInAnswer' set, which will result in a different rendering in question-mode.` ) } else if (doesMatch) { // The current mapping is shadowed! context.err(`Mapping ${i} is shadowed by mapping ${j} and will thus never be shown: The mapping ${parsedConditions[i].asHumanString( false, false, {} )} is fully matched by a previous mapping (namely ${j}), which matches: ${parsedConditions[j].asHumanString(false, false, {})}. To fix this problem, you can try to: - Move the shadowed mapping up - Do you want to use a different text in 'question mode'? Add 'hideInAnswer=true' to the first mapping - Use "addExtraTags": ["key=value", ...] in order to avoid a different rendering (e.g. [{"if": "fee=no", "then": "Free to use", "hideInAnswer":true}, {"if": {"and":["fee=no","charge="]}, "then": "Free to use"}] can be replaced by [{"if":"fee=no", "then": "Free to use", "addExtraTags": ["charge="]}] `) } } } return json } } export class DetectMappingsWithImages extends DesugaringStep { private readonly _doesImageExist: DoesImageExist constructor(doesImageExist: DoesImageExist) { super( "Checks that 'then'clauses in mappings don't have images, but use 'icon' instead", [], "DetectMappingsWithImages" ) this._doesImageExist = doesImageExist } /** * const context = ConversionContext.test() * const r = new DetectMappingsWithImages(new DoesImageExist(new Set())).convert({ * "mappings": [ * { * "if": "bicycle_parking=stands", * "then": { * "en": "Staple racks ", * "nl": "Nietjes ", * "fr": "Arceaux ", * "gl": "De roda (Stands) ", * "de": "Fahrradbügel ", * "hu": "Korlát ", * "it": "Archetti ", * "zh_Hant": "單車架 " * } * }] * }, context); * context.hasErrors() // => true * context.getAll("error").some(msg => msg.message.indexOf("./assets/layers/bike_parking/staple.svg") >= 0) // => true */ convert(json: TagRenderingConfigJson, context: ConversionContext): TagRenderingConfigJson { if (json.mappings === undefined || json.mappings.length === 0) { return json } const ignoreToken = "ignore-image-in-then" for (let i = 0; i < json.mappings.length; i++) { const mapping = json.mappings[i] const ignore = mapping["#"]?.indexOf(ignoreToken) >= 0 const images = Utils.Dedup(Translations.T(mapping.then)?.ExtractImages() ?? []) const ctx = context.enters("mappings", i) if (images.length > 0) { if (!ignore) { ctx.err( `A mapping has an image in the 'then'-clause. Remove the image there and use \`"icon": \` instead. The images found are ${images.join( ", " )}. (This check can be turned of by adding "#": "${ignoreToken}" in the mapping, but this is discouraged` ) } else { ctx.info( `Ignored image ${images.join( ", " )} in 'then'-clause of a mapping as this check has been disabled` ) for (const image of images) { this._doesImageExist.convert(image, ctx) } } } else if (ignore) { ctx.warn(`Unused '${ignoreToken}' - please remove this`) } } return json } } class ValidatePossibleLinks extends DesugaringStep> { constructor() { super( "Given a possible set of translations, validates that does have `rel='noopener'` set", [], "ValidatePossibleLinks" ) } public isTabnabbingProne(str: string): boolean { const p = parse_html(str) const links = Array.from(p.getElementsByTagName("a")) if (links.length == 0) { return false } for (const link of Array.from(links)) { if (link.getAttribute("target") !== "_blank") { continue } const rel = new Set(link.getAttribute("rel")?.split(" ") ?? []) if (rel.has("noopener")) { continue } const source = link.getAttribute("href") if (source.startsWith("http")) { // No variable part - we assume the link is safe continue } return true } return false } convert( json: string | Record, context: ConversionContext ): string | Record { if (typeof json === "string") { if (this.isTabnabbingProne(json)) { context.err( "The string " + json + " has a link targeting `_blank`, but it doesn't have `rel='noopener'` set. This gives rise to reverse tabnapping" ) } } else { for (const k in json) { if (this.isTabnabbingProne(json[k])) { context.err( `The translation for ${k} '${json[k]}' has a link targeting \`_blank\`, but it doesn't have \`rel='noopener'\` set. This gives rise to reverse tabnapping` ) } } } return json } } class CheckTranslation extends DesugaringStep { public static readonly allowUndefined: CheckTranslation = new CheckTranslation(true) public static readonly noUndefined: CheckTranslation = new CheckTranslation() private readonly _allowUndefined: boolean constructor(allowUndefined: boolean = false) { super( "Checks that a translation is valid and internally consistent", ["*"], "CheckTranslation" ) this._allowUndefined = allowUndefined } convert(json: Translatable, context: ConversionContext): Translatable { if (json === undefined || json === null) { if (!this._allowUndefined) { context.err("Expected a translation, but got " + json) } return json } if (typeof json === "string") { return json } const keys = Object.keys(json) if (keys.length === 0) { context.err("No actual values are given in this translation, it is completely empty") return json } const en = json["en"] if (!en && json["*"] === undefined) { const msg = "Received a translation without english version" context.warn(msg) } for (const key of keys) { const lng = json[key] if (lng === "") { context.enter(lng).err("Got an empty string in translation for language " + lng) } // TODO validate that all subparts are here } return json } } class MiscTagRenderingChecks extends DesugaringStep { constructor() { super("Miscellaneous checks on the tagrendering", ["special"], "MiscTagRenderingChecks") } convert( json: TagRenderingConfigJson | QuestionableTagRenderingConfigJson, context: ConversionContext ): TagRenderingConfigJson { if (json["special"] !== undefined) { context.err( 'Detected `special` on the top level. Did you mean `{"render":{ "special": ... }}`' ) } if (Object.keys(json).length === 1 && typeof json["render"] === "string") { context.warn( `use the content directly instead of {render: ${JSON.stringify(json["render"])}}` ) } { for (const key of ["question", "questionHint", "render"]) { CheckTranslation.allowUndefined.convert(json[key], context.enter(key)) } for (let i = 0; i < json.mappings?.length ?? 0; i++) { const mapping = json.mappings[i] CheckTranslation.noUndefined.convert( mapping.then, context.enters("mappings", i, "then") ) if (!mapping.if) { context.enters("mappings", i).err("No `if` is defined") } const en = mapping?.then?.["en"] if (en && this.detectYesOrNo(en)) { console.log("Found a match with yes or no: ", { en }) context .enters("mappings", i, "then") .warn( "A mapping should not start with 'yes' or 'no'. If the attribute is known, it will only show 'yes' or 'no' without the question, resulting in a weird phrasing in the information box" ) } } } if (json["group"]) { context.err('Groups are deprecated, use `"label": ["' + json["group"] + '"]` instead') } if (json["question"] && json.freeform?.key === undefined && json.mappings === undefined) { context.err( "A question is defined, but no mappings nor freeform (key) are. Add at least one of them" ) } if (json["question"] && !json.freeform && (json.mappings?.length ?? 0) == 1) { context.err("A question is defined, but there is only one option to choose from.") } if (json["questionHint"] && !json["question"]) { context .enter("questionHint") .err( "A questionHint is defined, but no question is given. As such, the questionHint will never be shown" ) } if (json.freeform) { if (json.render === undefined) { context .enter("render") .err( "This tagRendering allows to set a value to key " + json.freeform.key + ", but does not define a `render`. Please, add a value here which contains `{" + json.freeform.key + "}`" ) } else { const render = new Translation(json.render) for (const ln in render.translations) { if (ln.startsWith("_")) { continue } const txt: string = render.translations[ln] if (txt === "") { context.enter("render").err(" Rendering for language " + ln + " is empty") } if ( txt.indexOf("{" + json.freeform.key + "}") >= 0 || txt.indexOf("&LBRACE" + json.freeform.key + "&RBRACE") >= 0 ) { continue } if (txt.indexOf("{" + json.freeform.key + ":") >= 0) { continue } if ( json.freeform["type"] === "opening_hours" && txt.indexOf("{opening_hours_table(") >= 0 ) { continue } const keyFirstArg = ["canonical", "fediverse_link", "translated"] if ( keyFirstArg.some( (funcName) => txt.indexOf(`{${funcName}(${json.freeform.key}`) >= 0 ) ) { continue } if ( json.freeform["type"] === "wikidata" && txt.indexOf("{wikipedia(" + json.freeform.key) >= 0 ) { continue } if (json.freeform.key === "wikidata" && txt.indexOf("{wikipedia()") >= 0) { continue } if ( json.freeform["type"] === "wikidata" && txt.indexOf(`{wikidata_label(${json.freeform.key})`) >= 0 ) { continue } context .enter("render") .err( `The rendering for language ${ln} does not contain \`{${json.freeform.key}}\`. This is a bug, as this rendering should show exactly this freeform key!` ) } } } if (json.render && json["question"] && json.freeform === undefined) { context.err( `Detected a tagrendering which takes input without freeform key in ${context}; the question is ${new Translation( json["question"] ).textFor("en")}` ) } const freeformType = json["freeform"]?.["type"] if (freeformType) { if (Validators.availableTypes.indexOf(freeformType) < 0) { context .enters("freeform", "type") .err( "Unknown type: " + freeformType + "; try one of " + Validators.availableTypes.join(", ") ) } } return json } /** * const obj = new MiscTagRenderingChecks() * obj.detectYesOrNo("Yes, this place has") // => true * obj.detectYesOrNo("Yes") // => true * obj.detectYesOrNo("No, this place does not have...") // => true * obj.detectYesOrNo("This place does not have...") // => false */ private detectYesOrNo(en: string): boolean { return en.toLowerCase().match(/^(yes|no)([,:;.?]|$)/) !== null } } export class ValidateTagRenderings extends Fuse { constructor(layerConfig?: LayerConfigJson, doesImageExist?: DoesImageExist) { super( "Various validation on tagRenderingConfigs", new DetectShadowedMappings(layerConfig), new DetectConflictingAddExtraTags(), // TODO enable new DetectNonErasedKeysInMappings(), new DetectMappingsWithImages(doesImageExist), new On("render", new ValidatePossibleLinks()), new On("question", new ValidatePossibleLinks()), new On("questionHint", new ValidatePossibleLinks()), new On("mappings", new Each(new On("then", new ValidatePossibleLinks()))), new MiscTagRenderingChecks() ) } } export class PrevalidateLayer extends DesugaringStep { private readonly _isBuiltin: boolean private readonly _doesImageExist: DoesImageExist /** * The paths where this layer is originally saved. Triggers some extra checks */ private readonly _path: string private readonly _studioValidations: boolean constructor(path: string, isBuiltin, doesImageExist, studioValidations) { super("Runs various checks against common mistakes for a layer", [], "PrevalidateLayer") this._path = path this._isBuiltin = isBuiltin this._doesImageExist = doesImageExist this._studioValidations = studioValidations } convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson { if (json.id === undefined) { context.enter("id").err(`Not a valid layer: id is undefined`) } else { if (json.id?.toLowerCase() !== json.id) { context.enter("id").err(`The id of a layer should be lowercase: ${json.id}`) } if (json.id?.match(/[a-z0-9-_]/) == null) { context.enter("id").err(`The id of a layer should match [a-z0-9-_]*: ${json.id}`) } } if (json.source === undefined) { context .enter("source") .err( "No source section is defined; please define one as data is not loaded otherwise" ) } else { if (json.source === "special" || json.source === "special:library") { } else if (json.source && json.source["osmTags"] === undefined) { context .enters("source", "osmTags") .err( "No osmTags defined in the source section - these should always be present, even for geojson layer" ) } else { const osmTags = TagUtils.Tag(json.source["osmTags"], context + "source.osmTags") if (osmTags.isNegative()) { context .enters("source", "osmTags") .err( "The source states tags which give a very wide selection: it only uses negative expressions, which will result in too much and unexpected data. Add at least one required tag. The tags are:\n\t" + osmTags.asHumanString(false, false, {}) ) } } if (json.source["geoJsonSource"] !== undefined) { context .enters("source", "geoJsonSource") .err("Use 'geoJson' instead of 'geoJsonSource'") } if (json.source["geojson"] !== undefined) { context .enters("source", "geojson") .err("Use 'geoJson' instead of 'geojson' (the J is a capital letter)") } } if ( json.syncSelection !== undefined && LayerConfig.syncSelectionAllowed.indexOf(json.syncSelection) < 0 ) { context .enter("syncSelection") .err( "Invalid sync-selection: must be one of " + LayerConfig.syncSelectionAllowed.map((v) => `'${v}'`).join(", ") + " but got '" + json.syncSelection + "'" ) } if (json["pointRenderings"]?.length > 0) { context .enter("pointRenderings") .err("Detected a 'pointRenderingS', it is written singular") } if ( !(json.pointRendering?.length > 0) && json.pointRendering !== null && json.source !== "special" && json.source !== "special:library" ) { context.enter("pointRendering").err("There are no pointRenderings at all...") } if (json["mapRendering"]) { context.enter("mapRendering").err("This layer has a legacy 'mapRendering'") } if (json.presets?.length > 0) { if (!(json.pointRendering?.length > 0)) { context.enter("presets").warn("A preset is defined, but there is no pointRendering") } } if (json.source === "special") { if (!Constants.priviliged_layers.find((x) => x == json.id)) { context.err( "Layer " + json.id + " uses 'special' as source.osmTags. However, this layer is not a priviliged layer" ) } } if (context.hasErrors()) { return undefined } if (json.tagRenderings !== undefined && json.tagRenderings.length > 0) { new On("tagRendering", new Each(new ValidateTagRenderings(json))) if (json.title === undefined && json.source !== "special:library") { context .enter("title") .err( "This layer does not have a title defined but it does have tagRenderings. Not having a title will disable the popups, resulting in an unclickable element. Please add a title. If not having a popup is intended and the tagrenderings need to be kept (e.g. in a library layer), set `title: null` to disable this error." ) } if (json.title === null) { context.info( "Title is `null`. This results in an element that cannot be clicked - even though tagRenderings is set." ) } { // Check for multiple, identical builtin questions - usability for studio users const duplicates = Utils.Duplicates( json.tagRenderings.filter((tr) => typeof tr === "string") ) for (let i = 0; i < json.tagRenderings.length; i++) { const tagRendering = json.tagRenderings[i] if (typeof tagRendering === "string" && duplicates.indexOf(tagRendering) > 0) { context .enters("tagRenderings", i) .err(`This builtin question is used multiple times (${tagRendering})`) } } } } if (json["builtin"] !== undefined) { context.err("This layer hasn't been expanded: " + json) return null } if (json.minzoom > Constants.minZoomLevelToAddNewPoint) { const c = context.enter("minzoom") const msg = `Minzoom is ${json.minzoom}, this should be at most ${Constants.minZoomLevelToAddNewPoint} as a preset is set. Why? Selecting the pin for a new item will zoom in to level before adding the point. Having a greater minzoom will hide the points, resulting in possible duplicates` if (json.presets?.length > 0) { c.err(msg) } else { c.warn(msg) } } { // duplicate ids in tagrenderings check const duplicates = Utils.NoNull( Utils.Duplicates(Utils.NoNull((json.tagRenderings ?? []).map((tr) => tr["id"]))) ) if (duplicates.length > 0) { // It is tempting to add an index to this warning; however, due to labels the indices here might be different from the index in the tagRendering list context .enter("tagRenderings") .err("Some tagrenderings have a duplicate id: " + duplicates.join(", ")) } } if (json.deletion !== undefined && json.deletion instanceof DeleteConfig) { if (json.deletion.softDeletionTags === undefined) { context .enter("deletion") .warn("No soft-deletion tags in deletion block for layer " + json.id) } } try { } catch (e) { context.err("Could not validate layer due to: " + e + e.stack) } if (this._studioValidations) { if (!json.description) { context.enter("description").err("A description is required") } if (!json.name) { context.enter("name").err("A name is required") } } if (this._isBuiltin) { // Some checks for legacy elements if (json["overpassTags"] !== undefined) { context.err( "Layer " + json.id + 'still uses the old \'overpassTags\'-format. Please use "source": {"osmTags": }\' instead of "overpassTags": (note: this isn\'t your fault, the custom theme generator still spits out the old format)' ) } const forbiddenTopLevel = [ "icon", "wayHandling", "roamingRenderings", "roamingRendering", "label", "width", "color", "colour", "iconOverlays", ] for (const forbiddenKey of forbiddenTopLevel) { if (json[forbiddenKey] !== undefined) context.err("Layer " + json.id + " still has a forbidden key " + forbiddenKey) } if (json["hideUnderlayingFeaturesMinPercentage"] !== undefined) { context.err( "Layer " + json.id + " contains an old 'hideUnderlayingFeaturesMinPercentage'" ) } if ( json.isShown !== undefined && (json.isShown["render"] !== undefined || json.isShown["mappings"] !== undefined) ) { context.warn("Has a tagRendering as `isShown`") } } if (this._isBuiltin) { // Check location of layer file const expected: string = `assets/layers/${json.id}/${json.id}.json` if (this._path != undefined && this._path.indexOf(expected) < 0) { context.err( "Layer is in an incorrect place. The path is " + this._path + ", but expected " + expected ) } } if (this._isBuiltin) { // Check for correct IDs if (json.tagRenderings?.some((tr) => tr["id"] === "")) { const emptyIndexes: number[] = [] for (let i = 0; i < json.tagRenderings.length; i++) { const tagRendering = json.tagRenderings[i] if (tagRendering["id"] === "") { emptyIndexes.push(i) } } context .enter(["tagRenderings", ...emptyIndexes]) .err( `Some tagrendering-ids are empty or have an emtpy string; this is not allowed (at ${emptyIndexes.join( "," )}])` ) } const duplicateIds = Utils.Duplicates( (json.tagRenderings ?? [])?.map((f) => f["id"]).filter((id) => id !== "questions") ) if (duplicateIds.length > 0 && !Utils.runningFromConsole) { context .enter("tagRenderings") .err(`Some tagRenderings have a duplicate id: ${duplicateIds}`) } if (json.description === undefined) { if (typeof json.source === null) { context.err("A priviliged layer must have a description") } else { context.warn("A builtin layer should have a description") } } } if (json.filter) { new On("filter", new Each(new ValidateFilter())).convert(json, context) } if (json.tagRenderings !== undefined) { new On( "tagRenderings", new Each(new ValidateTagRenderings(json, this._doesImageExist)) ).convert(json, context) } if (json.pointRendering !== null && json.pointRendering !== undefined) { if (!Array.isArray(json.pointRendering)) { throw ( "pointRendering in " + json.id + " is not iterable, it is: " + typeof json.pointRendering ) } for (let i = 0; i < json.pointRendering.length; i++) { const pointRendering = json.pointRendering[i] if (pointRendering.marker === undefined) { continue } for (const icon of pointRendering?.marker) { const indexM = pointRendering?.marker.indexOf(icon) if (!icon.icon) { continue } if (icon.icon["condition"]) { context .enters("pointRendering", i, "marker", indexM, "icon", "condition") .err( "Don't set a condition in a marker as this will result in an invisible but clickable element. Use extra filters in the source instead." ) } } } } if (json.presets !== undefined) { if (typeof json.source === "string") { context.enter("presets").err("A special layer cannot have presets") } // Check that a preset will be picked up by the layer itself const baseTags = TagUtils.Tag(json.source["osmTags"]) for (let i = 0; i < json.presets.length; i++) { const preset = json.presets[i] if (!preset) { context.enters("presets", i).err("This preset is undefined") continue } if (!preset.tags) { context.enters("presets", i, "tags").err("No tags defined for this preset") continue } if (!preset.tags) { context.enters("presets", i, "title").err("No title defined for this preset") } const tags = new And(preset.tags.map((t) => TagUtils.Tag(t))) const properties = {} for (const tag of tags.asChange({ id: "node/-1" })) { properties[tag.k] = tag.v } const doMatch = baseTags.matchesProperties(properties) if (!doMatch) { context .enters("presets", i, "tags") .err( "This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " + tags.asHumanString(false, false, {}) + "\n The required tags are: " + baseTags.asHumanString(false, false, {}) ) } } } return json } } export class ValidateLayerConfig extends DesugaringStep { private readonly validator: ValidateLayer constructor( path: string, isBuiltin: boolean, doesImageExist: DoesImageExist, studioValidations: boolean = false, skipDefaultLayers: boolean = false ) { super("Thin wrapper around 'ValidateLayer", [], "ValidateLayerConfig") this.validator = new ValidateLayer( path, isBuiltin, doesImageExist, studioValidations, skipDefaultLayers ) } convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson { const prepared = this.validator.convert(json, context) if (!prepared) { context.err("Preparing layer failed") return undefined } return prepared?.raw } } export class ValidateLayer extends Conversion< LayerConfigJson, { parsed: LayerConfig; raw: LayerConfigJson } > { private readonly _skipDefaultLayers: boolean private readonly _prevalidation: PrevalidateLayer constructor( path: string, isBuiltin: boolean, doesImageExist: DoesImageExist, studioValidations: boolean = false, skipDefaultLayers: boolean = false ) { super("Doesn't change anything, but emits warnings and errors", [], "ValidateLayer") this._prevalidation = new PrevalidateLayer( path, isBuiltin, doesImageExist, studioValidations ) this._skipDefaultLayers = skipDefaultLayers } convert( json: LayerConfigJson, context: ConversionContext ): { parsed: LayerConfig; raw: LayerConfigJson } { context = context.inOperation(this.name) if (typeof json === "string") { context.err( `Not a valid layer: the layerConfig is a string. 'npm run generate:layeroverview' might be needed` ) return undefined } if (this._skipDefaultLayers && Constants.added_by_default.indexOf(json.id) >= 0) { return { parsed: undefined, raw: json } } this._prevalidation.convert(json, context.inOperation(this._prevalidation.name)) if (context.hasErrors()) { return undefined } let layerConfig: LayerConfig try { layerConfig = new LayerConfig(json, "validation", true) } catch (e) { context.err("Could not parse layer due to:" + e) return undefined } for (let i = 0; i < (layerConfig.calculatedTags ?? []).length; i++) { const [_, code, __] = layerConfig.calculatedTags[i] try { new Function("feat", "return " + code + ";") } catch (e) { context .enters("calculatedTags", i) .err( `Invalid function definition: the custom javascript is invalid:${e}. The offending javascript code is:\n ${code}` ) } } return { raw: json, parsed: layerConfig } } } export class ValidateFilter extends DesugaringStep { constructor() { super("Detect common errors in the filters", [], "ValidateFilter") } convert(filter: FilterConfigJson, context: ConversionContext): FilterConfigJson { if (typeof filter === "string") { // Calling another filter, we skip return filter } for (const option of filter.options) { for (let i = 0; i < option.fields?.length ?? 0; i++) { const field = option.fields[i] const type = field.type ?? "string" if (Validators.availableTypes.find((t) => t === type) === undefined) { context .enters("fields", i) .err( `Invalid filter: ${type} is not a valid textfield type.\n\tTry one of ${Array.from( Validators.availableTypes ).join(",")}` ) } } } return filter } } export class DetectDuplicateFilters extends DesugaringStep<{ layers: LayerConfigJson[] themes: LayoutConfigJson[] }> { constructor() { super( "Tries to detect layers where a shared filter can be used (or where similar filters occur)", [], "DetectDuplicateFilters" ) } convert( json: { layers: LayerConfigJson[]; themes: LayoutConfigJson[] }, context: ConversionContext ): { layers: LayerConfigJson[]; themes: LayoutConfigJson[] } { const { layers, themes } = json const perOsmTag = new Map< string, { layer: LayerConfigJson layout: LayoutConfigJson | undefined filter: FilterConfigJson }[] >() for (const layer of layers) { this.addLayerFilters(layer, perOsmTag) } for (const theme of themes) { if (theme.id === "personal") { continue } for (const layer of theme.layers) { if (typeof layer === "string") { continue } if (layer["builtin"] !== undefined) { continue } this.addLayerFilters(layer, perOsmTag, theme) } } // At this point, we have gathered all filters per tag - time to find duplicates perOsmTag.forEach((value, key) => { if (value.length <= 1) { // Seen this key just once, it is unique return } let msg = "Possible duplicate filter: " + key for (const { filter, layer, layout } of value) { let id = "" if (layout !== undefined) { id = layout.id + ":" } msg += `\n - ${id}${layer.id}.${filter.id}` } context.warn(msg) }) return json } /** * Add all filter options into 'perOsmTag' */ private addLayerFilters( layer: LayerConfigJson, perOsmTag: Map< string, { layer: LayerConfigJson layout: LayoutConfigJson | undefined filter: FilterConfigJson }[] >, layout?: LayoutConfigJson | undefined ): void { if (layer.filter === undefined || layer.filter === null) { return } if (layer.filter["sameAs"] !== undefined) { return } for (const filter of <(string | FilterConfigJson)[]>layer.filter) { if (typeof filter === "string") { continue } if (filter["#"]?.indexOf("ignore-possible-duplicate") >= 0) { continue } for (const option of filter.options) { if (option.osmTags === undefined) { continue } const key = JSON.stringify(option.osmTags) if (!perOsmTag.has(key)) { perOsmTag.set(key, []) } perOsmTag.get(key).push({ layer, filter, layout, }) } } } } export class DetectDuplicatePresets extends DesugaringStep { constructor() { super( "Detects mappings which have identical (english) names or identical mappings.", ["presets"], "DetectDuplicatePresets" ) } convert(json: LayoutConfig, context: ConversionContext): LayoutConfig { const presets: PresetConfig[] = [].concat(...json.layers.map((l) => l.presets)) const enNames = presets.map((p) => p.title.textFor("en")) if (new Set(enNames).size != enNames.length) { const dups = Utils.Duplicates(enNames) const layersWithDup = json.layers.filter((l) => l.presets.some((p) => dups.indexOf(p.title.textFor("en")) >= 0) ) const layerIds = layersWithDup.map((l) => l.id) context.err( `This themes has multiple presets which are named:${dups}, namely layers ${layerIds.join( ", " )} this is confusing for contributors and is probably the result of reusing the same layer multiple times. Use \`{"override": {"=presets": []}}\` to remove some presets` ) } const optimizedTags = presets.map((p) => new And(p.tags).optimize()) for (let i = 0; i < presets.length; i++) { const presetATags = optimizedTags[i] const presetA = presets[i] for (let j = i + 1; j < presets.length; j++) { const presetBTags = optimizedTags[j] const presetB = presets[j] if ( Utils.SameObject(presetATags, presetBTags) && Utils.sameList( presetA.preciseInput.snapToLayers, presetB.preciseInput.snapToLayers ) ) { context.err( `This themes has multiple presets with the same tags: ${presetATags.asHumanString( false, false, {} )}, namely the preset '${presets[i].title.textFor("en")}' and '${presets[ j ].title.textFor("en")}'` ) } } } return json } }