forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			245 lines
		
	
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			245 lines
		
	
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
 | |
| import { QueryParameters } from "./Web/QueryParameters"
 | |
| import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
 | |
| import { FixedUiElement } from "../UI/Base/FixedUiElement"
 | |
| import { Utils } from "../Utils"
 | |
| import Combine from "../UI/Base/Combine"
 | |
| import { SubtleButton } from "../UI/Base/SubtleButton"
 | |
| import BaseUIElement from "../UI/BaseUIElement"
 | |
| import { UIEventSource } from "./UIEventSource"
 | |
| import { LocalStorageSource } from "./Web/LocalStorageSource"
 | |
| import LZString from "lz-string"
 | |
| import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
 | |
| import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
 | |
| import SharedTagRenderings from "../Customizations/SharedTagRenderings"
 | |
| import * as known_layers from "../assets/generated/known_layers.json"
 | |
| import { PrepareTheme } from "../Models/ThemeConfig/Conversion/PrepareTheme"
 | |
| import * as licenses from "../assets/generated/license_info.json"
 | |
| import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
 | |
| import { FixImages } from "../Models/ThemeConfig/Conversion/FixImages"
 | |
| import Svg from "../Svg"
 | |
| import {
 | |
|     DoesImageExist,
 | |
|     PrevalidateTheme,
 | |
|     ValidateThemeAndLayers,
 | |
| } from "../Models/ThemeConfig/Conversion/Validation"
 | |
| 
 | |
