Fix loading of relative images in custom themes

This commit is contained in:
pietervdvn 2022-02-04 15:48:26 +01:00
parent 8d79d94e7b
commit a3b32a3697
7 changed files with 346 additions and 235 deletions

View file

@ -10,16 +10,17 @@ import {UIEventSource} from "./UIEventSource";
import {LocalStorageSource} from "./Web/LocalStorageSource"; import {LocalStorageSource} from "./Web/LocalStorageSource";
import LZString from "lz-string"; import LZString from "lz-string";
import * as personal from "../assets/themes/personal/personal.json"; import * as personal from "../assets/themes/personal/personal.json";
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; import {FixImages, FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import SharedTagRenderings from "../Customizations/SharedTagRenderings"; import SharedTagRenderings from "../Customizations/SharedTagRenderings";
import * as known_layers from "../assets/generated/known_layers.json" import * as known_layers from "../assets/generated/known_layers.json"
import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson"; import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson";
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme"; import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme";
import {Layer} from "leaflet"; import * as licenses from "../assets/generated/license_info.json"
export default class DetermineLayout { export default class DetermineLayout {
private static readonly _knownImages =new Set( Array.from(licenses).map(l => l.path))
/** /**
* Gets the correct layout for this website * Gets the correct layout for this website
*/ */
@ -144,6 +145,7 @@ export default class DetermineLayout {
sharedLayers: knownLayersDict sharedLayers: knownLayersDict
} }
json = new FixLegacyTheme().convertStrict(json, "While loading a dynamic theme") json = new FixLegacyTheme().convertStrict(json, "While loading a dynamic theme")
json = new FixImages(DetermineLayout._knownImages).convertStrict(json, "While fixing the images")
json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme") json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme")
console.log("The layoutconfig is ", json) console.log("The layoutconfig is ", json)
return json return json

View file

@ -2,7 +2,8 @@ import {LayoutConfigJson} from "../Json/LayoutConfigJson";
import {Utils} from "../../../Utils"; import {Utils} from "../../../Utils";
import LineRenderingConfigJson from "../Json/LineRenderingConfigJson"; import LineRenderingConfigJson from "../Json/LineRenderingConfigJson";
import {LayerConfigJson} from "../Json/LayerConfigJson"; import {LayerConfigJson} from "../Json/LayerConfigJson";
import {DesugaringContext, DesugaringStep, Fuse, OnEvery} from "./Conversion"; import {DesugaringStep, Fuse, OnEvery} from "./Conversion";
import * as metapaths from "../../../assets/layoutconfigmeta.json"
export class UpdateLegacyLayer extends DesugaringStep<LayerConfigJson | string | { builtin, override }> { export class UpdateLegacyLayer extends DesugaringStep<LayerConfigJson | string | { builtin, override }> {
@ -157,3 +158,103 @@ export class FixLegacyTheme extends Fuse<LayoutConfigJson> {
); );
} }
} }
export class FixImages extends DesugaringStep<LayoutConfigJson> {
private readonly _knownImages: Set<string>;
constructor(knownImages: Set<string>) {
super("Walks over the entire theme and replaces images to the relative URL. Only works if the ID of the theme is an URL");
this._knownImages = knownImages;
}
/**
* Walks the path into the object till the end.
*
* If a list is encountered, this is tranparently walked recursively on every object.
*
* The leaf objects are replaced
*/
private static WalkPath(path: string[], object: any, replaceLeaf: ((leaf: any) => any)) {
const head = path[0]
if (path.length === 1) {
// We have reached the leaf
const leaf = object[head];
if (leaf !== undefined) {
object[head] = replaceLeaf(leaf)
}
return
}
const sub = object[head]
if (sub === undefined) {
return;
}
if (typeof sub !== "object") {
return;
}
if (sub["forEach"] !== undefined) {
sub.forEach(el => FixImages.WalkPath(path.slice(1), el, replaceLeaf))
return;
}
FixImages.WalkPath(path.slice(1), sub, replaceLeaf)
}
convert(json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors?: string[]; warnings?: string[] } {
let url: URL;
console.log("Fixing images!")
try {
url = new URL(json.id)
} catch (e) {
// Not a URL, we don't rewrite
return {result: json}
}
const absolute = url.protocol +"//"+url.host
let relative = url.protocol +"//"+ url.host + url.pathname
relative = relative.substring(0, relative.lastIndexOf("/"))
const self = this;
function replaceString(leaf: string) {
if (self._knownImages.has(leaf)) {
return leaf;
}
if (leaf.startsWith("./")) {
return relative + leaf.substring(1)
}
if (leaf.startsWith("/")) {
return absolute + leaf
}
return leaf;
}
json = Utils.Clone(json)
let paths = metapaths["default"] ?? metapaths
for (const metapath of paths) {
if (metapath.typeHint !== "image" && metapath.typeHint !== "icon") {
continue
}
FixImages.WalkPath(metapath.path, json, leaf => {
console.log("Detected leaf: ", leaf)
if (typeof leaf === "string") {
return replaceString(leaf)
}
if (metapath.type["some"] !== undefined && (<any[]>metapath.type).some(t => t["$ref"] == "\"#/definitions/TagRenderingConfigJson\"")) {
console.log("Possibly found a tagrendering")
}
return leaf;
})
}
return {
result: json
};
}
}

