1003 lines
39 KiB
TypeScript
1003 lines
39 KiB
TypeScript
import { Concat, DesugaringContext, DesugaringStep, Each, FirstOf, Fuse, On, SetDefault } from "./Conversion"
|
|
import { LayerConfigJson } from "../Json/LayerConfigJson"
|
|
import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
|
|
import { Utils } from "../../../Utils"
|
|
import RewritableConfigJson from "../Json/RewritableConfigJson"
|
|
import SpecialVisualizations from "../../../UI/SpecialVisualizations"
|
|
import Translations from "../../../UI/i18n/Translations"
|
|
import { Translation } from "../../../UI/i18n/Translation"
|
|
import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json"
|
|
import FilterConfigJson from "../Json/FilterConfigJson"
|
|
import { TagConfigJson } from "../Json/TagConfigJson"
|
|
import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson"
|
|
import ValidationUtils from "./ValidationUtils"
|
|
import { RenderingSpecification } from "../../../UI/SpecialVisualization"
|
|
import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
|
|
import { ConfigMeta } from "../../../UI/Studio/configMeta"
|
|
import LineRenderingConfigJson from "../Json/LineRenderingConfigJson"
|
|
import { ConversionContext } from "./ConversionContext"
|
|
import { ExpandRewrite } from "./ExpandRewrite"
|
|
import { TagUtils } from "../../../Logic/Tags/TagUtils"
|
|
import { ExpandFilter, PruneFilters } from "./ExpandFilter"
|
|
import { ExpandTagRendering } from "./ExpandTagRendering"
|
|
|
|
class AddFiltersFromTagRenderings extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
'Inspects all the tagRenderings. If some tagRenderings have the `filter` attribute set, introduce those filters. This step might introduce shorthand filter names, thus \'ExpandFilter\' should be run afterwards. Can be disabled with "#filter":"no-auto"',
|
|
["filter"],
|
|
"AddFiltersFromTagRenderings"
|
|
)
|
|
}
|
|
|
|
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
|
|
const noAutoFilters = json["#filter"] === "no-auto"
|
|
if (noAutoFilters) {
|
|
return json
|
|
}
|
|
|
|
if (json.filter?.["sameAs"]) {
|
|
return json
|
|
}
|
|
|
|
const filters: (FilterConfigJson | string)[] = [...(<any>json.filter ?? [])]
|
|
|
|
function filterExists(filterName: string): boolean {
|
|
return filters.some((existing) => {
|
|
const id: string = existing["id"] ?? existing
|
|
return (
|
|
filterName === id ||
|
|
(filterName.startsWith("filters.") && filterName.endsWith("." + id))
|
|
)
|
|
})
|
|
}
|
|
|
|
for (let i = 0; i < json.tagRenderings?.length; i++) {
|
|
const tagRendering = <TagRenderingConfigJson>json.tagRenderings[i]
|
|
if (!tagRendering?.filter) {
|
|
continue
|
|
}
|
|
if (tagRendering.filter === true) {
|
|
if (filterExists(tagRendering["id"])) {
|
|
continue
|
|
}
|
|
filters.push(
|
|
ExpandFilter.buildFilterFromTagRendering(
|
|
tagRendering,
|
|
context.enters("tagRenderings", i, "filter")
|
|
)
|
|
)
|
|
continue
|
|
}
|
|
for (const filterName of tagRendering.filter ?? []) {
|
|
if (typeof filterName !== "string") {
|
|
context.enters("tagRenderings", i, "filter").err("Not a string: " + filterName)
|
|
}
|
|
if (filterExists(filterName)) {
|
|
// This filter has already been added
|
|
continue
|
|
}
|
|
if (!filterName) {
|
|
context.err("Got undefined as filter expansion in " + tagRendering["id"])
|
|
continue
|
|
}
|
|
filters.push(filterName)
|
|
}
|
|
}
|
|
|
|
if (filters.length === 0) {
|
|
return json
|
|
}
|
|
|
|
return { ...json, filter: filters }
|
|
}
|
|
}
|
|
|
|
class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"If no 'inline' is set on the freeform key, it will be automatically added. If no special renderings are used, it'll be set to true",
|
|
["freeform.inline"],
|
|
"DetectInline"
|
|
)
|
|
}
|
|
|
|
convert(
|
|
json: QuestionableTagRenderingConfigJson,
|
|
context: ConversionContext
|
|
): QuestionableTagRenderingConfigJson {
|
|
if (json.freeform === undefined) {
|
|
return json
|
|
}
|
|
let spec: Record<string, string>
|
|
if (typeof json.render === "string") {
|
|
spec = { "*": json.render }
|
|
} else {
|
|
spec = <Record<string, string>>json.render
|
|
}
|
|
for (const key in spec) {
|
|
if (spec[key].indexOf("<a ") >= 0) {
|
|
// We have a link element, it probably contains something that needs to be substituted...
|
|
// Let's play this safe and not inline it
|
|
return json
|
|
}
|
|
const fullSpecification = SpecialVisualizations.constructSpecification(spec[key])
|
|
if (fullSpecification.length > 1) {
|
|
// We found a special rendering!
|
|
if (json.freeform.inline === true) {
|
|
context.err(
|
|
"'inline' is set, but the rendering contains a special visualisation...\n " +
|
|
spec[key]
|
|
)
|
|
}
|
|
json = JSON.parse(JSON.stringify(json))
|
|
json.freeform.inline = false
|
|
return json
|
|
}
|
|
}
|
|
json = JSON.parse(JSON.stringify(json))
|
|
if (typeof json.freeform === "string") {
|
|
context.err("'freeform' is a string, but should be an object")
|
|
return json
|
|
}
|
|
|
|
if (json.render === undefined) {
|
|
context.err("No 'render' defined")
|
|
return json
|
|
}
|
|
|
|
if (
|
|
!Object.values(json?.render)?.some((render) => render !== "{" + json.freeform.key + "}")
|
|
) {
|
|
// We only render the current value, without anything more. Not worth inlining
|
|
return json
|
|
}
|
|
|
|
json.freeform.inline ??= true
|
|
return json
|
|
}
|
|
}
|
|
|
|
export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"Adds a 'questions'-object if no question element is added yet",
|
|
["tagRenderings"],
|
|
"AddQuestionBox"
|
|
)
|
|
}
|
|
|
|
/**
|
|
* const action = new AddQuestionBox()
|
|
* const tagRenderings = [{id:"questions", render: {"*": "{questions()}" } }]
|
|
* const conv = action.convert({tagRenderings}, ConversionContext.construct(["test"], []))
|
|
* conv.tagRenderings // => [{id:"questions", render: {"*": "{questions()}" } }]
|
|
*/
|
|
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
|
|
if (
|
|
json.tagRenderings === undefined ||
|
|
json.tagRenderings.some((tr) => tr["id"] === "leftover-questions")
|
|
) {
|
|
return json
|
|
}
|
|
if (json.source === "special") {
|
|
return json
|
|
}
|
|
json = { ...json }
|
|
json.tagRenderings = [...json.tagRenderings]
|
|
const allSpecials: Exclude<RenderingSpecification, string>[] = <any>(
|
|
ValidationUtils.getAllSpecialVisualisations(
|
|
<QuestionableTagRenderingConfigJson[]>json.tagRenderings
|
|
).filter((spec) => typeof spec !== "string")
|
|
)
|
|
|
|
const questionSpecials = allSpecials.filter((sp) => sp.func.funcName === "questions")
|
|
const noLabels = questionSpecials.filter(
|
|
(sp) => sp.args.length === 0 || sp.args[0].trim() === ""
|
|
)
|
|
|
|
if (noLabels.length > 1) {
|
|
context.err(
|
|
"Multiple 'questions'-visualisations found which would show _all_ questions. Don't do this"
|
|
)
|
|
}
|
|
|
|
// ALl labels that are used in this layer
|
|
const allLabels = new Set(
|
|
[].concat(
|
|
...json.tagRenderings.map(
|
|
(tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? []
|
|
)
|
|
)
|
|
)
|
|
const seen: Set<string> = new Set()
|
|
for (const questionSpecial of questionSpecials) {
|
|
if (typeof questionSpecial === "string") {
|
|
continue
|
|
}
|
|
const used = questionSpecial.args[0]
|
|
?.split(";")
|
|
?.map((a) => a.trim())
|
|
?.filter((s) => s != "")
|
|
const blacklisted = questionSpecial.args[1]
|
|
?.split(";")
|
|
?.map((a) => a.trim())
|
|
?.filter((s) => s != "")
|
|
if (blacklisted?.length > 0 && used?.length > 0) {
|
|
context.err(
|
|
"The {questions()}-special rendering only supports either a blacklist OR a whitelist, but not both." +
|
|
"\n Whitelisted: " +
|
|
used.join(", ") +
|
|
"\n Blacklisted: " +
|
|
blacklisted.join(", ")
|
|
)
|
|
}
|
|
for (const usedLabel of used) {
|
|
if (!allLabels.has(usedLabel)) {
|
|
context.err(
|
|
"This layers specifies a special question element for label `" +
|
|
usedLabel +
|
|
"`, but this label doesn't exist.\n" +
|
|
" Available labels are " +
|
|
Array.from(allLabels).join(", ")
|
|
)
|
|
}
|
|
seen.add(usedLabel)
|
|
}
|
|
}
|
|
|
|
if (noLabels.length == 0) {
|
|
/* At this point, we know which question labels are not yet handled and which already are handled, and we
|
|
* know there is no previous catch-all questions
|
|
*/
|
|
const question: QuestionableTagRenderingConfigJson = {
|
|
id: "leftover-questions",
|
|
render: {
|
|
"*": `{questions( ,${Array.from(seen).join(";")})}`,
|
|
},
|
|
}
|
|
json.tagRenderings.push(question)
|
|
}
|
|
return json
|
|
}
|
|
}
|
|
|
|
export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
|
|
private readonly _desugaring: DesugaringContext
|
|
private readonly _addedByDefaultAtTop: QuestionableTagRenderingConfigJson[]
|
|
private readonly _addedByDefault: QuestionableTagRenderingConfigJson[]
|
|
private readonly builtinQuestions: QuestionableTagRenderingConfigJson[]
|
|
|
|
constructor(desugaring: DesugaringContext) {
|
|
super(
|
|
"Add some editing elements, such as the delete button or the move button if they are configured. These used to be handled by the feature info box, but this has been replaced by special visualisation elements",
|
|
[],
|
|
"AddEditingElements"
|
|
)
|
|
this._desugaring = desugaring
|
|
this.builtinQuestions = Array.from(this._desugaring.tagRenderings?.values() ?? [])
|
|
|
|
this._addedByDefaultAtTop = this.getAddedByDefaultIds("added_by_default_top")
|
|
this._addedByDefault = this.getAddedByDefaultIds("added_by_default")
|
|
}
|
|
|
|
public getAddedByDefaultIds(key: string): QuestionableTagRenderingConfigJson[] {
|
|
const addByDefault = this.builtinQuestions.filter((tr) => tr.labels?.indexOf(key) >= 0)
|
|
const ids = new Set(addByDefault.map((tr) => tr.id))
|
|
const idsInOrder = this._desugaring.tagRenderingOrder?.filter((id) => ids.has(id)) ?? []
|
|
return Utils.NoNull(idsInOrder.map((id) => this._desugaring.tagRenderings.get(id)))
|
|
}
|
|
|
|
convert(json: LayerConfigJson, _: ConversionContext): LayerConfigJson {
|
|
if (this._desugaring.tagRenderings === null) {
|
|
return json
|
|
}
|
|
if (json.source === "special") {
|
|
return json
|
|
}
|
|
if (!json.title && !json.tagRenderings) {
|
|
return json
|
|
}
|
|
json = { ...json }
|
|
json.tagRenderings = [...(json.tagRenderings ?? [])]
|
|
const allIds = new Set<string>(json.tagRenderings.map((tr) => tr["id"]))
|
|
const specialVisualisations = ValidationUtils.getAllSpecialVisualisations(
|
|
<any>json.tagRenderings
|
|
)
|
|
|
|
const usedSpecialFunctions = new Set(
|
|
specialVisualisations.map((sv) =>
|
|
typeof sv === "string" ? undefined : sv.func.funcName
|
|
)
|
|
)
|
|
|
|
/***** ADD TO TOP ****/
|
|
|
|
json.tagRenderings.unshift(...this._addedByDefaultAtTop.filter((tr) => !allIds.has(tr.id)))
|
|
|
|
/***** ADD TO BOTTOM ****/
|
|
|
|
if (!usedSpecialFunctions.has("minimap")) {
|
|
json.tagRenderings.push(this._desugaring.tagRenderings.get("minimap"))
|
|
}
|
|
|
|
if (
|
|
usedSpecialFunctions.has("image_upload") &&
|
|
!usedSpecialFunctions.has("nearby_images")
|
|
) {
|
|
json.tagRenderings.push(this._desugaring.tagRenderings.get("nearby_images"))
|
|
}
|
|
|
|
if (json.allowSplit && !usedSpecialFunctions.has("split_button")) {
|
|
json.tagRenderings.push(this._desugaring.tagRenderings.get("split_button"))
|
|
delete json.allowSplit
|
|
}
|
|
|
|
if (json.allowMove && !usedSpecialFunctions.has("move_button")) {
|
|
json.tagRenderings.push({
|
|
id: "move-button",
|
|
render: { "*": "{move_button()}" },
|
|
})
|
|
}
|
|
if (json.deletion && !usedSpecialFunctions.has("delete_button")) {
|
|
json.tagRenderings.push({
|
|
id: "delete-button",
|
|
render: { "*": "{delete_button()}" },
|
|
})
|
|
}
|
|
|
|
json.tagRenderings.push(...this._addedByDefault.filter((tr) => !allIds.has(tr.id)))
|
|
|
|
if (!usedSpecialFunctions.has("all_tags")) {
|
|
const trc: QuestionableTagRenderingConfigJson = {
|
|
id: "all-tags",
|
|
render: { "*": "{all_tags()}" },
|
|
labels: ["ignore_docs"],
|
|
metacondition: {
|
|
or: [
|
|
"__featureSwitchIsDebugging=true",
|
|
"mapcomplete-show_tags=full",
|
|
"mapcomplete-show_debug=yes",
|
|
],
|
|
},
|
|
}
|
|
json.tagRenderings?.push(trc)
|
|
}
|
|
|
|
return json
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a 'special' translation into a regular translation which uses parameters
|
|
*/
|
|
export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"Converts a 'special' translation into a regular translation which uses parameters",
|
|
["special"],
|
|
"RewriteSpecial"
|
|
)
|
|
}
|
|
|
|
private static escapeStr(v: string, context: ConversionContext): string {
|
|
if (typeof v !== "string") {
|
|
context.err("Detected a non-string value where one expected a string: " + v)
|
|
return RewriteSpecial.escapeStr("" + v, context)
|
|
}
|
|
return v
|
|
.replace(/,/g, "&COMMA")
|
|
.replace(/\{/g, "&LBRACE")
|
|
.replace(/}/g, "&RBRACE")
|
|
.replace(/\(/g, "&LPARENS")
|
|
.replace(/\)/g, "&RPARENS")
|
|
}
|
|
|
|
/**
|
|
* Does the heavy lifting and conversion
|
|
*
|
|
* // should not do anything if no 'special'-key is present
|
|
* RewriteSpecial.convertIfNeeded({"en": "xyz", "nl": "abc"}, ConversionContext.test()) // => {"en": "xyz", "nl": "abc"}
|
|
*
|
|
* // should handle a simple special case
|
|
* RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel"}}, ConversionContext.test()) // => {'*': "{image_carousel()}"}
|
|
*
|
|
* // should add a class to the special element
|
|
* RewriteSpecial.convertIfNeeded({"special": {"type":"qr_code"}, class:"inline"}, ConversionContext.test()) // => {'*': "{qr_code():inline}"}
|
|
*
|
|
* // should handle special case with a parameter
|
|
* RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel", "image_key": "some_image_key"}}, ConversionContext.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, ConversionContext.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, ConversionContext.test())
|
|
* r // => {"en": "PREFIX {image_upload(,,)} POSTFIX", "nl": "PREFIX {image_upload(,,)} Achtervoegsel" }
|
|
*
|
|
* // should warn for unexpected keys
|
|
* const context = ConversionContext.test()
|
|
* RewriteSpecial.convertIfNeeded({"special": {type: "image_carousel"}, "en": "xyz"}, context) // => {'*': "{image_carousel()}"}
|
|
* context.getAll("error")[0].message // => "The only keys allowed next to a 'special'-block are 'before', 'after' and 'class'. Perhaps you meant to put 'en' into the special block?"
|
|
*
|
|
* // should give an error on unknown visualisations
|
|
* const context = ConversionContext.test()
|
|
* RewriteSpecial.convertIfNeeded({"special": {type: "qsdf"}}, context) // => undefined
|
|
* context.getAll("error")[0].message.indexOf("Special visualisation 'qsdf' not found") >= 0 // => true
|
|
*
|
|
* // should give an error is 'type' is missing
|
|
* const context = ConversionContext.test()
|
|
* RewriteSpecial.convertIfNeeded({"special": {}}, context) // => undefined
|
|
* context.getAll("error")[0].message // => "A 'special'-block should define 'type' to indicate which visualisation should be used"
|
|
*
|
|
*
|
|
* // an actual test
|
|
* const special = {
|
|
* "before": {
|
|
* "en": "<h3>Entrances</h3>This building has {_entrances_count} entrances:"
|
|
* },
|
|
* "after": {
|
|
* "en": "{_entrances_count_without_width_count} entrances don't have width information yet"
|
|
* },
|
|
* "special": {
|
|
* "type": "multi",
|
|
* "key": "_entrance_properties_with_width",
|
|
* "tagrendering": {
|
|
* "en": "An <a href='#{id}'>entrance</a> of {canonical(width)}"
|
|
* }
|
|
* }}
|
|
* const context = ConversionContext.test()
|
|
* RewriteSpecial.convertIfNeeded(special, context) // => {"en": "<h3>Entrances</h3>This building has {_entrances_count} entrances:{multi(_entrance_properties_with_width,An <a href='#&LBRACEid&RBRACE'>entrance</a> of &LBRACEcanonical&LPARENSwidth&RPARENS&RBRACE,)}{_entrances_count_without_width_count} entrances don't have width information yet"}
|
|
* context.getAll("error") // => []
|
|
*
|
|
* // another actual test
|
|
* const special = {
|
|
* "special":{
|
|
* "type": "multi",
|
|
* "key": "_nearby_bicycle_parkings:props",
|
|
* "tagrendering": "<b>{id}</b> ({distance}m) {tagApply(a,b,c)}"
|
|
* }}
|
|
* const context = ConversionContext.test()
|
|
* RewriteSpecial.convertIfNeeded(special, context) // => {"*": "{multi(_nearby_bicycle_parkings:props,<b>&LBRACEid&RBRACE</b> &LPARENS&LBRACEdistance&RBRACEm&RPARENS &LBRACEtagApply&LPARENSa&COMMAb&COMMAc&RPARENS&RBRACE,)}"}
|
|
* context.getAll("error") // => []
|
|
*/
|
|
private static convertIfNeeded(
|
|
input:
|
|
| (object & {
|
|
special: {
|
|
type: string
|
|
}
|
|
})
|
|
| any,
|
|
context: ConversionContext
|
|
): any {
|
|
const special = input["special"]
|
|
if (special === undefined) {
|
|
return input
|
|
}
|
|
|
|
const type = special["type"]
|
|
if (type === undefined) {
|
|
context.err(
|
|
"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) {
|
|
const options = Utils.sortedByLevenshteinDistance(
|
|
type,
|
|
SpecialVisualizations.specialVisualizations,
|
|
(sp) => sp.funcName
|
|
)
|
|
context.err(
|
|
`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
|
|
}
|
|
Array.from(Object.keys(input))
|
|
.filter((k) => k !== "special" && k !== "before" && k !== "after" && k !== "class")
|
|
.map((k) => {
|
|
return `The only keys allowed next to a 'special'-block are 'before', 'after' and 'class'. Perhaps you meant to put '${k}' into the special block?`
|
|
})
|
|
.forEach((e) => context.err(e))
|
|
|
|
const argNamesList = vis.args.map((a) => a.name)
|
|
const argNames = new Set<string>(argNamesList)
|
|
// Check for obsolete and misspelled arguments
|
|
Object.keys(special)
|
|
.filter((k) => !argNames.has(k))
|
|
.filter((k) => k !== "type" && k !== "before" && k !== "after")
|
|
.map((wrongArg) => {
|
|
const byDistance = Utils.sortedByLevenshteinDistance(
|
|
wrongArg,
|
|
argNamesList,
|
|
(x) => x
|
|
)
|
|
return `Unexpected argument in special block at ${context} with name '${wrongArg}'. Did you mean ${
|
|
byDistance[0]
|
|
}?\n\tAll known arguments are ${argNamesList.join(", ")}`
|
|
})
|
|
.forEach((e) => context.err(e))
|
|
|
|
// 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) {
|
|
context.err(
|
|
`Obligated parameter '${arg.name}' in special rendering of type ${
|
|
vis.funcName
|
|
} not found.\n The full special rendering specification is: '${JSON.stringify(
|
|
input
|
|
)}'\n ${arg.name}: ${arg.doc}`
|
|
)
|
|
}
|
|
}
|
|
|
|
const foundLanguages = new Set<string>()
|
|
const translatedArgs = argNamesList
|
|
.map((nm) => special[nm])
|
|
.filter((v) => v !== undefined)
|
|
.filter((v) => Translations.isProbablyATranslation(v) || v["*"] !== undefined)
|
|
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)
|
|
const clss: string = input.class !== undefined ? ":" + input.class : ""
|
|
|
|
for (const ln of Object.keys(before?.translations ?? {})) {
|
|
foundLanguages.add(ln)
|
|
}
|
|
for (const ln of Object.keys(after?.translations ?? {})) {
|
|
foundLanguages.add(ln)
|
|
}
|
|
|
|
if (foundLanguages.size === 0) {
|
|
const args = argNamesList
|
|
.map((nm) => RewriteSpecial.escapeStr(special[nm] ?? "", context))
|
|
.join(",")
|
|
return {
|
|
"*": `{${type}(${args})${clss}}`,
|
|
}
|
|
}
|
|
|
|
const result = {}
|
|
const languages = Array.from(foundLanguages)
|
|
languages.sort()
|
|
for (const ln of languages) {
|
|
const args = []
|
|
for (const argName of argNamesList) {
|
|
let v = special[argName] ?? ""
|
|
if (Translations.isProbablyATranslation(v)) {
|
|
v = new Translation(v).textFor(ln)
|
|
}
|
|
|
|
if (typeof v === "string") {
|
|
args.push(RewriteSpecial.escapeStr(v, context))
|
|
} else if (typeof v === "object") {
|
|
args.push(JSON.stringify(v))
|
|
} else {
|
|
args.push(v)
|
|
}
|
|
}
|
|
const beforeText = before?.textFor(ln) ?? ""
|
|
const afterText = after?.textFor(ln) ?? ""
|
|
result[ln] = `${beforeText}{${type}(${args
|
|
.map((a) => a)
|
|
.join(",")})${clss}}${afterText}`
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* const tr = {
|
|
* render: {special: {type: "image_carousel", image_key: "image" }},
|
|
* mappings: [
|
|
* {
|
|
* if: "other_image_key",
|
|
* then: {special: {type: "image_carousel", image_key: "other_image_key"}}
|
|
* }
|
|
* ]
|
|
* }
|
|
* const result = new RewriteSpecial().convertStrict(tr,ConversionContext.test())
|
|
* const expected = {render: {'*': "{image_carousel(image)}"}, mappings: [{if: "other_image_key", then: {'*': "{image_carousel(other_image_key)}"}} ]}
|
|
* result // => expected
|
|
*
|
|
* // Should put text before if specified
|
|
* const tr = {
|
|
* render: {special: {type: "image_carousel", image_key: "image"}, before: {en: "Some introduction"} },
|
|
* }
|
|
* const result = new RewriteSpecial().convertStrict(tr,ConversionContext.test())
|
|
* const expected = {render: {'en': "Some introduction{image_carousel(image)}"}}
|
|
* result // => expected
|
|
*
|
|
* // Should put text after if specified
|
|
* const tr = {
|
|
* render: {special: {type: "image_carousel", image_key: "image"}, after: {en: "Some footer"} },
|
|
* }
|
|
* const result = new RewriteSpecial().convertStrict(tr,ConversionContext.test())
|
|
* const expected = {render: {'en': "{image_carousel(image)}Some footer"}}
|
|
* result // => expected
|
|
*/
|
|
convert(json: TagRenderingConfigJson, context: ConversionContext): TagRenderingConfigJson {
|
|
json = Utils.Clone(json)
|
|
const paths: ConfigMeta[] = tagrenderingconfigmeta
|
|
for (const path of paths) {
|
|
if (path.hints.typehint !== "rendered") {
|
|
continue
|
|
}
|
|
Utils.WalkPath(path.path, json, (leaf, travelled) =>
|
|
RewriteSpecial.convertIfNeeded(leaf, context.enter(travelled))
|
|
)
|
|
}
|
|
|
|
return json
|
|
}
|
|
}
|
|
|
|
class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
|
|
private _expand: ExpandTagRendering
|
|
|
|
constructor(state: DesugaringContext, layer: LayerConfigJson) {
|
|
super("Expands shorthand properties on iconBadges", ["iconBadges"], "ExpandIconBadges")
|
|
this._expand = new ExpandTagRendering(state, layer)
|
|
}
|
|
|
|
convert(json: PointRenderingConfigJson, context: ConversionContext): PointRenderingConfigJson {
|
|
if (!json["iconBadges"]) {
|
|
return json
|
|
}
|
|
const badgesJson = json.iconBadges
|
|
|
|
const iconBadges: {
|
|
if: TagConfigJson
|
|
then: string | MinimalTagRenderingConfigJson
|
|
}[] = []
|
|
|
|
for (let i = 0; i < badgesJson.length; i++) {
|
|
const iconBadge: {
|
|
if: TagConfigJson
|
|
then: string | MinimalTagRenderingConfigJson
|
|
} = badgesJson[i]
|
|
const expanded = this._expand.convert(
|
|
<QuestionableTagRenderingConfigJson>iconBadge.then,
|
|
context.enters("iconBadges", i)
|
|
)
|
|
if (expanded === undefined) {
|
|
iconBadges.push(iconBadge)
|
|
continue
|
|
}
|
|
|
|
iconBadges.push(
|
|
...expanded.map((resolved) => ({
|
|
if: iconBadge.if,
|
|
then: <MinimalTagRenderingConfigJson>resolved,
|
|
}))
|
|
)
|
|
}
|
|
|
|
return { ...json, iconBadges }
|
|
}
|
|
}
|
|
|
|
class PreparePointRendering extends Fuse<PointRenderingConfigJson> {
|
|
constructor(state: DesugaringContext, layer: LayerConfigJson) {
|
|
super(
|
|
"Prepares point renderings by expanding 'icon' and 'iconBadges'." +
|
|
" A tagRendering from the host tagRenderings will be substituted in",
|
|
new On(
|
|
"marker",
|
|
new Each(
|
|
new On(
|
|
"icon",
|
|
new FirstOf(new ExpandTagRendering(state, layer, { applyCondition: false }))
|
|
)
|
|
)
|
|
),
|
|
new ExpandIconBadges(state, layer)
|
|
)
|
|
}
|
|
}
|
|
|
|
class SetFullNodeDatabase extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"sets the fullNodeDatabase-bit if needed",
|
|
["fullNodeDatabase"],
|
|
"SetFullNodeDatabase"
|
|
)
|
|
}
|
|
|
|
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
|
|
const needsSpecial =
|
|
json.tagRenderings?.some((tr) => {
|
|
if (typeof tr === "string") {
|
|
return false
|
|
}
|
|
const specs = ValidationUtils.getSpecialVisualisations(<TagRenderingConfigJson>tr)
|
|
return specs?.some((sp) => sp.needsNodeDatabase)
|
|
}) ?? false
|
|
if (!needsSpecial) {
|
|
return json
|
|
}
|
|
context.debug("Layer " + json.id + " needs the fullNodeDatabase")
|
|
return { ...json, fullNodeDatabase: true }
|
|
}
|
|
}
|
|
|
|
class ExpandMarkerRenderings extends DesugaringStep<IconConfigJson> {
|
|
private readonly _layer: LayerConfigJson
|
|
private readonly _state: DesugaringContext
|
|
|
|
constructor(state: DesugaringContext, layer: LayerConfigJson) {
|
|
super(
|
|
"Expands tagRenderings in the icons, if needed",
|
|
["icon", "color"],
|
|
"ExpandMarkerRenderings"
|
|
)
|
|
this._layer = layer
|
|
this._state = state
|
|
}
|
|
|
|
convert(json: IconConfigJson, context: ConversionContext): IconConfigJson {
|
|
const expander = new ExpandTagRendering(this._state, this._layer, {applyCondition: false})
|
|
const result: IconConfigJson = { icon: undefined, color: undefined }
|
|
if (json.icon && json.icon["builtin"]) {
|
|
result.icon = <MinimalTagRenderingConfigJson>(
|
|
expander.convert(<any>json.icon, context.enter("icon"))[0]
|
|
) ?? json.icon
|
|
} else {
|
|
result.icon = json.icon
|
|
}
|
|
if (json.color && json.color["builtin"]) {
|
|
result.color = <MinimalTagRenderingConfigJson>(
|
|
expander.convert(<any>json.color, context.enter("color"))[0]
|
|
) ?? json.color
|
|
} else {
|
|
result.color = json.color
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
class AddFavouriteBadges extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"Adds the favourite heart to the title and the rendering badges",
|
|
[],
|
|
"AddFavouriteBadges"
|
|
)
|
|
}
|
|
|
|
convert(json: LayerConfigJson, _: ConversionContext): LayerConfigJson {
|
|
if (json.source === "special" || json.source === "special:library") {
|
|
return json
|
|
}
|
|
const pr = json.pointRendering?.[0]
|
|
if (pr) {
|
|
pr.iconBadges ??= []
|
|
if (!pr.iconBadges.some((ti) => ti.if === "_favourite=yes")) {
|
|
pr.iconBadges.push({ if: "_favourite=yes", then: "circle:white;heart:red" })
|
|
}
|
|
}
|
|
|
|
return json
|
|
}
|
|
}
|
|
|
|
export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"Adds the 'rating'-element if a reviews-element is used in the tagRenderings",
|
|
["titleIcons"],
|
|
"AddRatingBadge"
|
|
)
|
|
}
|
|
|
|
convert(json: LayerConfigJson, _: ConversionContext): LayerConfigJson {
|
|
if (!json.tagRenderings) {
|
|
return json
|
|
}
|
|
if (json.titleIcons.some((ti) => ti === "icons.rating" || ti["id"] === "rating")) {
|
|
// already added
|
|
return json
|
|
}
|
|
if (json.id === "favourite") {
|
|
// handled separately
|
|
return json
|
|
}
|
|
|
|
const specialVis: Exclude<RenderingSpecification, string>[] = <
|
|
Exclude<RenderingSpecification, string>[]
|
|
>ValidationUtils.getAllSpecialVisualisations(<any>json.tagRenderings).filter(
|
|
(rs) => typeof rs !== "string"
|
|
)
|
|
const funcs = new Set<string>(specialVis.map((rs) => rs.func.funcName))
|
|
|
|
if (funcs.has("list_reviews")) {
|
|
;(<(string | TagRenderingConfigJson)[]>json.titleIcons).push("icons.rating")
|
|
}
|
|
return json
|
|
}
|
|
}
|
|
|
|
export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"The auto-icon creates a (non-clickable) title icon based on a tagRendering which has icons",
|
|
["titleIcons"],
|
|
"AutoTitleIcon"
|
|
)
|
|
}
|
|
|
|
private createTitleIconsBasedOn(
|
|
tr: QuestionableTagRenderingConfigJson
|
|
): TagRenderingConfigJson | undefined {
|
|
const mappings: { if: TagConfigJson; then: string }[] = tr.mappings
|
|
?.filter((m) => m.icon !== undefined)
|
|
.map((m) => {
|
|
const path: string = typeof m.icon === "string" ? m.icon : m.icon.path
|
|
const img = `<img class="m-1 h-6 w-6 low-interaction rounded" src='${path}'/>`
|
|
return { if: m.if, then: img }
|
|
})
|
|
if (!mappings || mappings.length === 0) {
|
|
return undefined
|
|
}
|
|
return <TagRenderingConfigJson>{
|
|
id: "title_icon_auto_" + tr.id,
|
|
mappings,
|
|
}
|
|
}
|
|
|
|
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
|
|
json = { ...json }
|
|
json.titleIcons = [...json.titleIcons]
|
|
|
|
const allAutoIndex = json.titleIcons.indexOf(<any>"auto:*")
|
|
if (allAutoIndex >= 0) {
|
|
const generated = Utils.NoNull(
|
|
json.tagRenderings.map((tr) => {
|
|
if (typeof tr === "string") {
|
|
return undefined
|
|
}
|
|
return this.createTitleIconsBasedOn(<any>tr)
|
|
})
|
|
)
|
|
json.titleIcons.splice(allAutoIndex, 1, ...generated)
|
|
return json
|
|
}
|
|
|
|
for (let i = 0; i < json.titleIcons.length; i++) {
|
|
const titleIcon = json.titleIcons[i]
|
|
if (typeof titleIcon !== "string") {
|
|
continue
|
|
}
|
|
if (!titleIcon.startsWith("auto:")) {
|
|
continue
|
|
}
|
|
const trId = titleIcon.substring("auto:".length)
|
|
const tr = <QuestionableTagRenderingConfigJson>(
|
|
json.tagRenderings.find((tr) => tr["id"] === trId)
|
|
)
|
|
if (tr === undefined) {
|
|
context.enters("titleIcons", i).err("TagRendering with id " + trId + " not found")
|
|
continue
|
|
}
|
|
const generated = this.createTitleIconsBasedOn(tr)
|
|
|
|
if (!generated) {
|
|
context
|
|
.enters("titleIcons", i)
|
|
.warn(
|
|
"TagRendering with id " +
|
|
trId +
|
|
" does not have any icons, not generating an icon for this"
|
|
)
|
|
continue
|
|
}
|
|
json.titleIcons[i] = generated
|
|
}
|
|
return json
|
|
}
|
|
}
|
|
|
|
class DeriveSource extends DesugaringStep<LayerConfigJson> {
|
|
constructor() {
|
|
super(
|
|
"If no source is given, automatically derives the osmTags by 'or'-ing all the preset tags",
|
|
["source"],
|
|
"DeriveSource"
|
|
)
|
|
}
|
|
|
|
public convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
|
|
if (json.source) {
|
|
return json
|
|
}
|
|
if (!json.presets) {
|
|
context.err(
|
|
"No source tags given. Trying to derive the source-tags based on the presets, but no presets are given"
|
|
)
|
|
return json
|
|
}
|
|
|
|
json = { ...json }
|
|
|
|
const raw = { or: json.presets.map((pr) => ({ and: pr.tags })) }
|
|
const osmTags = TagUtils.optimzeJson(raw)
|
|
if (osmTags === false) {
|
|
context.err("The preset-tags optimize to 'false' " + JSON.stringify(raw))
|
|
return json
|
|
}
|
|
if (osmTags === true) {
|
|
context.err("The preset-tags optimize to 'true' " + JSON.stringify(raw))
|
|
return json
|
|
}
|
|
|
|
json.source = { osmTags }
|
|
return json
|
|
}
|
|
}
|
|
|
|
export class PrepareLayer extends Fuse<LayerConfigJson> {
|
|
constructor(
|
|
state: DesugaringContext,
|
|
options?: { addTagRenderingsToContext?: false | boolean }
|
|
) {
|
|
super(
|
|
"Fully prepares and expands a layer for the LayerConfig.",
|
|
new DeriveSource(),
|
|
new On("tagRenderings", new Each(new RewriteSpecial())),
|
|
new On("tagRenderings", new Concat(new ExpandRewrite()).andThenF(Utils.Flatten)),
|
|
new On(
|
|
"tagRenderings",
|
|
(layer) =>
|
|
new Concat(
|
|
new ExpandTagRendering(state, layer, {
|
|
addToContext: options?.addTagRenderingsToContext ?? false,
|
|
})
|
|
)
|
|
),
|
|
new On("tagRenderings", new Each(new DetectInline())),
|
|
new AddQuestionBox(),
|
|
new AddEditingElements(state),
|
|
new SetFullNodeDatabase(),
|
|
new On<
|
|
(LineRenderingConfigJson | RewritableConfigJson<LineRenderingConfigJson>)[],
|
|
LayerConfigJson
|
|
>("lineRendering", new Each(new ExpandRewrite()).andThenF(Utils.Flatten)),
|
|
new On<PointRenderingConfigJson[], LayerConfigJson>(
|
|
"pointRendering",
|
|
(layer) =>
|
|
new Each(new On("marker", new Each(new ExpandMarkerRenderings(state, layer))))
|
|
),
|
|
new On<PointRenderingConfigJson[], LayerConfigJson>(
|
|
"pointRendering",
|
|
(layer) => new Each(new PreparePointRendering(state, layer))
|
|
),
|
|
new SetDefault("titleIcons", ["icons.defaults"]),
|
|
new AddRatingBadge(),
|
|
new AddFavouriteBadges(),
|
|
new AutoTitleIcon(),
|
|
new On(
|
|
"titleIcons",
|
|
(layer) =>
|
|
new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true }))
|
|
),
|
|
new AddFiltersFromTagRenderings(),
|
|
new ExpandFilter(state),
|
|
new PruneFilters()
|
|
)
|
|
}
|
|
}
|