| export default class DetermineLayout {
 | |
|     private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
 | |
| 
 | |
|     /**
 | |
|      * Gets the correct layout for this website
 | |
|      */
 | |
|     public static async GetLayout(): Promise<LayoutConfig> {
 | |
|         const loadCustomThemeParam = QueryParameters.GetQueryParameter(
 | |
|             "userlayout",
 | |
|             "false",
 | |
|             "If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways: \n\n- The hash of the URL contains a base64-encoded .json-file containing the theme definition\n- The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator\n- The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme"
 | |
|         )
 | |
|         const layoutFromBase64 = decodeURIComponent(loadCustomThemeParam.data)
 | |
| 
 | |
|         if (layoutFromBase64.startsWith("http")) {
 | |
|             return await DetermineLayout.LoadRemoteTheme(layoutFromBase64)
 | |
|         }
 | |
| 
 | |
|         if (layoutFromBase64 !== "false") {
 | |
|             // We have to load something from the hash (or from disk)
 | |
|             return DetermineLayout.LoadLayoutFromHash(loadCustomThemeParam)
 | |
|         }
 | |
| 
 | |
|         let layoutId: string = undefined
 | |
| 
 | |
|         const path = window.location.pathname.split("/").slice(-1)[0]
 | |
|         if (path !== "theme.html" && path !== "") {
 | |
|             layoutId = path
 | |
|             if (path.endsWith(".html")) {
 | |
|                 layoutId = path.substr(0, path.length - 5)
 | |
|             }
 | |
|             console.log("Using layout", layoutId)
 | |
|         }
 | |
|         layoutId = QueryParameters.GetQueryParameter(
 | |
|             "layout",
 | |
|             layoutId,
 | |
|             "The layout to load into MapComplete"
 | |
|         ).data
 | |
|         return AllKnownLayouts.allKnownLayouts.get(layoutId?.toLowerCase())
 | |
|     }
 | |
| 
 | |
|     public static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>): LayoutConfig | null {
 | |
|         let hash = location.hash.substr(1)
 | |
|         let json: any
 | |
| 
 | |
|         try {
 | |
|             // layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter
 | |
|             const dedicatedHashFromLocalStorage = LocalStorageSource.Get(
 | |
|                 "user-layout-" + userLayoutParam.data?.replace(" ", "_")
 | |
|             )
 | |
|             if (dedicatedHashFromLocalStorage.data?.length < 10) {
 | |
|                 dedicatedHashFromLocalStorage.setData(undefined)
 | |
|             }
 | |
| 
 | |
|             const hashFromLocalStorage = LocalStorageSource.Get("last-loaded-user-layout")
 | |
|             if (hash.length < 10) {
 | |
|                 hash = dedicatedHashFromLocalStorage.data ?? hashFromLocalStorage.data
 | |
|             } else {
 | |
|                 console.log("Saving hash to local storage")
 | |
|                 hashFromLocalStorage.setData(hash)
 | |
|                 dedicatedHashFromLocalStorage.setData(hash)
 | |
|             }
 | |
| 
 | |
|             try {
 | |
|                 json = JSON.parse(atob(hash))
 | |
|             } catch (e) {
 | |
|                 // We try to decode with lz-string
 | |
|                 try {
 | |
|                     json = JSON.parse(Utils.UnMinify(LZString.decompressFromBase64(hash)))
 | |
|                 } catch (e) {
 | |
|                     console.error(e)
 | |
|                     DetermineLayout.ShowErrorOnCustomTheme(
 | |
|                         "Could not decode the hash",
 | |
|                         new FixedUiElement("Not a valid (LZ-compressed) JSON")
 | |
|                     )
 | |
|                     return null
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             const layoutToUse = DetermineLayout.prepCustomTheme(json)
 | |
|             userLayoutParam.setData(layoutToUse.id)
 | |
|             return layoutToUse
 | |
|         } catch (e) {
 | |
|             console.error(e)
 | |
|             if (hash === undefined || hash.length < 10) {
 | |
|                 DetermineLayout.ShowErrorOnCustomTheme(
 | |
|                     "Could not load a theme from the hash",
 | |
|                     new FixedUiElement("Hash does not contain data"),
 | |
|                     json
 | |
|                 )
 | |
|             }
 | |
|             this.ShowErrorOnCustomTheme("Could not parse the hash", new FixedUiElement(e), json)
 | |
|             return null
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public static ShowErrorOnCustomTheme(
 | |
|         intro: string = "Error: could not parse the custom layout:",
 | |
|         error: BaseUIElement,
 | |
|         json?: any
 | |
|     ) {
 | |
|         new Combine([
 | |
|             intro,
 | |
|             error.SetClass("alert"),
 | |
|             new SubtleButton(Svg.back_svg(), "Go back to the theme overview", {
 | |
|                 url: window.location.protocol + "//" + window.location.host + "/index.html",
 | |
|                 newTab: false,
 | |
|             }),
 | |
|             json !== undefined
 | |
|                 ? new SubtleButton(Svg.download_svg(), "Download the JSON file").onClick(() => {
 | |
|                       Utils.offerContentsAsDownloadableFile(
 | |
|                           JSON.stringify(json, null, "  "),
 | |
|                           "theme_definition.json"
 | |
|                       )
 | |
|                   })
 | |
|                 : undefined,
 | |
|         ])
 | |
|             .SetClass("flex flex-col clickable")
 | |
|             .AttachTo("centermessage")
 | |
|     }
 | |
| 
 | |
|     private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): LayoutConfig {
 | |
|         if (json.layers === undefined && json.tagRenderings !== undefined) {
 | |
|             const iconTr = json.mapRendering.map((mr) => mr.icon).find((icon) => icon !== undefined)
 | |
|             const icon = new TagRenderingConfig(iconTr).render.txt
 | |
|             json = {
 | |
|                 id: json.id,
 | |
|                 description: json.description,
 | |
|                 descriptionTail: {
 | |
|                     en: "<div class='alert'>Layer only mode.</div> The loaded custom theme actually isn't a custom theme, but only contains a layer.",
 | |
|                 },
 | |
|                 icon,
 | |
|                 title: json.name,
 | |
|                 layers: [json],
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         const knownLayersDict = new Map<string, LayerConfigJson>()
 | |
|         for (const key in known_layers.layers) {
 | |
|             const layer = known_layers.layers[key]
 | |
|             knownLayersDict.set(layer.id, <LayerConfigJson>layer)
 | |
|         }
 | |
|         const converState = {
 | |
|             tagRenderings: SharedTagRenderings.SharedTagRenderingJson,
 | |
|             sharedLayers: knownLayersDict,
 | |
|             publicLayers: new Set<string>(),
 | |
|         }
 | |
|         json = new FixLegacyTheme().convertStrict(json, "While loading a dynamic theme")
 | |
|         const raw = json
 | |
| 
 | |
|         json = new FixImages(DetermineLayout._knownImages).convertStrict(
 | |
|             json,
 | |
|             "While fixing the images"
 | |
|         )
 | |
|         json.enableNoteImports = json.enableNoteImports ?? false
 | |
|         json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme")
 | |
|         console.log("The layoutconfig is ", json)
 | |
| 
 | |
|         json.id = forceId ?? json.id
 | |
| 
 | |
|         {
 | |
|             let { errors } = new PrevalidateTheme().convert(json, "validation")
 | |
|             if (errors.length > 0) {
 | |
|                 throw "Detected errors: " + errors.join("\n")
 | |
|             }
 | |
|         }
 | |
|         {
 | |
|             let { errors } = new ValidateThemeAndLayers(
 | |
|                 new DoesImageExist(new Set<string>(), (_) => true),
 | |
|                 "",
 | |
|                 false,
 | |
|                 SharedTagRenderings.SharedTagRendering
 | |
|             ).convert(json, "validation")
 | |
|             if (errors.length > 0) {
 | |
|                 throw "Detected errors: " + errors.join("\n")
 | |
|             }
 | |
|         }
 | |
|         return new LayoutConfig(json, false, {
 | |
|             definitionRaw: JSON.stringify(raw, null, "  "),
 | |
|             definedAtUrl: sourceUrl,
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     private static async LoadRemoteTheme(link: string): Promise<LayoutConfig | null> {
 | |
|         console.log("Downloading map theme from ", link)
 | |
| 
 | |
|         new FixedUiElement(`Downloading the theme from the <a href="${link}">link</a>...`).AttachTo(
 | |
|             "centermessage"
 | |
|         )
 | |
| 
 | |
|         try {
 | |
|             let parsed = await Utils.downloadJson(link)
 | |
|             try {
 | |
|                 let forcedId = parsed.id
 | |
|                 const url = new URL(link)
 | |
|                 if (!(url.hostname === "localhost" || url.hostname === "127.0.0.1")) {
 | |
|                     forcedId = link
 | |
|                 }
 | |
|                 console.log("Loaded remote link:", link)
 | |
|                 return DetermineLayout.prepCustomTheme(parsed, link, forcedId)
 | |
|             } catch (e) {
 | |
|                 console.error(e)
 | |
|                 DetermineLayout.ShowErrorOnCustomTheme(
 | |
|                     `<a href="${link}">${link}</a> is invalid:`,
 | |
|                     new FixedUiElement(e),
 | |
|                     parsed
 | |
|                 )
 | |
|                 return null
 | |
|             }
 | |
|         } catch (e) {
 | |
|             console.error(e)
 | |
|             DetermineLayout.ShowErrorOnCustomTheme(
 | |
|                 `<a href="${link}">${link}</a> is invalid - probably not found or invalid JSON:`,
 | |
|                 new FixedUiElement(e)
 | |
|             )
 | |
|             return null
 | |
|         }
 | |
|     }
 | |
| }
 |