View file

@ -37,7 +37,8 @@ export default interface PointRenderingConfigJson {
* *
* Note: strings are interpreted as icons, so layering and substituting is supported. You can use `circle:white;./my_icon.svg` to add a background circle * Note: strings are interpreted as icons, so layering and substituting is supported. You can use `circle:white;./my_icon.svg` to add a background circle
*/ */
iconBadges?: { if: string | AndOrTagConfigJson, iconBadges?: {
if: string | AndOrTagConfigJson,
/** /**
* Badge to show * Badge to show
* Type: icon * Type: icon

View file

@ -119,45 +119,64 @@ export default class MoreScreen extends Combine {
]).SetClass("flex flex-col border border-gray-300 p-2 rounded-lg") ]).SetClass("flex flex-col border border-gray-300 p-2 rounded-lg")
} }
private static createButtonFor(state: UserRelatedState, id: string): BaseUIElement {
const allPreferences = state.osmConnection.preferencesHandler.preferences.data;
const length = Number(allPreferences[id + "-combined-length"])
let str = "";
for (let i = 0; i < length; i++) {
str += allPreferences[id + "-" + i]
}
try {
const value: {
id: string
icon: string,
title: any,
shortDescription: any
} = JSON.parse(str)
return MoreScreen.createLinkButton(state, value, true)
} catch (e) {
console.debug("Could not parse unofficial theme information for " + id, e)
return undefined
}
}
private static createUnofficialThemeList(buttonClass: string, state: UserRelatedState, themeListClasses): BaseUIElement { private static createUnofficialThemeList(buttonClass: string, state: UserRelatedState, themeListClasses): BaseUIElement {
const prefix = "mapcomplete-unofficial-theme-"; const prefix = "mapcomplete-unofficial-theme-";
return new VariableUiElement(state.osmConnection.preferencesHandler.preferences.map(allPreferences => {
console.log("All preferences are ", allPreferences)
const allThemes: BaseUIElement[] = []
for (const key in allPreferences) {
if (key.startsWith(prefix) && key.endsWith("-combined-length")) {
const id = key.substring(0, key.length - "-length".length)
const length = Number(allPreferences[key])
let str = ""; var currentIds: UIEventSource<string[]> = state.osmConnection.preferencesHandler.preferences
for (let i = 0; i < length; i++) { .map(allPreferences => {
str += allPreferences[id + "-" + i] const ids: string[] = []
}
console.log("Theme " + id + " has settings ", str)
try {
const value: {
id: string
icon: string,
title: any,
shortDescription: any
} = JSON.parse(str)
const link = MoreScreen.createLinkButton(state, value, true).SetClass(buttonClass) for (const key in allPreferences) {
allThemes.push(link) if (key.startsWith(prefix) && key.endsWith("-combined-length")) {
} catch (e) { const id = key.substring(0, key.length - "-length".length)
console.error("Could not parse unofficial theme information for " + id, e) ids.push(id)
} }
} }
}
if (allThemes.length <= 0) { return ids
return undefined; });
} var stableIds = UIEventSource.ListStabilized<string>(currentIds)
return new Combine([
Translations.t.general.customThemeIntro.Clone(), return new VariableUiElement(
new Combine(allThemes).SetClass(themeListClasses) stableIds.map(ids => {
]); const allThemes: BaseUIElement[] = []
})); for (const id of ids) {
const link = this.createButtonFor(state, id)
if (link !== undefined) {
allThemes.push(link.SetClass(buttonClass))
}
}
if (allThemes.length <= 0) {
return undefined;
}
return new Combine([
Translations.t.general.customThemeIntro.Clone(),
new Combine(allThemes).SetClass(themeListClasses)
]);
}));
} }
private static createPreviouslyVistedHiddenList(state: UserRelatedState, buttonClass: string, themeListStyle: string) { private static createPreviouslyVistedHiddenList(state: UserRelatedState, buttonClass: string, themeListStyle: string) {

View file

@ -134,7 +134,7 @@ function main() {
return {typeHint: type.substr("type: ".length), type: schemePart.type ?? schemePart.anyOf} return {typeHint: type.substr("type: ".length), type: schemePart.type ?? schemePart.anyOf}
}, themeSchema) }, themeSchema)
// writeFileSync("./assets/layoutconfigmeta.json",JSON.stringify(withTypes.map(({path, t}) => ({path, ...t})), null, " ")) writeFileSync("./assets/layoutconfigmeta.json",JSON.stringify(withTypes.map(({path, t}) => ({path, ...t})), null, " "))
} }

View file

@ -1,10 +1,10 @@
import T from "./TestHelper"; import T from "./TestHelper";
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; import {FixImages, FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson"; import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson";
import {AddMiniMap} from "../Models/ThemeConfig/Conversion/PrepareTheme"; import {AddMiniMap} from "../Models/ThemeConfig/Conversion/PrepareTheme";
import {DetectShadowedMappings} from "../Models/ThemeConfig/Conversion/Validation"; import {DetectShadowedMappings} from "../Models/ThemeConfig/Conversion/Validation";
import * as Assert from "assert";
export default class LegacyThemeLoaderSpec extends T { export default class LegacyThemeLoaderSpec extends T {
@ -143,215 +143,203 @@ export default class LegacyThemeLoaderSpec extends T {
] ]
} }
private static readonly organic_waste_theme = { private static readonly verkeerde_borden ={
"id": "recycling-organic", "id": "https://raw.githubusercontent.com/seppesantens/MapComplete-Themes/main/VerkeerdeBordenDatabank/VerkeerdeBordenDatabank.json",
"title": { "title": {
"nl": "Inzamelpunt organisch alval" "nl": "VerkeerdeBordenDatabank",
}, "en": "Erratic Signs Database"
"shortDescription": {
"nl": "Inzamelpunt organisch alval"
}, },
"maintainer": "Seppe Santens",
"icon": "https://upload.wikimedia.org/wikipedia/commons/b/bc/Belgian_traffic_sign_A51.svg",
"description": { "description": {
"nl": "Op deze kaart vindt u inzamelpunten voor organisch afval. Beheer deze naar goeddunken en vermogen." "nl": "Een kaart om verkeerde of ontbrekende verkeersborden te tonen en te editeren.",
"en": "A map to show and edit incorrect or missing traffic signs."
},
"version": "2021-09-16",
"startLat": 51.08881,
"startLon": 3.447282,
"startZoom": 15,
"clustering": {
"maxZoom": 8
}, },
"language": [
"nl"
],
"maintainer": "",
"icon": "https://upload.wikimedia.org/wikipedia/commons/1/15/Compost_…able_waste_-_biodegradable_waste_-_biological_waste_icon.png",
"version": "0",
"startLat": 0,
"startLon": 0,
"startZoom": 1,
"widenFactor": 0.05,
"socialImage": "",
"layers": [ "layers": [
{ {
"id": "recycling-organic", "id": "trafficsign",
"name": { "name": {
"nl": "Inzamelpunt organisch alval" "nl": "verkeersbord",
"en": "traffic sign"
}, },
"minzoom": 12,
"title": {
"render": {
"nl": "Inzamelpunt organisch alval"
},
"mappings": [
{
"if": {
"and": [
"name~*"
]
},
"then": {
"nl": "{name}"
}
}
]
},
"allowMove": true,
"deletion": {},
"tagRenderings": [
"images",
{
"freeform": {
"key": "opening_hours",
"type": "opening_hours",
"addExtraTags": []
},
"question": {
"nl": "Wat zijn de openingsuren?"
},
"render": {
"nl": "{opening_hours_table()}"
},
"mappings": [
{
"if": {
"and": [
"opening_hours=\"by appointment\""
]
},
"then": {
"nl": "Op afspraak"
}
}
],
"id": "Composthoekjes-opening_hours"
},
{
"question": {
"nl": "Wat is de website voor meer informatie?"
},
"freeform": {
"key": "website",
"type": "url"
},
"render": {
"nl": "<a href=\"{website}\">{website}</a>"
},
"id": "Composthoekjes-website"
},
{
"question": {
"nl": "Wat is het type inzamelpunt?"
},
"mappings": [
{
"if": "recycling_type=container",
"then": "Container of vat"
},
{
"if": "recycling_type=centre",
"then": "Verwerkingsplaats of containerpark"
},
{
"if": "recycling_type=dump",
"then": "Composthoop"
}
],
"id": "Composthoekjes-type"
},
{
"question": {
"nl": "Wie mag hier organisch afval bezorgen?"
},
"mappings": [
{
"if": "access=yes",
"then": "Publiek toegankelijk"
},
{
"if": "access=no",
"then": "Privaat"
},
{
"if": "access=permessive",
"then": "Mogelijks toegelaten tot nader order"
},
{
"if": "access=",
"then": "Waarschijnlijk publiek toegankelijk",
"hideInAnswer": true
},
{
"if": "access=residents",
"then": "Bewoners van gemeente",
"hideInAnswer": "recycling_type!=centre"
}
],
"id": "Composthoekjes-voor-wie"
},
{
"question": {
"nl": "Wat is de naam van dit compost-inzamelpunt?"
},
"freeform": {
"key": "name",
"addExtraTags": ["noname="]
},
"render": {
"nl": "De naam van dit compost-inzamelpunt is {name}"
},
"mappings": [
{
"if": {"and": ["noname=yes", "name="]},
"then": "Heeft geen naam"
},
{
"if": "name=",
"then": "Geen naam bekend",
"hideInAnswer": true
}
],
"id": "Composthoekjes-name"
}],
"presets": [
{
"tags": [
"amenity=recycling",
"recycling:organic=yes"
],
"title": {
"nl": "een inzamelpunt voor organisch afval"
}
}
],
"source": { "source": {
"osmTags": { "osmTags": {
"and": [ "and": [
"recycling:organic~*" "traffic_sign~*",
"traffic_sign:issue~*"
] ]
} }
}, },
"mapRendering": [ "minzoom": 10,
"title": {
"render": {
"nl": "verkeersbord",
"en": "traffic sign"
}
},
"tagRenderings": [
"images",
{ {
"icon": { "render": {
"render": "circle:white;https://upload.wikimedia.org/wikipedia/commons/1/15/Compost_…able_waste_-_biodegradable_waste_-_biological_waste_icon.png" "nl": "ID verkeersbord: {traffic_sign}",
"en": "traffic sign ID: {traffic_sign}"
}, },
"iconSize": { "question": {
"render": "40,40,center" "nl": "Wat is het ID voor dit verkeersbord?",
"en": "What is ID for this traffic sign?"
}, },
"location": [ "freeform": {
"point" "key": "traffic_sign"
] },
"id": "trafficsign-traffic_sign"
}, },
{ {
"color": { "render": {
"render": "#00f" "nl": "Probleem bij dit verkeersbord: {traffic_sign:issue}",
"en": "Issue with this traffic sign: {traffic_sign:issue}"
}, },
"width": { "question": {
"render": "8" "nl": "Wat is het probleem met dit verkeersbord?",
} "en": "What is the issue with this traffic sign?"
},
"freeform": {
"key": "traffic_sign:issue"
},
"id": "trafficsign-traffic_sign:issue"
},
{
"question": {
"nl": "Wanneer werd dit verkeersbord laatst gesurveyed?",
"en": "When was this traffic sign last surveyed?"
},
"render": {
"nl": "Dit verkeersbord werd laatst gesurveyed op {survey:date}",
"en": "This traffic sign was last surveyed on {survey:date}"
},
"freeform": {
"key": "survey:date",
"type": "date"
},
"mappings": [
{
"if": "survey:date:={_now:date}",
"then": "Vandaag gesurveyed!"
}
],
"id": "trafficsign-survey:date"
}
],
"mapRendering": [
{
"icon": "./TS_bolt.svg",
"location": [
"point",
"centroid"
]
}
]
},
{
"id": "notrafficsign",
"name": {
"nl": "geen verkeersbord",
"en": "no traffic sign"
},
"source": {
"osmTags": {
"and": [
{
"or": [
"no:traffic_sign~*",
"not:traffic_sign~*"
]
},
"traffic_sign:issue~*"
]
}
},
"minzoom": 10,
"title": {
"render": {
"nl": "ontbrekend verkeersbord",
"en": "missing traffic sign"
}
},
"tagRenderings": [
"images",
{
"render": {
"nl": "ID ontbrekend verkeersbord: {no:traffic_sign}",
"en": "missing traffic sign ID: {no:traffic_sign}"
},
"question": {
"nl": "Wat is het ID voor het ontbrekende verkeersbord?",
"en": "What is ID for the missing traffic sign?"
},
"freeform": {
"key": "no:traffic_sign"
},
"id": "notrafficsign-no:traffic_sign"
},
{
"render": {
"nl": "Probleem bij deze situatie: {traffic_sign:issue}",
"en": "Issue with this situation: {traffic_sign:issue}"
},
"question": {
"nl": "Wat is er mis met deze situatie?",
"en": "What is the issue with this situation?"
},
"freeform": {
"key": "traffic_sign:issue"
},
"id": "notrafficsign-traffic_sign:issue"
},
{
"question": {
"nl": "Wanneer werd deze situatie laatst gesurveyed?",
"en": "When was this situation last surveyed?"
},
"render": {
"nl": "Deze situatie werd laatst gesurveyed op {survey:date}",
"en": "This situation was last surveyed on {survey:date}"
},
"freeform": {
"key": "survey:date",
"type": "date"
},
"mappings": [
{
"if": "survey:date:={_now:date}",
"then": "Vandaag gesurveyed!"
}
],
"id": "notrafficsign-survey:date"
}
],
"mapRendering": [
{
"icon": "./TS_questionmark.svg",
"location": [
"point",
"centroid"
]
} }
] ]
} }
] ],
"defaultBackgroundId": "Stamen.TonerLite"
} }
constructor() { constructor() {
super([ super([
["Walking_node_theme", () => { ["Walking_node_theme", () => {
@ -439,8 +427,13 @@ export default class LegacyThemeLoaderSpec extends T {
}, "test"); }, "test");
T.isTrue(r0.errors.length > 0, "Failing case is not detected") T.isTrue(r0.errors.length > 0, "Failing case is not detected")
} }
],
] ["Images are rewritten", () => {
const fixed = new FixImages(new Set<string>()).convertStrict(LegacyThemeLoaderSpec.verkeerde_borden, "test")
const fixedValue = fixed.layers[0]["mapRendering"][0].icon
Assert.equal("https://raw.githubusercontent.com/seppesantens/MapComplete-Themes/main/VerkeerdeBordenDatabank/TS_bolt.svg",
fixedValue)
} ]
] ]
); );
} }

View file

@ -37,14 +37,7 @@
<link href="./assets/generated/svg_mapcomplete_logo72.png" rel="apple-touch-icon" sizes="72x72"> <link href="./assets/generated/svg_mapcomplete_logo72.png" rel="apple-touch-icon" sizes="72x72">
<!-- THEME-SPECIFIC-END--> <!-- THEME-SPECIFIC-END-->
<style>
#decoration-desktop img {
width: 100%;
height: 100%;
}
</style>
</head> </head>
<body> <body>
@ -70,13 +63,15 @@
<div class="clutter absolute h-24 left-24 right-24 top-56 text-xl text-center" <div class="clutter absolute h-24 left-24 right-24 top-56 text-xl text-center"
id="centermessage" style="z-index: 4000"> id="centermessage" style="z-index: 4000">
<h2>Loading MapComplete, hang on...</h2> <h1>Loading MapComplete, hang on...</h1>
<p>Powered by OpenStreetMap</p> <p class="subtle">Powered by OpenStreetMap</p>
</div> </div>
<span class="absolute" id="belowmap" style="z-index: -1">Below</span> <span class="absolute" id="belowmap" style="z-index: -1">Below</span>
<div id="leafletDiv"></div> <div id="leafletDiv"></div>
<script src="./index.ts"></script> <script src="./index.ts"></script>
<script async data-goatcounter="https://pietervdvn.goatcounter.com/count" src="//gc.zgo.at/count.js"></script> <script async data-goatcounter="https://pietervdvn.goatcounter.com/count" src="//gc.zgo.at/count.js"></script>