Refactoring: make needed URLs explicit

This commit is contained in:
Pieter Vander Vennet 2023-09-27 22:21:35 +02:00
parent 7852829f1b
commit 4852888b41
51 changed files with 978 additions and 871 deletions

View file

@ -1658,7 +1658,9 @@
}, },
{ {
"id": "repeated", "id": "repeated",
"labels": ["level"], "labels": [
"level"
],
"condition": "repeat_on~*", "condition": "repeat_on~*",
"render": { "render": {
"en": "Multiple, identical objects can be found on floors {repeat_on}.", "en": "Multiple, identical objects can be found on floors {repeat_on}.",
@ -1667,7 +1669,9 @@
}, },
{ {
"id": "single_level", "id": "single_level",
"labels": ["level"], "labels": [
"level"
],
"condition": "repeat_on=", "condition": "repeat_on=",
"question": { "question": {
"nl": "Op welke verdieping bevindt dit punt zich?", "nl": "Op welke verdieping bevindt dit punt zich?",

View file

@ -1,6 +1,6 @@
{ {
"name": "mapcomplete", "name": "mapcomplete",
"version": "0.33.4", "version": "0.33.5",
"repository": "https://github.com/pietervdvn/MapComplete", "repository": "https://github.com/pietervdvn/MapComplete",
"description": "A small website to edit OSM easily", "description": "A small website to edit OSM easily",
"bugs": "https://github.com/pietervdvn/MapComplete/issues", "bugs": "https://github.com/pietervdvn/MapComplete/issues",
@ -18,24 +18,11 @@
"Alternatively, you can override the `osm` credentials using the environment variables `VITE_OSM_OAUTH_CLIENT_ID` and `VITE_OSM_OAUTH_SECRET`" "Alternatively, you can override the `osm` credentials using the environment variables `VITE_OSM_OAUTH_CLIENT_ID` and `VITE_OSM_OAUTH_SECRET`"
], ],
"oauth_credentials": { "oauth_credentials": {
"osm_pietervdvn": {
"#": "This client_id is registered by 'Pieter Vander Vennet' on OSM.org",
"oauth_client_id": "sa1ngLJBJ8McmzHElN8NYtIDm5TZTYEYhq3-0snO4Qc",
"oauth_secret": "XU_cD5Mvw9VKk9T0t_gO8V7cbRC4Hmw2Tb4Rv0Zmz-U",
"url": "https://www.openstreetmap.org"
},
"osm": {
"#": "This client-id is registered by 'MapComplete' on osm.org", "#": "This client-id is registered by 'MapComplete' on osm.org",
"oauth_client_id": "K93H1d8ve7p-tVLE1ZwsQ4lAFLQk8INx5vfTLMu5DWk", "oauth_client_id": "K93H1d8ve7p-tVLE1ZwsQ4lAFLQk8INx5vfTLMu5DWk",
"oauth_secret": "NBWGhWDrD3QDB35xtVuxv4aExnmIt4FA_WgeLtwxasg", "oauth_secret": "NBWGhWDrD3QDB35xtVuxv4aExnmIt4FA_WgeLtwxasg",
"url": "https://www.openstreetmap.org" "url": "https://www.openstreetmap.org"
}, },
"osm-test": {
"oauth_client_id": "HwUn6GPxGm1m9WwMarxTglhy6dBTM4YkaV1I9h6pDGU",
"oauth_secret": "luFZtPJg7j96K6WM6RpcZ_3M-r6muuDq6fG1ygk0I_4",
"url": "https://master.apis.dev.openstreetmap.org"
}
},
"api_keys": { "api_keys": {
"#": "Various API-keys for various services. Feel free to reuse those in another MapComplete-hosted version", "#": "Various API-keys for various services. Feel free to reuse those in another MapComplete-hosted version",
"imgur": "7070e7167f0a25a", "imgur": "7070e7167f0a25a",

View file

@ -12,8 +12,8 @@ mkdir dist/assets 2> /dev/null
export NODE_OPTIONS="--max-old-space-size=8192" export NODE_OPTIONS="--max-old-space-size=8192"
# This script ends every line with '&&' to chain everything. A failure will thus stop the build # This script ends every line with '&&' to chain everything. A failure will thus stop the build
# npm run generate:editor-layer-index && npm run generate:editor-layer-index &&
# npm run generate && npm run generate &&
npm run generate:layouts npm run generate:layouts
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
@ -38,7 +38,8 @@ then
export ASSET_URL export ASSET_URL
echo "$ASSET_URL" echo "$ASSET_URL"
else else
ASSET_URL="$BRANCH" # ASSET_URL="$BRANCH"
ASSET_URL="./"
export ASSET_URL export ASSET_URL
echo "$ASSET_URL" echo "$ASSET_URL"
fi fi

View file

@ -8,6 +8,10 @@ import LayoutConfig from "../src/Models/ThemeConfig/LayoutConfig"
import xml2js from "xml2js" import xml2js from "xml2js"
import ScriptUtils from "./ScriptUtils" import ScriptUtils from "./ScriptUtils"
import { Utils } from "../src/Utils" import { Utils } from "../src/Utils"
import SpecialVisualizations from "../src/UI/SpecialVisualizations"
import Constants from "../src/Models/Constants"
import { AvailableRasterLayers, RasterLayerPolygon } from "../src/Models/RasterLayers"
import { ImmutableStore } from "../src/Logic/UIEventSource"
const sharp = require("sharp") const sharp = require("sharp")
const template = readFileSync("theme.html", "utf8") const template = readFileSync("theme.html", "utf8")
@ -195,29 +199,72 @@ function asLangSpan(t: Translation, tag = "span"): string {
if (lang === "_context") { if (lang === "_context") {
continue continue
} }
values.push(`<${tag} lang='${lang}'>${t.translations[lang]}</${tag}>`) values.push(`<${tag} lang="${lang}">${t.translations[lang]}</${tag}>`)
} }
return values.join("\n") return values.join("\n")
} }
let cspCached: string = undefined let previousSrc: Set<string> = new Set<string>()
function generateCsp(): string { function generateCsp(layout: LayoutConfig): string {
if (cspCached !== undefined) { const apiUrls: string[] = [
return cspCached "self",
...Constants.defaultOverpassUrls,
Constants.countryCoderEndpoint,
"https://api.openstreetmap.org",
"https://pietervdvn.goatcounter.com",
].concat(...SpecialVisualizations.specialVisualizations.map((sv) => sv.needsUrls))
const geojsonSources: string[] = layout.layers.map((l) => l.source?.geojsonSource)
const hosts = new Set<string>()
const eliLayers: RasterLayerPolygon[] = AvailableRasterLayers.layersAvailableAt(
new ImmutableStore({ lon: 0, lat: 0 })
).data
const vectorLayers = eliLayers.filter((l) => l.properties.type === "vector")
const vectorSources = vectorLayers.map((l) => l.properties.url)
apiUrls.push(...vectorSources)
for (const connectSource of apiUrls.concat(geojsonSources)) {
if (!connectSource) {
continue
} }
try {
const url = new URL(connectSource)
hosts.add("https://" + url.host)
} catch (e) {
hosts.add(connectSource)
}
}
const connectSrc = Array.from(hosts).sort()
const newSrcs = connectSrc.filter((newItem) => !previousSrc.has(newItem))
console.log(
"Got",
hosts.size,
"connect-src items for theme",
layout.id,
"(extra sources: ",
newSrcs.join(" ") + ")"
)
previousSrc = hosts
const csp = { const csp = {
"default-src": "'self'", "default-src": "'self'",
"script-src": "'self'", "script-src": "'self' https://gc.zgo.at/count.js",
"img-src": "*", "img-src": "* data:", // maplibre depends on 'data:' to load
"connect-src": "*", "connect-src": connectSrc.join(" "),
"report-to": "https://report.mapcomplete.org/csp",
"worker-src": "'self' blob:", // Vite somehow loads the worker via a 'blob'
"style-src": "'self' 'unsafe-inline'", // unsafe-inline is needed to change the default background pin colours
} }
const content = Object.keys(csp) const content = Object.keys(csp)
.map((k) => k + ": " + csp[k]) .map((k) => k + " " + csp[k])
.join("; ") .join("; ")
cspCached = `<meta http-equiv="Content-Security-Policy" content="${content}">` return [
return cspCached `<meta http-equiv ="Report-To" content='{"group":"csp-endpoint", "max_age": 86400,"endpoints": [\{"url": "https://report.mapcomplete.org/csp"}], "include_subdomains": true}'>`,
`<meta http-equiv="Content-Security-Policy" content="${content}">`,
].join("\n")
} }
async function createLandingPage(layout: LayoutConfig, manifest, whiteIcons, alreadyWritten) { async function createLandingPage(layout: LayoutConfig, manifest, whiteIcons, alreadyWritten) {
@ -290,7 +337,7 @@ async function createLandingPage(layout: LayoutConfig, manifest, whiteIcons, alr
...apple_icons, ...apple_icons,
].join("\n") ].join("\n")
const loadingText = Translations.t.general.loadingTheme.Subs({ theme: ogTitle }) const loadingText = Translations.t.general.loadingTheme.Subs({ theme: layout.title })
let output = template let output = template
.replace("Loading MapComplete, hang on...", asLangSpan(loadingText, "h1")) .replace("Loading MapComplete, hang on...", asLangSpan(loadingText, "h1"))
@ -299,7 +346,7 @@ async function createLandingPage(layout: LayoutConfig, manifest, whiteIcons, alr
Translations.t.general.poweredByOsm.textFor(targetLanguage) Translations.t.general.poweredByOsm.textFor(targetLanguage)
) )
.replace(/<!-- THEME-SPECIFIC -->.*<!-- THEME-SPECIFIC-END-->/s, themeSpecific) .replace(/<!-- THEME-SPECIFIC -->.*<!-- THEME-SPECIFIC-END-->/s, themeSpecific)
.replace(/<!-- CSP -->/, generateCsp()) .replace(/<!-- CSP -->/, generateCsp(layout))
.replace( .replace(
/<!-- DESCRIPTION START -->.*<!-- DESCRIPTION END -->/s, /<!-- DESCRIPTION START -->.*<!-- DESCRIPTION END -->/s,
asLangSpan(layout.shortDescription) asLangSpan(layout.shortDescription)
@ -311,7 +358,7 @@ async function createLandingPage(layout: LayoutConfig, manifest, whiteIcons, alr
.replace( .replace(
'<script src="./src/index.ts" type="module"></script>', '<script src="./src/index.ts" type="module"></script>',
`<script type="module" src='./index_${layout.id}.ts'></script>` `<script type="module" src="./index_${layout.id}.ts"></script>`
) )
return output return output

View file

@ -4,7 +4,6 @@ hosted.mapcomplete.org {
header { header {
+Permissions-Policy "interest-cohort=()" +Permissions-Policy "interest-cohort=()"
+Report-To `\{"group":"csp-endpoint", "max_age": 86400,"endpoints": [\{"url": "https://report.mapcomplete.org/csp"}], "include_subdomains": true}` +Report-To `\{"group":"csp-endpoint", "max_age": 86400,"endpoints": [\{"url": "https://report.mapcomplete.org/csp"}], "include_subdomains": true}`
+Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://gc.zgo.at ; img-src * ; report-uri https://report.mapcomplete.org/csp ; report-to csp-endpoint ;"
} }
} }

View file

@ -10,14 +10,15 @@
# unzip tiles.zip # unzip tiles.zip
MAPCOMPLETE_CONFIGURATION="config_hetzner" MAPCOMPLETE_CONFIGURATION="config_hetzner"
cp config.json config.json.bu &&
cp ./scripts/hetzner/config.json . && # Copy the config _before_ building, as the config might contain some needed URLs
npm run reset:layeroverview npm run reset:layeroverview
npm run test npm run test
cp config.json config.json.bu &&
cp ./scripts/hetzner/config.json . &&
npm run prepare-deploy && npm run prepare-deploy &&
mv config.json.bu config.json && mv config.json.bu config.json &&
zip dist.zip -r dist/* && zip dist.zip -r dist/* &&
scp -r dist.zip hetzner:/root/ && scp -r dist.zip hetzner:/root/ &&
scp ./scripts/hetzner/config/* hetzner:/root/ echo "Upload completed, deploying config and booting" &&
ssh hetzner -t "unzip dist.zip && rm dist.zip && rm -rf public/ && mv dist public && caddy stop && caddy start" rsync -rzh --progress dist.zip hetzner:/root/ &&
ssh hetzner -t "unzip dist.zip && rm dist.zip && rm -rf public/ && mv dist public && caddy stop && caddy start" &&
rm dist.zip rm dist.zip

View file

@ -0,0 +1,13 @@
export {}
window.addEventListener("load", async () => {
if (!("serviceWorker" in navigator)) {
console.log("Service workers are not supported")
return
}
try {
await navigator.serviceWorker.register("/service-worker.js")
console.log("Service worker registration successful")
} catch (err) {
console.error("Service worker registration failed", err)
}
})

View file

@ -23,27 +23,27 @@ export default class AllImageProviders {
) )
), ),
] ]
public static apiUrls: string[] = [].concat(
...AllImageProviders.ImageAttributionSource.map((src) => src.apiUrls())
)
public static defaultKeys = [].concat(
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes)
)
private static providersByName = { private static providersByName = {
imgur: Imgur.singleton, imgur: Imgur.singleton,
mapillary: Mapillary.singleton, mapillary: Mapillary.singleton,
wikidata: WikidataImageProvider.singleton, wikidata: WikidataImageProvider.singleton,
wikimedia: WikimediaImageProvider.singleton, wikimedia: WikimediaImageProvider.singleton,
} }
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
}
public static defaultKeys = [].concat(
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes)
)
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map< private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
string, string,
UIEventSource<ProvidedImage[]> UIEventSource<ProvidedImage[]>
>() >()
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
}
public static LoadImagesFor( public static LoadImagesFor(
tags: Store<Record<string, string>>, tags: Store<Record<string, string>>,
tagKey?: string[] tagKey?: string[]

View file

@ -3,6 +3,10 @@ import ImageProvider, { ProvidedImage } from "./ImageProvider"
export default class GenericImageProvider extends ImageProvider { export default class GenericImageProvider extends ImageProvider {
public defaultKeyPrefixes: string[] = ["image"] public defaultKeyPrefixes: string[] = ["image"]
public apiUrls(): string[] {
return []
}
private readonly _valuePrefixBlacklist: string[] private readonly _valuePrefixBlacklist: string[]
public constructor(valuePrefixBlacklist: string[]) { public constructor(valuePrefixBlacklist: string[]) {

View file

@ -65,4 +65,6 @@ export default abstract class ImageProvider {
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>
public abstract DownloadAttribution(url: string): Promise<LicenseInfo> public abstract DownloadAttribution(url: string): Promise<LicenseInfo>
public abstract apiUrls(): string[]
} }

View file

@ -4,16 +4,23 @@ import { Utils } from "../../Utils";
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants";
import { LicenseInfo } from "./LicenseInfo"; import { LicenseInfo } from "./LicenseInfo";
import { ImageUploader } from "./ImageUploader"; import { ImageUploader } from "./ImageUploader";
import Img from "../../UI/Base/Img";
export class Imgur extends ImageProvider implements ImageUploader { export class Imgur extends ImageProvider implements ImageUploader {
public static readonly defaultValuePrefix = ["https://i.imgur.com"] public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur() public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"] public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10 public readonly maxFileSizeInMegabytes = 10
public static readonly apiUrl = "https://api.imgur.com/3/image"
private constructor() { private constructor() {
super() super()
} }
apiUrls(): string[] {
return [Imgur.apiUrl]
}
/** /**
* Uploads an image, returns the URL where to find the image * Uploads an image, returns the URL where to find the image
* @param title * @param title
@ -24,8 +31,8 @@ export class Imgur extends ImageProvider implements ImageUploader{
title: string, title: string,
description: string, description: string,
blob: File blob: File
): Promise<{ key: string, value: string }> { ): Promise<{ key: string; value: string }> {
const apiUrl = "https://api.imgur.com/3/image" const apiUrl = Imgur.apiUrl
const apiKey = Constants.ImgurApiKey const apiKey = Constants.ImgurApiKey
const formData = new FormData() const formData = new FormData()
@ -33,7 +40,6 @@ export class Imgur extends ImageProvider implements ImageUploader{
formData.append("title", title) formData.append("title", title)
formData.append("description", description) formData.append("description", description)
const settings: RequestInit = { const settings: RequestInit = {
method: "POST", method: "POST",
body: formData, body: formData,

View file

@ -17,6 +17,10 @@ export class Mapillary extends ImageProvider {
] ]
defaultKeyPrefixes = ["mapillary", "image"] defaultKeyPrefixes = ["mapillary", "image"]
apiUrls(): string[] {
return ["https://mapillary.com", "https://www.mapillary.com", "https://graph.mapillary.com"]
}
/** /**
* Indicates that this is the same URL * Indicates that this is the same URL
* Ignores 'stp' parameter * Ignores 'stp' parameter

View file

@ -5,6 +5,9 @@ import { WikimediaImageProvider } from "./WikimediaImageProvider"
import Wikidata from "../Web/Wikidata" import Wikidata from "../Web/Wikidata"
export class WikidataImageProvider extends ImageProvider { export class WikidataImageProvider extends ImageProvider {
public apiUrls(): string[] {
return Wikidata.neededUrls
}
public static readonly singleton = new WikidataImageProvider() public static readonly singleton = new WikidataImageProvider()
public readonly defaultKeyPrefixes = ["wikidata"] public readonly defaultKeyPrefixes = ["wikidata"]

View file

@ -11,11 +11,11 @@ import Wikimedia from "../Web/Wikimedia"
*/ */
export class WikimediaImageProvider extends ImageProvider { export class WikimediaImageProvider extends ImageProvider {
public static readonly singleton = new WikimediaImageProvider() public static readonly singleton = new WikimediaImageProvider()
public static readonly commonsPrefixes = [ public static readonly apiUrls = [
"https://commons.wikimedia.org/wiki/", "https://commons.wikimedia.org/wiki/",
"https://upload.wikimedia.org", "https://upload.wikimedia.org",
"File:",
] ]
public static readonly commonsPrefixes = [...WikimediaImageProvider.apiUrls, "File:"]
private readonly commons_key = "wikimedia_commons" private readonly commons_key = "wikimedia_commons"
public readonly defaultKeyPrefixes = [this.commons_key, "image"] public readonly defaultKeyPrefixes = [this.commons_key, "image"]
@ -66,6 +66,10 @@ export class WikimediaImageProvider extends ImageProvider {
return value return value
} }
apiUrls(): string[] {
return WikimediaImageProvider.apiUrls
}
SourceIcon(backlink: string): BaseUIElement { SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg().SetStyle("width:2em;height: 2em") const img = Svg.wikimedia_commons_white_svg().SetStyle("width:2em;height: 2em")
if (backlink === undefined) { if (backlink === undefined) {

View file

@ -32,11 +32,12 @@ export default class Maproulette {
private readonly apiKey: string private readonly apiKey: string
public static singleton = new Maproulette() public static singleton = new Maproulette()
public static readonly defaultEndpoint = "https://maproulette.org/api/v2"
/** /**
* Creates a new Maproulette instance * Creates a new Maproulette instance
* @param endpoint The API endpoint to use * @param endpoint The API endpoint to use
*/ */
constructor(endpoint: string = "https://maproulette.org/api/v2") { constructor(endpoint: string = Maproulette.defaultEndpoint) {
this.endpoint = endpoint this.endpoint = endpoint
this.apiKey = Constants.MaprouletteApiKey this.apiKey = Constants.MaprouletteApiKey
} }

View file

@ -0,0 +1,6 @@
export interface AuthConfig {
"#"?: string // optional comment
oauth_client_id: string
oauth_secret: string
url: string
}

View file

@ -1,63 +1,55 @@
// @ts-ignore // @ts-ignore
import { osmAuth } from "osm-auth"; import { osmAuth } from "osm-auth"
import { Store, Stores, UIEventSource } from "../UIEventSource"; import { Store, Stores, UIEventSource } from "../UIEventSource"
import { OsmPreferences } from "./OsmPreferences"; import { OsmPreferences } from "./OsmPreferences"
import { Utils } from "../../Utils"; import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"; import { LocalStorageSource } from "../Web/LocalStorageSource"
import * as config from "../../../package.json"; import { AuthConfig } from "./AuthConfig"
import Constants from "../../Models/Constants"
export default class UserDetails { export default class UserDetails {
public loggedIn = false; public loggedIn = false
public name = "Not logged in"; public name = "Not logged in"
public uid: number; public uid: number
public csCount = 0; public csCount = 0
public img?: string; public img?: string
public unreadMessages = 0; public unreadMessages = 0
public totalMessages: number = 0; public totalMessages: number = 0
public home: { lon: number; lat: number }; public home: { lon: number; lat: number }
public backend: string; public backend: string
public account_created: string; public account_created: string
public tracesCount: number = 0; public tracesCount: number = 0
public description: string; public description: string
constructor(backend: string) { constructor(backend: string) {
this.backend = backend; this.backend = backend
} }
} }
export interface AuthConfig {
"#"?: string; // optional comment
oauth_client_id: string;
oauth_secret: string;
url: string;
}
export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable" export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable"
export class OsmConnection { export class OsmConnection {
public static readonly oauth_configs: Record<string, AuthConfig> = public auth
config.config.oauth_credentials; public userDetails: UIEventSource<UserDetails>
public auth; public isLoggedIn: Store<boolean>
public userDetails: UIEventSource<UserDetails>;
public isLoggedIn: Store<boolean>;
public gpxServiceIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>( public gpxServiceIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>(
"unknown" "unknown"
); )
public apiIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>( public apiIsOnline: UIEventSource<OsmServiceState> = new UIEventSource<OsmServiceState>(
"unknown" "unknown"
); )
public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">( public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">(
"not-attempted" "not-attempted"
); )
public preferencesHandler: OsmPreferences; public preferencesHandler: OsmPreferences
public readonly _oauth_config: AuthConfig; public readonly _oauth_config: AuthConfig
private readonly _dryRun: Store<boolean>; private readonly _dryRun: Store<boolean>
private fakeUser: boolean; private readonly fakeUser: boolean
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []; private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []
private readonly _iframeMode: Boolean | boolean; private readonly _iframeMode: Boolean | boolean
private readonly _singlePage: boolean; private readonly _singlePage: boolean
private isChecking = false; private isChecking = false
constructor(options?: { constructor(options?: {
dryRun?: Store<boolean> dryRun?: Store<boolean>
@ -65,86 +57,83 @@ export class OsmConnection {
oauth_token?: UIEventSource<string> oauth_token?: UIEventSource<string>
// Used to keep multiple changesets open and to write to the correct changeset // Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean singlePage?: boolean
osmConfiguration?: "osm" | "osm-test"
attemptLogin?: true | boolean attemptLogin?: true | boolean
}) { }) {
options = options ?? {}; options ??= {}
this.fakeUser = options.fakeUser ?? false; this.fakeUser = options?.fakeUser ?? false
this._singlePage = options.singlePage ?? true; this._singlePage = options?.singlePage ?? true
this._oauth_config = this._oauth_config = Constants.osmAuthConfig
OsmConnection.oauth_configs[options.osmConfiguration ?? "osm"] ?? console.debug("Using backend", this._oauth_config.url)
OsmConnection.oauth_configs.osm; this._iframeMode = Utils.runningFromConsole ? false : window !== window.top
console.debug("Using backend", this._oauth_config.url);
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
// Check if there are settings available in environment variables, and if so, use those // Check if there are settings available in environment variables, and if so, use those
if ( if (
import.meta.env.VITE_OSM_OAUTH_CLIENT_ID !== undefined && import.meta.env.VITE_OSM_OAUTH_CLIENT_ID !== undefined &&
import.meta.env.VITE_OSM_OAUTH_SECRET !== undefined import.meta.env.VITE_OSM_OAUTH_SECRET !== undefined
) { ) {
console.debug("Using environment variables for oauth config"); console.debug("Using environment variables for oauth config")
this._oauth_config = { this._oauth_config = {
oauth_client_id: import.meta.env.VITE_OSM_OAUTH_CLIENT_ID, oauth_client_id: import.meta.env.VITE_OSM_OAUTH_CLIENT_ID,
oauth_secret: import.meta.env.VITE_OSM_OAUTH_SECRET, oauth_secret: import.meta.env.VITE_OSM_OAUTH_SECRET,
url: "https://api.openstreetmap.org" url: "https://api.openstreetmap.org",
}; }
} }
this.userDetails = new UIEventSource<UserDetails>( this.userDetails = new UIEventSource<UserDetails>(
new UserDetails(this._oauth_config.url), new UserDetails(this._oauth_config.url),
"userDetails" "userDetails"
); )
if (options.fakeUser) { if (options.fakeUser) {
const ud = this.userDetails.data; const ud = this.userDetails.data
ud.csCount = 5678; ud.csCount = 5678
ud.loggedIn = true; ud.loggedIn = true
ud.unreadMessages = 0; ud.unreadMessages = 0
ud.name = "Fake user"; ud.name = "Fake user"
ud.totalMessages = 42; ud.totalMessages = 42
} }
const self = this; const self = this
this.UpdateCapabilities(); this.UpdateCapabilities()
this.isLoggedIn = this.userDetails.map( this.isLoggedIn = this.userDetails.map(
(user) => (user) =>
user.loggedIn && user.loggedIn &&
(self.apiIsOnline.data === "unknown" || self.apiIsOnline.data === "online"), (self.apiIsOnline.data === "unknown" || self.apiIsOnline.data === "online"),
[this.apiIsOnline] [this.apiIsOnline]
); )
this.isLoggedIn.addCallback((isLoggedIn) => { this.isLoggedIn.addCallback((isLoggedIn) => {
if (self.userDetails.data.loggedIn == false && isLoggedIn == true) { if (self.userDetails.data.loggedIn == false && isLoggedIn == true) {
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do // We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
// This means someone attempted to toggle this; so we attempt to login! // This means someone attempted to toggle this; so we attempt to login!
self.AttemptLogin(); self.AttemptLogin()
} }
}); })
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false); this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.updateAuthObject(); this.updateAuthObject()
this.preferencesHandler = new OsmPreferences( this.preferencesHandler = new OsmPreferences(
this.auth, this.auth,
<any /*This is needed to make the tests work*/>this <any /*This is needed to make the tests work*/>this
); )
if (options.oauth_token?.data !== undefined) { if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data); console.log(options.oauth_token.data)
const self = this; const self = this
this.auth.bootstrapToken( this.auth.bootstrapToken(
options.oauth_token.data, options.oauth_token.data,
(x) => { (x) => {
console.log("Called back: ", x); console.log("Called back: ", x)
self.AttemptLogin(); self.AttemptLogin()
}, },
this.auth this.auth
); )
options.oauth_token.setData(undefined); options.oauth_token.setData(undefined)
} }
if (this.auth.authenticated() && options.attemptLogin !== false) { if (this.auth.authenticated() && options.attemptLogin !== false) {
this.AttemptLogin(); // Also updates the user badge this.AttemptLogin() // Also updates the user badge
} else { } else {
console.log("Not authenticated"); console.log("Not authenticated")
} }
} }
@ -156,25 +145,25 @@ export class OsmConnection {
prefix?: string prefix?: string
} }
): UIEventSource<string> { ): UIEventSource<string> {
return this.preferencesHandler.GetPreference(key, defaultValue, options); return this.preferencesHandler.GetPreference(key, defaultValue, options)
} }
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> { public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
return this.preferencesHandler.GetLongPreference(key, prefix); return this.preferencesHandler.GetLongPreference(key, prefix)
} }
public OnLoggedIn(action: (userDetails: UserDetails) => void) { public OnLoggedIn(action: (userDetails: UserDetails) => void) {
this._onLoggedIn.push(action); this._onLoggedIn.push(action)
} }
public LogOut() { public LogOut() {
this.auth.logout(); this.auth.logout()
this.userDetails.data.loggedIn = false; this.userDetails.data.loggedIn = false
this.userDetails.data.csCount = 0; this.userDetails.data.csCount = 0
this.userDetails.data.name = ""; this.userDetails.data.name = ""
this.userDetails.ping(); this.userDetails.ping()
console.log("Logged out"); console.log("Logged out")
this.loadingStatus.setData("not-attempted"); this.loadingStatus.setData("not-attempted")
} }
/** /**
@ -183,95 +172,95 @@ export class OsmConnection {
* new OsmConnection().Backend() // => "https://www.openstreetmap.org" * new OsmConnection().Backend() // => "https://www.openstreetmap.org"
*/ */
public Backend(): string { public Backend(): string {
return this._oauth_config.url; return this._oauth_config.url
} }
public AttemptLogin() { public AttemptLogin() {
this.UpdateCapabilities(); this.UpdateCapabilities()
this.loadingStatus.setData("loading"); this.loadingStatus.setData("loading")
if (this.fakeUser) { if (this.fakeUser) {
this.loadingStatus.setData("logged-in"); this.loadingStatus.setData("logged-in")
console.log("AttemptLogin called, but ignored as fakeUser is set"); console.log("AttemptLogin called, but ignored as fakeUser is set")
return; return
} }
const self = this; const self = this
console.log("Trying to log in..."); console.log("Trying to log in...")
this.updateAuthObject(); this.updateAuthObject()
LocalStorageSource.Get("location_before_login").setData( LocalStorageSource.Get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href Utils.runningFromConsole ? undefined : window.location.href
); )
this.auth.xhr( this.auth.xhr(
{ {
method: "GET", method: "GET",
path: "/api/0.6/user/details" path: "/api/0.6/user/details",
}, },
function (err, details) { function (err, details) {
if (err != null) { if (err != null) {
console.log(err); console.log(err)
self.loadingStatus.setData("error"); self.loadingStatus.setData("error")
if (err.status == 401) { if (err.status == 401) {
console.log("Clearing tokens..."); console.log("Clearing tokens...")
// Not authorized - our token probably got revoked // Not authorized - our token probably got revoked
self.auth.logout(); self.auth.logout()
self.LogOut(); self.LogOut()
} }
return; return
} }
if (details == null) { if (details == null) {
self.loadingStatus.setData("error"); self.loadingStatus.setData("error")
return; return
} }
self.CheckForMessagesContinuously(); self.CheckForMessagesContinuously()
// details is an XML DOM of user details // details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0]; let userInfo = details.getElementsByTagName("user")[0]
let data = self.userDetails.data; let data = self.userDetails.data
data.loggedIn = true; data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo); console.log("Login completed, userinfo is ", userInfo)
data.name = userInfo.getAttribute("display_name"); data.name = userInfo.getAttribute("display_name")
data.account_created = userInfo.getAttribute("account_created"); data.account_created = userInfo.getAttribute("account_created")
data.uid = Number(userInfo.getAttribute("id")); data.uid = Number(userInfo.getAttribute("id"))
data.csCount = Number.parseInt( data.csCount = Number.parseInt(
userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? 0 userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? 0
); )
data.tracesCount = Number.parseInt( data.tracesCount = Number.parseInt(
userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? 0 userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? 0
); )
data.img = undefined; data.img = undefined
const imgEl = userInfo.getElementsByTagName("img"); const imgEl = userInfo.getElementsByTagName("img")
if (imgEl !== undefined && imgEl[0] !== undefined) { if (imgEl !== undefined && imgEl[0] !== undefined) {
data.img = imgEl[0].getAttribute("href"); data.img = imgEl[0].getAttribute("href")
} }
const description = userInfo.getElementsByTagName("description"); const description = userInfo.getElementsByTagName("description")
if (description !== undefined && description[0] !== undefined) { if (description !== undefined && description[0] !== undefined) {
data.description = description[0]?.innerHTML; data.description = description[0]?.innerHTML
} }
const homeEl = userInfo.getElementsByTagName("home"); const homeEl = userInfo.getElementsByTagName("home")
if (homeEl !== undefined && homeEl[0] !== undefined) { if (homeEl !== undefined && homeEl[0] !== undefined) {
const lat = parseFloat(homeEl[0].getAttribute("lat")); const lat = parseFloat(homeEl[0].getAttribute("lat"))
const lon = parseFloat(homeEl[0].getAttribute("lon")); const lon = parseFloat(homeEl[0].getAttribute("lon"))
data.home = { lat: lat, lon: lon }; data.home = { lat: lat, lon: lon }
} }
self.loadingStatus.setData("logged-in"); self.loadingStatus.setData("logged-in")
const messages = userInfo const messages = userInfo
.getElementsByTagName("messages")[0] .getElementsByTagName("messages")[0]
.getElementsByTagName("received")[0]; .getElementsByTagName("received")[0]
data.unreadMessages = parseInt(messages.getAttribute("unread")); data.unreadMessages = parseInt(messages.getAttribute("unread"))
data.totalMessages = parseInt(messages.getAttribute("count")); data.totalMessages = parseInt(messages.getAttribute("count"))
self.userDetails.ping(); self.userDetails.ping()
for (const action of self._onLoggedIn) { for (const action of self._onLoggedIn) {
action(self.userDetails.data); action(self.userDetails.data)
} }
self._onLoggedIn = []; self._onLoggedIn = []
} }
); )
} }
/** /**
@ -290,20 +279,20 @@ export class OsmConnection {
{ {
method, method,
options: { options: {
header header,
}, },
content, content,
path: `/api/0.6/${path}` path: `/api/0.6/${path}`,
}, },
function (err, response) { function (err, response) {
if (err !== null) { if (err !== null) {
error(err); error(err)
} else { } else {
ok(response); ok(response)
} }
} }
); )
}); })
} }
public async post( public async post(
@ -311,7 +300,7 @@ export class OsmConnection {
content?: string, content?: string,
header?: Record<string, string | number> header?: Record<string, string | number>
): Promise<any> { ): Promise<any> {
return await this.interact(path, "POST", header, content); return await this.interact(path, "POST", header, content)
} }
public async put( public async put(
@ -319,60 +308,60 @@ export class OsmConnection {
content?: string, content?: string,
header?: Record<string, string | number> header?: Record<string, string | number>
): Promise<any> { ): Promise<any> {
return await this.interact(path, "PUT", header, content); return await this.interact(path, "PUT", header, content)
} }
public async get(path: string, header?: Record<string, string | number>): Promise<any> { public async get(path: string, header?: Record<string, string | number>): Promise<any> {
return await this.interact(path, "GET", header); return await this.interact(path, "GET", header)
} }
public closeNote(id: number | string, text?: string): Promise<void> { public closeNote(id: number | string, text?: string): Promise<void> {
let textSuffix = ""; let textSuffix = ""
if ((text ?? "") !== "") { if ((text ?? "") !== "") {
textSuffix = "?text=" + encodeURIComponent(text); textSuffix = "?text=" + encodeURIComponent(text)
} }
if (this._dryRun.data) { if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text); console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text)
return new Promise((ok) => { return new Promise((ok) => {
ok(); ok()
}); })
} }
return this.post(`notes/${id}/close${textSuffix}`); return this.post(`notes/${id}/close${textSuffix}`)
} }
public reopenNote(id: number | string, text?: string): Promise<void> { public reopenNote(id: number | string, text?: string): Promise<void> {
if (this._dryRun.data) { if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text); console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text)
return new Promise((ok) => { return new Promise((ok) => {
ok(); ok()
}); })
} }
let textSuffix = ""; let textSuffix = ""
if ((text ?? "") !== "") { if ((text ?? "") !== "") {
textSuffix = "?text=" + encodeURIComponent(text); textSuffix = "?text=" + encodeURIComponent(text)
} }
return this.post(`notes/${id}/reopen${textSuffix}`); return this.post(`notes/${id}/reopen${textSuffix}`)
} }
public async openNote(lat: number, lon: number, text: string): Promise<{ id: number }> { public async openNote(lat: number, lon: number, text: string): Promise<{ id: number }> {
if (this._dryRun.data) { if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually opening note with text ", text); console.warn("Dryrun enabled - not actually opening note with text ", text)
return new Promise<{ id: number }>((ok) => { return new Promise<{ id: number }>((ok) => {
window.setTimeout( window.setTimeout(
() => ok({ id: Math.floor(Math.random() * 1000) }), () => ok({ id: Math.floor(Math.random() * 1000) }),
Math.random() * 5000 Math.random() * 5000
); )
}); })
} }
// Lat and lon must be strings for the API to accept it // Lat and lon must be strings for the API to accept it
const content = `lat=${lat}&lon=${lon}&text=${encodeURIComponent(text)}` const content = `lat=${lat}&lon=${lon}&text=${encodeURIComponent(text)}`
const response = await this.post("notes.json", content, { const response = await this.post("notes.json", content, {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}); })
const parsed = JSON.parse(response); const parsed = JSON.parse(response)
const id = parsed.properties; const id = parsed.properties
console.log("OPENED NOTE", id); console.log("OPENED NOTE", id)
return id; return id
} }
public async uploadGpxTrack( public async uploadGpxTrack(
@ -390,61 +379,61 @@ export class OsmConnection {
} }
): Promise<{ id: number }> { ): Promise<{ id: number }> {
if (this._dryRun.data) { if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually uploading GPX ", gpx); console.warn("Dryrun enabled - not actually uploading GPX ", gpx)
return new Promise<{ id: number }>((ok, error) => { return new Promise<{ id: number }>((ok, error) => {
window.setTimeout( window.setTimeout(
() => ok({ id: Math.floor(Math.random() * 1000) }), () => ok({ id: Math.floor(Math.random() * 1000) }),
Math.random() * 5000 Math.random() * 5000
); )
}); })
} }
const contents = { const contents = {
file: gpx, file: gpx,
description: options.description ?? "", description: options.description ?? "",
tags: options.labels?.join(",") ?? "", tags: options.labels?.join(",") ?? "",
visibility: options.visibility visibility: options.visibility,
}; }
const extras = { const extras = {
file: file:
"; filename=\"" + '; filename="' +
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) + (options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
"\"\r\nContent-Type: application/gpx+xml" '"\r\nContent-Type: application/gpx+xml',
}; }
const boundary = "987654"; const boundary = "987654"
let body = ""; let body = ""
for (const key in contents) { for (const key in contents) {
body += "--" + boundary + "\r\n"; body += "--" + boundary + "\r\n"
body += "Content-Disposition: form-data; name=\"" + key + "\""; body += 'Content-Disposition: form-data; name="' + key + '"'
if (extras[key] !== undefined) { if (extras[key] !== undefined) {
body += extras[key]; body += extras[key]
} }
body += "\r\n\r\n"; body += "\r\n\r\n"
body += contents[key] + "\r\n"; body += contents[key] + "\r\n"
} }
body += "--" + boundary + "--\r\n"; body += "--" + boundary + "--\r\n"
const response = await this.post("gpx/create", body, { const response = await this.post("gpx/create", body, {
"Content-Type": "multipart/form-data; boundary=" + boundary, "Content-Type": "multipart/form-data; boundary=" + boundary,
"Content-Length": body.length "Content-Length": body.length,
}); })
const parsed = JSON.parse(response); const parsed = JSON.parse(response)
console.log("Uploaded GPX track", parsed); console.log("Uploaded GPX track", parsed)
return { id: parsed }; return { id: parsed }
} }
public addCommentToNote(id: number | string, text: string): Promise<void> { public addCommentToNote(id: number | string, text: string): Promise<void> {
if (this._dryRun.data) { if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id); console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id)
return new Promise((ok) => { return new Promise((ok) => {
ok(); ok()
}); })
} }
if ((text ?? "") === "") { if ((text ?? "") === "") {
throw "Invalid text!"; throw "Invalid text!"
} }
return new Promise((ok, error) => { return new Promise((ok, error) => {
@ -452,17 +441,17 @@ export class OsmConnection {
{ {
method: "POST", method: "POST",
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}` path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`,
}, },
function (err, _) { function (err, _) {
if (err !== null) { if (err !== null) {
error(err); error(err)
} else { } else {
ok(); ok()
} }
} }
); )
}); })
} }
/** /**
@ -471,31 +460,31 @@ export class OsmConnection {
public finishLogin(callback: (previousURL: string) => void) { public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () { this.auth.authenticate(function () {
// Fully authed at this point // Fully authed at this point
console.log("Authentication successful!"); console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login"); const previousLocation = LocalStorageSource.Get("location_before_login")
callback(previousLocation.data); callback(previousLocation.data)
}); })
} }
private updateAuthObject() { private updateAuthObject() {
let pwaStandAloneMode = false; let pwaStandAloneMode = false
try { try {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
pwaStandAloneMode = true; pwaStandAloneMode = true
} else if ( } else if (
window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches window.matchMedia("(display-mode: fullscreen)").matches
) { ) {
pwaStandAloneMode = true; pwaStandAloneMode = true
} }
} catch (e) { } catch (e) {
console.warn( console.warn(
"Detecting standalone mode failed", "Detecting standalone mode failed",
e, e,
". Assuming in browser and not worrying furhter" ". Assuming in browser and not worrying furhter"
); )
} }
const standalone = this._iframeMode || pwaStandAloneMode || !this._singlePage; const standalone = this._iframeMode || pwaStandAloneMode || !this._singlePage
// In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway... // In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway...
// Same for an iframe... // Same for an iframe...
@ -508,46 +497,46 @@ export class OsmConnection {
? "https://mapcomplete.org/land.html" ? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html", : window.location.protocol + "//" + window.location.host + "/land.html",
singlepage: !standalone, singlepage: !standalone,
auto: true auto: true,
}); })
} }
private CheckForMessagesContinuously() { private CheckForMessagesContinuously() {
const self = this; const self = this
if (this.isChecking) { if (this.isChecking) {
return; return
} }
this.isChecking = true; this.isChecking = true
Stores.Chronic(5 * 60 * 1000).addCallback((_) => { Stores.Chronic(5 * 60 * 1000).addCallback((_) => {
if (self.isLoggedIn.data) { if (self.isLoggedIn.data) {
console.log("Checking for messages"); console.log("Checking for messages")
self.AttemptLogin(); self.AttemptLogin()
} }
}); })
} }
private UpdateCapabilities(): void { private UpdateCapabilities(): void {
const self = this; const self = this
this.FetchCapabilities().then(({ api, gpx }) => { this.FetchCapabilities().then(({ api, gpx }) => {
self.apiIsOnline.setData(api); self.apiIsOnline.setData(api)
self.gpxServiceIsOnline.setData(gpx); self.gpxServiceIsOnline.setData(gpx)
}); })
} }
private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> { private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return { api: "online", gpx: "online" }; return { api: "online", gpx: "online" }
} }
const result = await Utils.downloadAdvanced(this.Backend() + "/api/0.6/capabilities"); const result = await Utils.downloadAdvanced(this.Backend() + "/api/0.6/capabilities")
if (result["content"] === undefined) { if (result["content"] === undefined) {
console.log("Something went wrong:", result); console.log("Something went wrong:", result)
return { api: "unreachable", gpx: "unreachable" }; return { api: "unreachable", gpx: "unreachable" }
} }
const xmlRaw = result["content"]; const xmlRaw = result["content"]
const parsed = new DOMParser().parseFromString(xmlRaw, "text/xml"); const parsed = new DOMParser().parseFromString(xmlRaw, "text/xml")
const statusEl = parsed.getElementsByTagName("status")[0]; const statusEl = parsed.getElementsByTagName("status")[0]
const api = <OsmServiceState>statusEl.getAttribute("api"); const api = <OsmServiceState>statusEl.getAttribute("api")
const gpx = <OsmServiceState>statusEl.getAttribute("gpx"); const gpx = <OsmServiceState>statusEl.getAttribute("gpx")
return { api, gpx }; return { api, gpx }
} }
} }

View file

@ -28,14 +28,8 @@ class FeatureSwitchUtils {
export class OsmConnectionFeatureSwitches { export class OsmConnectionFeatureSwitches {
public readonly featureSwitchFakeUser: UIEventSource<boolean> public readonly featureSwitchFakeUser: UIEventSource<boolean>
public readonly featureSwitchApiURL: UIEventSource<string>
constructor() { constructor() {
this.featureSwitchApiURL = QueryParameters.GetQueryParameter(
"backend",
"osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'"
)
this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter( this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter(
"fake-user", "fake-user",
@ -143,7 +137,6 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
let testingDefaultValue = false let testingDefaultValue = false
if ( if (
this.featureSwitchApiURL.data !== "osm-test" &&
!Utils.runningFromConsole && !Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1") (location.hostname === "localhost" || location.hostname === "127.0.0.1")
) { ) {

View file

@ -1,9 +1,9 @@
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoOperations } from "../GeoOperations" import { GeoOperations } from "../GeoOperations"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource" import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
import { Mapillary } from "../ImageProviders/Mapillary"
import P4C from "pic4carto" import P4C from "pic4carto"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
export interface NearbyImageOptions { export interface NearbyImageOptions {
lon: number lon: number
lat: number lat: number
@ -35,17 +35,12 @@ export interface P4CPicture {
} }
/** /**
* Uses Pic4wCarto to fetch nearby images from various providers * Uses Pic4Carto to fetch nearby images from various providers
*/ */
export default class NearbyImagesSearch { export default class NearbyImagesSearch {
private static readonly services = [ public static readonly services = ["mapillary", "flickr", "kartaview", "wikicommons"] as const
"mapillary", public static readonly apiUrls = ["https://api.flickr.com"]
"flickr", private readonly individualStores: Store<{ images: P4CPicture[]; beforeFilter: number }>[]
"openstreetcam",
"wikicommons",
] as const
private individualStores
private readonly _store: UIEventSource<P4CPicture[]> = new UIEventSource<P4CPicture[]>([]) private readonly _store: UIEventSource<P4CPicture[]> = new UIEventSource<P4CPicture[]>([])
public readonly store: Store<P4CPicture[]> = this._store public readonly store: Store<P4CPicture[]> = this._store
private readonly _options: NearbyImageOptions private readonly _options: NearbyImageOptions
@ -71,16 +66,16 @@ export default class NearbyImagesSearch {
this.update() this.update()
} }
private static buildPictureFetcher( private static async fetchImages(
options: NearbyImageOptions, options: NearbyImageOptions,
fetcher: "mapillary" | "flickr" | "openstreetcam" | "wikicommons" fetcher: P4CService
): Store<{ images: P4CPicture[]; beforeFilter: number }> { ): Promise<P4CPicture[]> {
const picManager = new P4C.PicturesManager({ usefetchers: [fetcher] }) const picManager = new P4C.PicturesManager({ usefetchers: [fetcher] })
const searchRadius = options.searchRadius ?? 100
const maxAgeSeconds = (options.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000 const maxAgeSeconds = (options.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000
const searchRadius = options.searchRadius ?? 100
const p4cStore = Stores.FromPromise<P4CPicture[]>( try {
picManager.startPicsRetrievalAround( const pics: P4CPicture[] = await picManager.startPicsRetrievalAround(
new P4C.LatLng(options.lat, options.lon), new P4C.LatLng(options.lat, options.lon),
searchRadius, searchRadius,
{ {
@ -88,7 +83,21 @@ export default class NearbyImagesSearch {
towardscenter: false, towardscenter: false,
} }
) )
return pics
} catch (e) {
console.error("Could not fetch images from service", fetcher, e)
return []
}
}
private static buildPictureFetcher(
options: NearbyImageOptions,
fetcher: P4CService
): Store<{ images: P4CPicture[]; beforeFilter: number }> {
const p4cStore = Stores.FromPromise<P4CPicture[]>(
NearbyImagesSearch.fetchImages(options, fetcher)
) )
const searchRadius = options.searchRadius ?? 100
return p4cStore.map( return p4cStore.map(
(images) => { (images) => {
if (images === undefined) { if (images === undefined) {
@ -220,3 +229,5 @@ class ImagesInLoadedDataFetcher {
return foundImages return foundImages
} }
} }
type P4CService = (typeof NearbyImagesSearch.services)[number]

View file

@ -1,7 +1,7 @@
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
export default class PlantNet { export default class PlantNet {
private static baseUrl = public static baseUrl =
"https://my-api.plantnet.org/v2/identify/all?api-key=2b10AAsjzwzJvucA5Ncm5qxe" "https://my-api.plantnet.org/v2/identify/all?api-key=2b10AAsjzwzJvucA5Ncm5qxe"
public static query(imageUrls: string[]): Promise<PlantNetResult> { public static query(imageUrls: string[]): Promise<PlantNetResult> {

View file

@ -123,6 +123,11 @@ export interface WikidataAdvancedSearchoptions extends WikidataSearchoptions {
* Utility functions around wikidata * Utility functions around wikidata
*/ */
export default class Wikidata { export default class Wikidata {
public static readonly neededUrls = [
"https://www.wikidata.org/",
"https://wikidata.org/",
"https://query.wikidata.org",
]
private static readonly _identifierPrefixes = ["Q", "L"].map((str) => str.toLowerCase()) private static readonly _identifierPrefixes = ["Q", "L"].map((str) => str.toLowerCase())
private static readonly _prefixesToRemove = [ private static readonly _prefixesToRemove = [
"https://www.wikidata.org/wiki/Lexeme:", "https://www.wikidata.org/wiki/Lexeme:",
@ -130,11 +135,11 @@ export default class Wikidata {
"http://www.wikidata.org/entity/", "http://www.wikidata.org/entity/",
"Lexeme:", "Lexeme:",
].map((str) => str.toLowerCase()) ].map((str) => str.toLowerCase())
private static readonly _storeCache = new Map< private static readonly _storeCache = new Map<
string, string,
Store<{ success: WikidataResponse } | { error: any }> Store<{ success: WikidataResponse } | { error: any }>
>() >()
/** /**
* Same as LoadWikidataEntry, but wrapped into a UIEventSource * Same as LoadWikidataEntry, but wrapped into a UIEventSource
* @param value * @param value
@ -388,6 +393,7 @@ export default class Wikidata {
} }
private static _cache = new Map<string, Promise<WikidataResponse>>() private static _cache = new Map<string, Promise<WikidataResponse>>()
public static async LoadWikidataEntryAsync(value: string | number): Promise<WikidataResponse> { public static async LoadWikidataEntryAsync(value: string | number): Promise<WikidataResponse> {
const key = "" + value const key = "" + value
const cached = Wikidata._cache.get(key) const cached = Wikidata._cache.get(key)
@ -398,6 +404,7 @@ export default class Wikidata {
Wikidata._cache.set(key, uncached) Wikidata._cache.set(key, uncached)
return uncached return uncached
} }
/** /**
* Loads a wikidata page * Loads a wikidata page
* @returns the entity of the given value * @returns the entity of the given value

View file

@ -34,6 +34,8 @@ export default class Wikipedia {
private static readonly idsToRemove = ["sjabloon_zie"] private static readonly idsToRemove = ["sjabloon_zie"]
public static readonly neededUrls = ["*.wikipedia.org"]
private static readonly _cache = new Map<string, Promise<string>>() private static readonly _cache = new Map<string, Promise<string>>()
private static _fullDetailsCache = new Map<string, Store<FullWikipediaDetails>>() private static _fullDetailsCache = new Map<string, Store<FullWikipediaDetails>>()
public readonly backend: string public readonly backend: string

View file

@ -1,6 +1,7 @@
import * as packagefile from "../../package.json" import * as packagefile from "../../package.json"
import * as extraconfig from "../../config.json" import * as extraconfig from "../../config.json"
import { Utils } from "../Utils" import { Utils } from "../Utils"
import { AuthConfig } from "../Logic/Osm/AuthConfig"
export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number] export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number]
@ -104,7 +105,8 @@ export default class Constants {
public static ImgurApiKey = Constants.config.api_keys.imgur public static ImgurApiKey = Constants.config.api_keys.imgur
public static readonly mapillary_client_token_v4 = Constants.config.api_keys.mapillary_v4 public static readonly mapillary_client_token_v4 = Constants.config.api_keys.mapillary_v4
public static defaultOverpassUrls = Constants.config.default_overpass_urls public static defaultOverpassUrls = Constants.config.default_overpass_urls
static countryCoderEndpoint: string = Constants.config.country_coder_host public static countryCoderEndpoint: string = Constants.config.country_coder_host
public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials
/** /**
* These are the values that are allowed to use as 'backdrop' icon for a map pin * These are the values that are allowed to use as 'backdrop' icon for a map pin

View file

@ -140,8 +140,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
"oauth_token", "oauth_token",
undefined, undefined,
"Used to complete the login" "Used to complete the login"
), )
osmConfiguration: <"osm" | "osm-test">this.featureSwitches.featureSwitchApiURL.data,
}) })
this.userRelatedState = new UserRelatedState( this.userRelatedState = new UserRelatedState(
this.osmConnection, this.osmConnection,

View file

@ -22,8 +22,7 @@ export default class AllThemesGui {
"oauth_token", "oauth_token",
undefined, undefined,
"Used to complete the login" "Used to complete the login"
), )
osmConfiguration: <"osm" | "osm-test">featureSwitches.featureSwitchApiURL.data,
}) })
const state = new UserRelatedState(osmConnection) const state = new UserRelatedState(osmConnection)
const intro = new Combine([ const intro = new Combine([

View file

@ -11,6 +11,7 @@ import { Utils } from "../../Utils"
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
export class OpenJosm extends Combine { export class OpenJosm extends Combine {
public static readonly needsUrls = ["http://127.0.0.1:8111/load_and_zoom"]
constructor(osmConnection: OsmConnection, bounds: Store<BBox>, iconStyle?: string) { constructor(osmConnection: OsmConnection, bounds: Store<BBox>, iconStyle?: string) {
const t = Translations.t.general.attribution const t = Translations.t.general.attribution

View file

@ -10,9 +10,11 @@ import Combine from "../Base/Combine"
import Title from "../Base/Title" import Title from "../Base/Title"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import Constants from "../../Models/Constants"
export class AddNoteCommentViz implements SpecialVisualization { export class AddNoteCommentViz implements SpecialVisualization {
funcName = "add_note_comment" funcName = "add_note_comment"
needsUrls = [Constants.osmAuthConfig.url]
docs = "A textfield to add a comment to a node (with the option to close the note)." docs = "A textfield to add a comment to a node (with the option to close the note)."
args = [ args = [
{ {

View file

@ -9,7 +9,6 @@ import { Utils } from "../../Utils"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import { VariableUiElement } from "../Base/VariableUIElement" import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading" import Loading from "../Base/Loading"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Changes } from "../../Logic/Osm/Changes" import { Changes } from "../../Logic/Osm/Changes"
@ -209,6 +208,8 @@ class ApplyButton extends UIElement {
export default class AutoApplyButton implements SpecialVisualization { export default class AutoApplyButton implements SpecialVisualization {
public readonly docs: BaseUIElement public readonly docs: BaseUIElement
public readonly funcName: string = "auto_apply" public readonly funcName: string = "auto_apply"
public readonly needsUrls = []
public readonly args: { public readonly args: {
name: string name: string
defaultValue?: string defaultValue?: string
@ -271,14 +272,7 @@ export default class AutoApplyButton implements SpecialVisualization {
argument: string[] argument: string[]
): BaseUIElement { ): BaseUIElement {
try { try {
if ( if (!state.layout.official && !state.featureSwitchIsTesting.data) {
!state.layout.official &&
!(
state.featureSwitchIsTesting.data ||
state.osmConnection._oauth_config.url ===
OsmConnection.oauth_configs["osm-test"].url
)
) {
const t = Translations.t.general.add.import const t = Translations.t.general.add.import
return new Combine([ return new Combine([
new FixedUiElement( new FixedUiElement(

View file

@ -8,9 +8,11 @@ import Toggle from "../Input/Toggle"
import { LoginToggle } from "./LoginButton" import { LoginToggle } from "./LoginButton"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import Constants from "../../Models/Constants"
export class CloseNoteButton implements SpecialVisualization { export class CloseNoteButton implements SpecialVisualization {
public readonly funcName = "close_note" public readonly funcName = "close_note"
public readonly needsUrls = [Constants.osmAuthConfig.url]
public readonly docs = public readonly docs =
"Button to close a note. A predifined text can be defined to close the note with. If the note is already closed, will show a small text." "Button to close a note. A predifined text can be defined to close the note with. If the note is already closed, will show a small text."
public readonly args = [ public readonly args = [

View file

@ -13,7 +13,7 @@ export class ExportAsGpxViz implements SpecialVisualization {
funcName = "export_as_gpx" funcName = "export_as_gpx"
docs = "Exports the selected feature as GPX-file" docs = "Exports the selected feature as GPX-file"
args = [] args = []
needsUrls = []
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>, tagSource: UIEventSource<Record<string, string>>,

View file

@ -2,10 +2,13 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import Histogram from "../BigComponents/Histogram" import Histogram from "../BigComponents/Histogram"
import { Feature } from "geojson" import { Feature } from "geojson"
import Constants from "../../Models/Constants"
export class HistogramViz implements SpecialVisualization { export class HistogramViz implements SpecialVisualization {
funcName = "histogram" funcName = "histogram"
docs = "Create a histogram for a list of given values, read from the properties." docs = "Create a histogram for a list of given values, read from the properties."
needsUrls = []
example = example =
'`{histogram(\'some_key\')}` with properties being `{some_key: ["a","b","a","c"]} to create a histogram' '`{histogram(\'some_key\')}` with properties being `{some_key: ["a","b","a","c"]} to create a histogram'
args = [ args = [

View file

@ -24,6 +24,7 @@ export interface ConflateFlowArguments extends ImportFlowArguments {
export default class ConflateImportButtonViz implements SpecialVisualization, AutoAction { export default class ConflateImportButtonViz implements SpecialVisualization, AutoAction {
supportsAutoAction: boolean = true supportsAutoAction: boolean = true
needsUrls = []
public readonly funcName: string = "conflate_button" public readonly funcName: string = "conflate_button"
public readonly args: { public readonly args: {
name: string name: string

View file

@ -194,10 +194,7 @@ export default abstract class ImportFlow<ArgT extends ImportFlowArguments> {
return { error: t.hasBeenImported } return { error: t.hasBeenImported }
} }
const usesTestUrl = if (!state.layout.official && !isTesting) {
this.state.osmConnection._oauth_config.url ===
OsmConnection.oauth_configs["osm-test"].url
if (!state.layout.official && !(isTesting || usesTestUrl)) {
// Unofficial theme - imports not allowed // Unofficial theme - imports not allowed
return { return {
error: t.officialThemesOnly, error: t.officialThemesOnly,

View file

@ -18,6 +18,7 @@ export class PointImportButtonViz implements SpecialVisualization {
public readonly docs: string | BaseUIElement public readonly docs: string | BaseUIElement
public readonly example?: string public readonly example?: string
public readonly args: { name: string; defaultValue?: string; doc: string }[] public readonly args: { name: string; defaultValue?: string; doc: string }[]
public needsUrls = []
constructor() { constructor() {
this.funcName = "import_button" this.funcName = "import_button"

View file

@ -20,6 +20,7 @@ import FullNodeDatabaseSource from "../../../Logic/FeatureSource/TiledFeatureSou
*/ */
export default class WayImportButtonViz implements AutoAction, SpecialVisualization { export default class WayImportButtonViz implements AutoAction, SpecialVisualization {
public readonly funcName: string = "import_way_button" public readonly funcName: string = "import_way_button"
needsUrls = []
public readonly docs: string = public readonly docs: string =
"This button will copy the data from an external dataset into OpenStreetMap, copying the geometry and adding it as a 'line'" + "This button will copy the data from an external dataset into OpenStreetMap, copying the geometry and adding it as a 'line'" +
ImportFlowUtils.documentationGeneral ImportFlowUtils.documentationGeneral

View file

@ -20,6 +20,7 @@ import { Feature } from "geojson"
export class LanguageElement implements SpecialVisualization { export class LanguageElement implements SpecialVisualization {
funcName: string = "language_chooser" funcName: string = "language_chooser"
needsUrls = []
docs: string | BaseUIElement = docs: string | BaseUIElement =
"The language element allows to show and pick all known (modern) languages. The key can be set" "The language element allows to show and pick all known (modern) languages. The key can be set"

View file

@ -9,6 +9,8 @@ import MapillaryLink from "../BigComponents/MapillaryLink.svelte"
export class MapillaryLinkVis implements SpecialVisualization { export class MapillaryLinkVis implements SpecialVisualization {
funcName = "mapillary_link" funcName = "mapillary_link"
docs = "Adds a button to open mapillary on the specified location" docs = "Adds a button to open mapillary on the specified location"
needsUrls = []
args = [ args = [
{ {
name: "zoom", name: "zoom",

View file

@ -13,6 +13,7 @@ import { BBox } from "../../Logic/BBox"
export class MinimapViz implements SpecialVisualization { export class MinimapViz implements SpecialVisualization {
funcName = "minimap" funcName = "minimap"
docs = "A small map showing the selected feature." docs = "A small map showing the selected feature."
needsUrls = []
args = [ args = [
{ {
doc: "The (maximum) zoomlevel: the target zoomlevel after fitting the entire feature. The minimap will fit the entire feature, then zoom out to this zoom level. The higher, the more zoomed in with 1 being the entire world and 19 being really close", doc: "The (maximum) zoomlevel: the target zoomlevel after fitting the entire feature. The minimap will fit the entire feature, then zoom out to this zoom level. The higher, the more zoomed in with 1 being the entire world and 19 being really close",

View file

@ -4,6 +4,7 @@ import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisua
export class MultiApplyViz implements SpecialVisualization { export class MultiApplyViz implements SpecialVisualization {
funcName = "multi_apply" funcName = "multi_apply"
needsUrls = []
docs = docs =
"A button to apply the tagging of this object onto a list of other features. This is an advanced feature for which you'll need calculatedTags" "A button to apply the tagging of this object onto a list of other features. This is an advanced feature for which you'll need calculatedTags"
args = [ args = [

View file

@ -8,9 +8,10 @@ import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import SvelteUIElement from "../Base/SvelteUIElement" import SvelteUIElement from "../Base/SvelteUIElement"
import PlantNet from "../PlantNet/PlantNet.svelte" import PlantNet from "../PlantNet/PlantNet.svelte"
import { default as PlantNetCode } from "../../Logic/Web/PlantNet"
export class PlantNetDetectionViz implements SpecialVisualization { export class PlantNetDetectionViz implements SpecialVisualization {
funcName = "plantnet_detection" funcName = "plantnet_detection"
needsUrls = [PlantNetCode.baseUrl]
docs = docs =
"Sends the images linked to the current object to plantnet.org and asks it what plant species is shown on it. The user can then select the correct species; the corresponding wikidata-identifier will then be added to the object (together with `source:species:wikidata=plantnet.org AI`). " "Sends the images linked to the current object to plantnet.org and asks it what plant species is shown on it. The user can then select the correct species; the corresponding wikidata-identifier will then be added to the object (together with `source:species:wikidata=plantnet.org AI`). "

View file

@ -11,6 +11,8 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
*/ */
export default class QuestionViz implements SpecialVisualization { export default class QuestionViz implements SpecialVisualization {
funcName = "questions" funcName = "questions"
needsUrls = []
docs = docs =
"The special element which shows the questions which are unkown. Added by default if not yet there" "The special element which shows the questions which are unkown. Added by default if not yet there"
args = [ args = [

View file

@ -15,6 +15,7 @@ export class ShareLinkViz implements SpecialVisualization {
doc: "The url to share (default: current URL)", doc: "The url to share (default: current URL)",
}, },
] ]
needsUrls = []
public constr( public constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,

View file

@ -21,6 +21,7 @@ import Maproulette from "../../Logic/Maproulette"
export default class TagApplyButton implements AutoAction, SpecialVisualization { export default class TagApplyButton implements AutoAction, SpecialVisualization {
public readonly funcName = "tag_apply" public readonly funcName = "tag_apply"
needsUrls = []
public readonly docs = public readonly docs =
"Shows a big button; clicking this button will apply certain tags onto the feature.\n\nThe first argument takes a specification of which tags to add.\n" + "Shows a big button; clicking this button will apply certain tags onto the feature.\n\nThe first argument takes a specification of which tags to add.\n" +
Utils.Special_visualizations_tagsToApplyHelpText Utils.Special_visualizations_tagsToApplyHelpText

View file

@ -2,6 +2,7 @@ import UploadTraceToOsmUI from "../BigComponents/UploadTraceToOsmUI"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import { GeoOperations } from "../../Logic/GeoOperations" import { GeoOperations } from "../../Logic/GeoOperations"
import Constants from "../../Models/Constants"
/** /**
* Wrapper around 'UploadTraceToOsmUI' * Wrapper around 'UploadTraceToOsmUI'
@ -11,6 +12,7 @@ export class UploadToOsmViz implements SpecialVisualization {
docs = docs =
"Uploads the GPS-history as GPX to OpenStreetMap.org; clears the history afterwards. The actual feature is ignored." "Uploads the GPS-history as GPX to OpenStreetMap.org; clears the history afterwards. The actual feature is ignored."
args = [] args = []
needsUrls = [Constants.osmAuthConfig.url]
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,

View file

@ -0,0 +1,32 @@
export {}
let lang = (
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator["userLanguage"] ||
"en"
).substr(0, 2)
function filterLangs(maindiv: HTMLElement) {
let foundLangs = 0
for (const child of Array.from(maindiv.children)) {
if (child.attributes.getNamedItem("lang")?.value === lang) {
foundLangs++
}
}
if (foundLangs === 0) {
lang = "en"
}
for (const child of Array.from(maindiv.children)) {
const childLang = child.attributes.getNamedItem("lang")
if (childLang === undefined) {
continue
}
if (childLang.value === lang) {
continue
}
child.parentElement.removeChild(child)
}
}
filterLangs(document.getElementById("descriptions-while-loading"))
filterLangs(document.getElementById("default-title"))

View file

@ -1,104 +1,108 @@
import { Store, UIEventSource } from "../Logic/UIEventSource"; import { Store, UIEventSource } from "../Logic/UIEventSource"
import BaseUIElement from "./BaseUIElement"; import BaseUIElement from "./BaseUIElement"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource"; import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource"
import { OsmConnection } from "../Logic/Osm/OsmConnection"; import { OsmConnection } from "../Logic/Osm/OsmConnection"
import { Changes } from "../Logic/Osm/Changes"; import { Changes } from "../Logic/Osm/Changes"
import { ExportableMap, MapProperties } from "../Models/MapProperties"; import { ExportableMap, MapProperties } from "../Models/MapProperties"
import LayerState from "../Logic/State/LayerState"; import LayerState from "../Logic/State/LayerState"
import { Feature, Geometry, Point } from "geojson"; import { Feature, Geometry, Point } from "geojson"
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
import { MangroveIdentity } from "../Logic/Web/MangroveReviews"; import { MangroveIdentity } from "../Logic/Web/MangroveReviews"
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore"; import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import FeatureSwitchState from "../Logic/State/FeatureSwitchState"; import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
import { MenuState } from "../Models/MenuState"; import { MenuState } from "../Models/MenuState"
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"; import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"
import { RasterLayerPolygon } from "../Models/RasterLayers"; import { RasterLayerPolygon } from "../Models/RasterLayers"
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"; import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { OsmTags } from "../Models/OsmFeature"; import { OsmTags } from "../Models/OsmFeature"
/** /**
* The state needed to render a special Visualisation. * The state needed to render a special Visualisation.
*/ */
export interface SpecialVisualizationState { export interface SpecialVisualizationState {
readonly guistate: MenuState; readonly guistate: MenuState
readonly layout: LayoutConfig; readonly layout: LayoutConfig
readonly featureSwitches: FeatureSwitchState; readonly featureSwitches: FeatureSwitchState
readonly layerState: LayerState; readonly layerState: LayerState
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>>, trackFeature?(feature: { properties: OsmTags }) }; readonly featureProperties: {
getStore(id: string): UIEventSource<Record<string, string>>
trackFeature?(feature: { properties: OsmTags })
}
readonly indexedFeatures: IndexedFeatureSource; readonly indexedFeatures: IndexedFeatureSource
/** /**
* Some features will create a new element that should be displayed. * Some features will create a new element that should be displayed.
* These can be injected by appending them to this featuresource (and pinging it) * These can be injected by appending them to this featuresource (and pinging it)
*/ */
readonly newFeatures: WritableFeatureSource; readonly newFeatures: WritableFeatureSource
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>; readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>
readonly osmConnection: OsmConnection; readonly osmConnection: OsmConnection
readonly featureSwitchUserbadge: Store<boolean>; readonly featureSwitchUserbadge: Store<boolean>
readonly featureSwitchIsTesting: Store<boolean>; readonly featureSwitchIsTesting: Store<boolean>
readonly changes: Changes; readonly changes: Changes
readonly osmObjectDownloader: OsmObjectDownloader; readonly osmObjectDownloader: OsmObjectDownloader
/** /**
* State of the main map * State of the main map
*/ */
readonly mapProperties: MapProperties & ExportableMap; readonly mapProperties: MapProperties & ExportableMap
readonly selectedElement: UIEventSource<Feature>; readonly selectedElement: UIEventSource<Feature>
/** /**
* Works together with 'selectedElement' to indicate what properties should be displayed * Works together with 'selectedElement' to indicate what properties should be displayed
*/ */
readonly selectedLayer: UIEventSource<LayerConfig>; readonly selectedLayer: UIEventSource<LayerConfig>
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>; readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>
/** /**
* If data is currently being fetched from external sources * If data is currently being fetched from external sources
*/ */
readonly dataIsLoading: Store<boolean>; readonly dataIsLoading: Store<boolean>
/** /**
* Only needed for 'ReplaceGeometryAction' * Only needed for 'ReplaceGeometryAction'
*/ */
readonly fullNodeDatabase?: FullNodeDatabaseSource; readonly fullNodeDatabase?: FullNodeDatabaseSource
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>; readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
readonly userRelatedState: { readonly userRelatedState: {
readonly imageLicense: UIEventSource<string>; readonly imageLicense: UIEventSource<string>
readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full"> readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
readonly mangroveIdentity: MangroveIdentity readonly mangroveIdentity: MangroveIdentity
readonly showAllQuestionsAtOnce: UIEventSource<boolean> readonly showAllQuestionsAtOnce: UIEventSource<boolean>
readonly preferencesAsTags: Store<Record<string, string>> readonly preferencesAsTags: Store<Record<string, string>>
readonly language: UIEventSource<string> readonly language: UIEventSource<string>
}; }
readonly lastClickObject: WritableFeatureSource; readonly lastClickObject: WritableFeatureSource
readonly availableLayers: Store<RasterLayerPolygon[]>; readonly availableLayers: Store<RasterLayerPolygon[]>
readonly imageUploadManager: ImageUploadManager; readonly imageUploadManager: ImageUploadManager
} }
export interface SpecialVisualization { export interface SpecialVisualization {
readonly funcName: string; readonly funcName: string
readonly docs: string | BaseUIElement; readonly docs: string | BaseUIElement
readonly example?: string; readonly example?: string
readonly needsUrls: string[]
/** /**
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included * Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included
*/ */
readonly needsNodeDatabase?: boolean; readonly needsNodeDatabase?: boolean
readonly args: { readonly args: {
name: string name: string
defaultValue?: string defaultValue?: string
doc: string doc: string
required?: false | boolean required?: false | boolean
}[]; }[]
readonly getLayerDependencies?: (argument: string[]) => string[]; readonly getLayerDependencies?: (argument: string[]) => string[]
structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[]; structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[]
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
@ -106,7 +110,7 @@ export interface SpecialVisualization {
argument: string[], argument: string[],
feature: Feature, feature: Feature,
layer: LayerConfig layer: LayerConfig
): BaseUIElement; ): BaseUIElement
} }
export type RenderingSpecification = export type RenderingSpecification =

View file

@ -1,71 +1,79 @@
import Combine from "./Base/Combine"; import Combine from "./Base/Combine"
import { FixedUiElement } from "./Base/FixedUiElement"; import { FixedUiElement } from "./Base/FixedUiElement"
import BaseUIElement from "./BaseUIElement"; import BaseUIElement from "./BaseUIElement"
import Title from "./Base/Title"; import Title from "./Base/Title"
import Table from "./Base/Table"; import Table from "./Base/Table"
import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization"; import {
import { HistogramViz } from "./Popup/HistogramViz"; RenderingSpecification,
import { MinimapViz } from "./Popup/MinimapViz"; SpecialVisualization,
import { ShareLinkViz } from "./Popup/ShareLinkViz"; SpecialVisualizationState,
import { UploadToOsmViz } from "./Popup/UploadToOsmViz"; } from "./SpecialVisualization"
import { MultiApplyViz } from "./Popup/MultiApplyViz"; import { HistogramViz } from "./Popup/HistogramViz"
import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz"; import { MinimapViz } from "./Popup/MinimapViz"
import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz"; import { ShareLinkViz } from "./Popup/ShareLinkViz"
import TagApplyButton from "./Popup/TagApplyButton"; import { UploadToOsmViz } from "./Popup/UploadToOsmViz"
import { CloseNoteButton } from "./Popup/CloseNoteButton"; import { MultiApplyViz } from "./Popup/MultiApplyViz"
import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis"; import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz"
import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"; import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz"
import AllTagsPanel from "./Popup/AllTagsPanel.svelte"; import TagApplyButton from "./Popup/TagApplyButton"
import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"; import { CloseNoteButton } from "./Popup/CloseNoteButton"
import { ImageCarousel } from "./Image/ImageCarousel"; import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis"
import { VariableUiElement } from "./Base/VariableUIElement"; import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import { Utils } from "../Utils"; import AllTagsPanel from "./Popup/AllTagsPanel.svelte"
import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata"; import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"
import { Translation } from "./i18n/Translation"; import { ImageCarousel } from "./Image/ImageCarousel"
import Translations from "./i18n/Translations"; import { VariableUiElement } from "./Base/VariableUIElement"
import ReviewForm from "./Reviews/ReviewForm"; import { Utils } from "../Utils"
import ReviewElement from "./Reviews/ReviewElement"; import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata"
import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"; import { Translation } from "./i18n/Translation"
import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"; import Translations from "./i18n/Translations"
import { SubtleButton } from "./Base/SubtleButton"; import ReviewForm from "./Reviews/ReviewForm"
import Svg from "../Svg"; import ReviewElement from "./Reviews/ReviewElement"
import NoteCommentElement from "./Popup/NoteCommentElement"; import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"
import { SubstitutedTranslation } from "./SubstitutedTranslation"; import { SubtleButton } from "./Base/SubtleButton"
import List from "./Base/List"; import Svg from "../Svg"
import StatisticsPanel from "./BigComponents/StatisticsPanel"; import NoteCommentElement from "./Popup/NoteCommentElement"
import AutoApplyButton from "./Popup/AutoApplyButton"; import { SubstitutedTranslation } from "./SubstitutedTranslation"
import { LanguageElement } from "./Popup/LanguageElement"; import List from "./Base/List"
import FeatureReviews from "../Logic/Web/MangroveReviews"; import StatisticsPanel from "./BigComponents/StatisticsPanel"
import Maproulette from "../Logic/Maproulette"; import AutoApplyButton from "./Popup/AutoApplyButton"
import SvelteUIElement from "./Base/SvelteUIElement"; import { LanguageElement } from "./Popup/LanguageElement"
import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource"; import FeatureReviews from "../Logic/Web/MangroveReviews"
import QuestionViz from "./Popup/QuestionViz"; import Maproulette from "../Logic/Maproulette"
import { Feature, Point } from "geojson"; import SvelteUIElement from "./Base/SvelteUIElement"
import { GeoOperations } from "../Logic/GeoOperations"; import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource"
import CreateNewNote from "./Popup/CreateNewNote.svelte"; import QuestionViz from "./Popup/QuestionViz"
import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte"; import { Feature, Point } from "geojson"
import UserProfile from "./BigComponents/UserProfile.svelte"; import { GeoOperations } from "../Logic/GeoOperations"
import LanguagePicker from "./LanguagePicker"; import CreateNewNote from "./Popup/CreateNewNote.svelte"
import Link from "./Base/Link"; import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import UserProfile from "./BigComponents/UserProfile.svelte"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"; import LanguagePicker from "./LanguagePicker"
import { OsmTags, WayId } from "../Models/OsmFeature"; import Link from "./Base/Link"
import MoveWizard from "./Popup/MoveWizard"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import SplitRoadWizard from "./Popup/SplitRoadWizard"; import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz"; import { OsmTags, WayId } from "../Models/OsmFeature"
import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte"; import MoveWizard from "./Popup/MoveWizard"
import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte"; import SplitRoadWizard from "./Popup/SplitRoadWizard"
import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz"; import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz"
import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz"; import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte"
import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz"; import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte"
import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte"; import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz"
import { OpenJosm } from "./BigComponents/OpenJosm"; import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz"
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"; import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz"
import FediverseValidator from "./InputElement/Validators/FediverseValidator"; import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte"
import SendEmail from "./Popup/SendEmail.svelte"; import { OpenJosm } from "./BigComponents/OpenJosm"
import NearbyImages from "./Popup/NearbyImages.svelte"; import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"; import FediverseValidator from "./InputElement/Validators/FediverseValidator"
import UploadImage from "./Image/UploadImage.svelte"; import SendEmail from "./Popup/SendEmail.svelte"
import NearbyImages from "./Popup/NearbyImages.svelte"
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"
import UploadImage from "./Image/UploadImage.svelte"
import { Imgur } from "../Logic/ImageProviders/Imgur"
import Constants from "../Models/Constants"
import { MangroveReviews } from "mangrove-reviews-typescript"
import Wikipedia from "../Logic/Web/Wikipedia"
import NearbyImagesSearch from "../Logic/Web/NearbyImagesSearch"
class NearbyImageVis implements SpecialVisualization { class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -79,7 +87,7 @@ class NearbyImageVis implements SpecialVisualization {
docs = docs =
"A component showing nearby images loaded from various online services such as Mapillary. In edit mode and when used on a feature, the user can select an image to add to the feature" "A component showing nearby images loaded from various online services such as Mapillary. In edit mode and when used on a feature, the user can select an image to add to the feature"
funcName = "nearby_images" funcName = "nearby_images"
needsUrls = NearbyImagesSearch.apiUrls
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tags: UIEventSource<Record<string, string>>, tags: UIEventSource<Record<string, string>>,
@ -117,6 +125,7 @@ class StealViz implements SpecialVisualization {
required: true, required: true,
}, },
] ]
needsUrls = []
constr(state: SpecialVisualizationState, featureTags, args) { constr(state: SpecialVisualizationState, featureTags, args) {
const [featureIdKey, layerAndtagRenderingIds] = args const [featureIdKey, layerAndtagRenderingIds] = args
@ -264,7 +273,6 @@ export default class SpecialVisualizations {
SpecialVisualizations.specialVisualizations SpecialVisualizations.specialVisualizations
.map((sp) => sp.funcName + "()") .map((sp) => sp.funcName + "()")
.join(", ") .join(", ")
} }
} }
@ -378,6 +386,7 @@ export default class SpecialVisualizations {
funcName: "add_new_point", funcName: "add_new_point",
docs: "An element which allows to add a new point on the 'last_click'-location. Only makes sense in the layer `last_click`", docs: "An element which allows to add a new point on the 'last_click'-location. Only makes sense in the layer `last_click`",
args: [], args: [],
needsUrls: [],
constr(state: SpecialVisualizationState, _, __, feature): BaseUIElement { constr(state: SpecialVisualizationState, _, __, feature): BaseUIElement {
let [lon, lat] = GeoOperations.centerpointCoordinates(feature) let [lon, lat] = GeoOperations.centerpointCoordinates(feature)
return new SvelteUIElement(AddNewPoint, { return new SvelteUIElement(AddNewPoint, {
@ -389,6 +398,7 @@ export default class SpecialVisualizations {
{ {
funcName: "user_profile", funcName: "user_profile",
args: [], args: [],
needsUrls: [],
docs: "A component showing information about the currently logged in user (username, profile description, profile picture + link to edit them). Mostly meant to be used in the 'user-settings'", docs: "A component showing information about the currently logged in user (username, profile description, profile picture + link to edit them). Mostly meant to be used in the 'user-settings'",
constr(state: SpecialVisualizationState): BaseUIElement { constr(state: SpecialVisualizationState): BaseUIElement {
return new SvelteUIElement(UserProfile, { return new SvelteUIElement(UserProfile, {
@ -399,6 +409,7 @@ export default class SpecialVisualizations {
{ {
funcName: "language_picker", funcName: "language_picker",
args: [], args: [],
needsUrls: [],
docs: "A component to set the language of the user interface", docs: "A component to set the language of the user interface",
constr(state: SpecialVisualizationState): BaseUIElement { constr(state: SpecialVisualizationState): BaseUIElement {
return new LanguagePicker( return new LanguagePicker(
@ -410,6 +421,7 @@ export default class SpecialVisualizations {
{ {
funcName: "logout", funcName: "logout",
args: [], args: [],
needsUrls: [Constants.osmAuthConfig.url],
docs: "Shows a button where the user can log out", docs: "Shows a button where the user can log out",
constr(state: SpecialVisualizationState): BaseUIElement { constr(state: SpecialVisualizationState): BaseUIElement {
return new SubtleButton(Svg.logout_svg(), Translations.t.general.logout, { return new SubtleButton(Svg.logout_svg(), Translations.t.general.logout, {
@ -426,6 +438,7 @@ export default class SpecialVisualizations {
funcName: "split_button", funcName: "split_button",
docs: "Adds a button which allows to split a way", docs: "Adds a button which allows to split a way",
args: [], args: [],
needsUrls: [],
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>> tagSource: UIEventSource<Record<string, string>>
@ -446,6 +459,7 @@ export default class SpecialVisualizations {
funcName: "move_button", funcName: "move_button",
docs: "Adds a button which allows to move the object to another location. The config will be read from the layer config", docs: "Adds a button which allows to move the object to another location. The config will be read from the layer config",
args: [], args: [],
needsUrls: [],
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>, tagSource: UIEventSource<Record<string, string>>,
@ -469,6 +483,7 @@ export default class SpecialVisualizations {
funcName: "delete_button", funcName: "delete_button",
docs: "Adds a button which allows to delete the object at this location. The config will be read from the layer config", docs: "Adds a button which allows to delete the object at this location. The config will be read from the layer config",
args: [], args: [],
needsUrls: [],
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>, tagSource: UIEventSource<Record<string, string>>,
@ -493,6 +508,7 @@ export default class SpecialVisualizations {
{ {
funcName: "open_note", funcName: "open_note",
args: [], args: [],
needsUrls: [Constants.osmAuthConfig.url],
docs: "Creates a new map note on the given location. This options is placed in the 'last_click'-popup automatically if the 'notes'-layer is enabled", docs: "Creates a new map note on the given location. This options is placed in the 'last_click'-popup automatically if the 'notes'-layer is enabled",
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
@ -525,6 +541,7 @@ export default class SpecialVisualizations {
defaultValue: "wikidata;wikipedia", defaultValue: "wikidata;wikipedia",
}, },
], ],
needsUrls: [...Wikidata.neededUrls, ...Wikipedia.neededUrls],
example: example:
"`{wikipedia()}` is a basic example, `{wikipedia(name:etymology:wikidata)}` to show the wikipedia page of whom the feature was named after. Also remember that these can be styled, e.g. `{wikipedia():max-height: 10rem}` to limit the height", "`{wikipedia()}` is a basic example, `{wikipedia(name:etymology:wikidata)}` to show the wikipedia page of whom the feature was named after. Also remember that these can be styled, e.g. `{wikipedia():max-height: 10rem}` to limit the height",
constr: (_, tagsSource, args) => { constr: (_, tagsSource, args) => {
@ -548,6 +565,7 @@ export default class SpecialVisualizations {
defaultValue: "wikidata", defaultValue: "wikidata",
}, },
], ],
needsUrls: Wikidata.neededUrls,
example: example:
"`{wikidata_label()}` is a basic example, `{wikipedia(name:etymology:wikidata)}` to show the label itself", "`{wikidata_label()}` is a basic example, `{wikipedia(name:etymology:wikidata)}` to show the label itself",
constr: (_, tagsSource, args) => constr: (_, tagsSource, args) =>
@ -577,6 +595,7 @@ export default class SpecialVisualizations {
funcName: "all_tags", funcName: "all_tags",
docs: "Prints all key-value pairs of the object - used for debugging", docs: "Prints all key-value pairs of the object - used for debugging",
args: [], args: [],
needsUrls: [],
constr: (state, tags: UIEventSource<any>) => constr: (state, tags: UIEventSource<any>) =>
new SvelteUIElement(AllTagsPanel, { tags, state }), new SvelteUIElement(AllTagsPanel, { tags, state }),
}, },
@ -590,6 +609,7 @@ export default class SpecialVisualizations {
doc: "The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... Multiple values are allowed if ';'-separated ", doc: "The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... Multiple values are allowed if ';'-separated ",
}, },
], ],
needsUrls: AllImageProviders.apiUrls,
constr: (state, tags, args) => { constr: (state, tags, args) => {
let imagePrefixes: string[] = undefined let imagePrefixes: string[] = undefined
if (args.length > 0) { if (args.length > 0) {
@ -605,27 +625,32 @@ export default class SpecialVisualizations {
{ {
funcName: "image_upload", funcName: "image_upload",
docs: "Creates a button where a user can upload an image to IMGUR", docs: "Creates a button where a user can upload an image to IMGUR",
needsUrls: [Imgur.apiUrl],
args: [ args: [
{ {
name: "image-key", name: "image-key",
doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)", doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)",
required: false required: false,
}, },
{ {
name: "label", name: "label",
doc: "The text to show on the button", doc: "The text to show on the button",
required: false required: false,
}, },
], ],
constr: (state, tags, args) => { constr: (state, tags, args) => {
return new SvelteUIElement(UploadImage, { return new SvelteUIElement(UploadImage, {
state,tags, labelText: args[1], image: args[0] state,
tags,
labelText: args[1],
image: args[0],
}) })
}, },
}, },
{ {
funcName: "reviews", funcName: "reviews",
docs: "Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten", docs: "Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten",
needsUrls: [MangroveReviews.ORIGINAL_API],
example: example:
"`{reviews()}` for a vanilla review, `{reviews(name, play_forest)}` to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play_forest' is used", "`{reviews()}` for a vanilla review, `{reviews(name, play_forest)}` to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play_forest' is used",
args: [ args: [
@ -676,6 +701,7 @@ export default class SpecialVisualizations {
doc: "Remove this string from the end of the value before parsing. __Note: use `&RPARENs` to indicate `)` if needed__", doc: "Remove this string from the end of the value before parsing. __Note: use `&RPARENs` to indicate `)` if needed__",
}, },
], ],
needsUrls: [],
example: example:
"A normal opening hours table can be invoked with `{opening_hours_table()}`. A table for e.g. conditional access with opening hours can be `{opening_hours_table(access:conditional, no @ &LPARENS, &RPARENS)}`", "A normal opening hours table can be invoked with `{opening_hours_table()}`. A table for e.g. conditional access with opening hours can be `{opening_hours_table(access:conditional, no @ &LPARENS, &RPARENS)}`",
constr: (state, tagSource: UIEventSource<any>, args) => { constr: (state, tagSource: UIEventSource<any>, args) => {
@ -688,38 +714,9 @@ export default class SpecialVisualizations {
) )
}, },
}, },
{
funcName: "live",
docs: "Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json[x][y][z], other: json[a][b][c] out of it and will return 'other' or 'json[a][b][c]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed_value)}",
example:
"{live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour_cnt;day:data.day_cnt;year:data.year_cnt,hour)}",
args: [
{
name: "Url",
doc: "The URL to load",
required: true,
},
{
name: "Shorthands",
doc: "A list of shorthands, of the format 'shorthandname:path.path.path'. separated by ;",
},
{
name: "path",
doc: "The path (or shorthand) that should be returned",
},
],
constr: (_, tagSource: UIEventSource<any>, args) => {
const url = args[0]
const shorthands = args[1]
const neededValue = args[2]
const source = LiveQueryHandler.FetchLiveData(url, shorthands.split(";"))
return new VariableUiElement(
source.map((data) => data[neededValue] ?? "Loading...")
)
},
},
{ {
funcName: "canonical", funcName: "canonical",
needsUrls: [],
docs: "Converts a short, canonical value into the long, translated text including the unit. This only works if a `unit` is defined for the corresponding value. The unit specification will be included in the text. ", docs: "Converts a short, canonical value into the long, translated text including the unit. This only works if a `unit` is defined for the corresponding value. The unit specification will be included in the text. ",
example: example:
"If the object has `length=42`, then `{canonical(length)}` will be shown as **42 meter** (in english), **42 metre** (in french), ...", "If the object has `length=42`, then `{canonical(length)}` will be shown as **42 meter** (in english), **42 metre** (in french), ...",
@ -757,6 +754,7 @@ export default class SpecialVisualizations {
funcName: "export_as_geojson", funcName: "export_as_geojson",
docs: "Exports the selected feature as GeoJson-file", docs: "Exports the selected feature as GeoJson-file",
args: [], args: [],
needsUrls: [],
constr: (state, tagSource, tagsSource, feature, layer) => { constr: (state, tagSource, tagsSource, feature, layer) => {
const t = Translations.t.general.download const t = Translations.t.general.download
@ -786,6 +784,7 @@ export default class SpecialVisualizations {
funcName: "open_in_iD", funcName: "open_in_iD",
docs: "Opens the current view in the iD-editor", docs: "Opens the current view in the iD-editor",
args: [], args: [],
needsUrls: [],
constr: (state, feature) => { constr: (state, feature) => {
return new SvelteUIElement(OpenIdEditor, { return new SvelteUIElement(OpenIdEditor, {
mapProperties: state.mapProperties, mapProperties: state.mapProperties,
@ -797,6 +796,8 @@ export default class SpecialVisualizations {
funcName: "open_in_josm", funcName: "open_in_josm",
docs: "Opens the current view in the JOSM-editor", docs: "Opens the current view in the JOSM-editor",
args: [], args: [],
needsUrls: OpenJosm.needsUrls,
constr: (state) => { constr: (state) => {
return new OpenJosm(state.osmConnection, state.mapProperties.bounds) return new OpenJosm(state.osmConnection, state.mapProperties.bounds)
}, },
@ -805,6 +806,7 @@ export default class SpecialVisualizations {
funcName: "clear_location_history", funcName: "clear_location_history",
docs: "A button to remove the travelled track information from the device", docs: "A button to remove the travelled track information from the device",
args: [], args: [],
needsUrls: [],
constr: (state) => { constr: (state) => {
return new SubtleButton( return new SubtleButton(
Svg.delete_icon_svg().SetStyle("height: 1.5rem"), Svg.delete_icon_svg().SetStyle("height: 1.5rem"),
@ -830,6 +832,7 @@ export default class SpecialVisualizations {
defaultValue: "0", defaultValue: "0",
}, },
], ],
needsUrls: [Constants.osmAuthConfig.url],
constr: (state, tags, args) => constr: (state, tags, args) =>
new VariableUiElement( new VariableUiElement(
tags tags
@ -858,16 +861,18 @@ export default class SpecialVisualizations {
defaultValue: "id", defaultValue: "id",
}, },
], ],
needsUrls: [Imgur.apiUrl],
constr: (state, tags, args) => { constr: (state, tags, args) => {
const id = tags.data[args[0] ?? "id"] const id = tags.data[args[0] ?? "id"]
tags = state.featureProperties.getStore(id) tags = state.featureProperties.getStore(id)
console.log("Id is", id) console.log("Id is", id)
return new SvelteUIElement(UploadImage, { state, tags }) return new SvelteUIElement(UploadImage, { state, tags })
} },
}, },
{ {
funcName: "title", funcName: "title",
args: [], args: [],
needsUrls: [],
docs: "Shows the title of the popup. Useful for some cases, e.g. 'What is phone number of {title()}?'", docs: "Shows the title of the popup. Useful for some cases, e.g. 'What is phone number of {title()}?'",
example: example:
"`What is the phone number of {title()}`, which might automatically become `What is the phone number of XYZ`.", "`What is the phone number of {title()}`, which might automatically become `What is the phone number of XYZ`.",
@ -888,6 +893,7 @@ export default class SpecialVisualizations {
{ {
funcName: "maproulette_task", funcName: "maproulette_task",
args: [], args: [],
needsUrls: [Maproulette.defaultEndpoint],
constr(state, tagSource) { constr(state, tagSource) {
let parentId = tagSource.data.mr_challengeId let parentId = tagSource.data.mr_challengeId
if (parentId === undefined) { if (parentId === undefined) {
@ -931,6 +937,7 @@ export default class SpecialVisualizations {
{ {
funcName: "maproulette_set_status", funcName: "maproulette_set_status",
docs: "Change the status of the given MapRoulette task", docs: "Change the status of the given MapRoulette task",
needsUrls: [Maproulette.defaultEndpoint],
example: example:
" The following example sets the status to '2' (false positive)\n" + " The following example sets the status to '2' (false positive)\n" +
"\n" + "\n" +
@ -1054,6 +1061,7 @@ export default class SpecialVisualizations {
funcName: "statistics", funcName: "statistics",
docs: "Show general statistics about the elements currently in view. Intended to use on the `current_view`-layer", docs: "Show general statistics about the elements currently in view. Intended to use on the `current_view`-layer",
args: [], args: [],
needsUrls: [],
constr: (state) => { constr: (state) => {
return new Combine( return new Combine(
state.layout.layers state.layout.layers
@ -1096,6 +1104,8 @@ export default class SpecialVisualizations {
required: true, required: true,
}, },
], ],
needsUrls: [],
constr(__, tags, args) { constr(__, tags, args) {
return new SvelteUIElement(SendEmail, { args, tags }) return new SvelteUIElement(SendEmail, { args, tags })
}, },
@ -1123,6 +1133,7 @@ export default class SpecialVisualizations {
doc: "If set, this link will act as a download-button. The contents of `href` will be offered for download; this parameter will act as the proposed filename", doc: "If set, this link will act as a download-button. The contents of `href` will be offered for download; this parameter will act as the proposed filename",
}, },
], ],
needsUrls: [],
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>, tagSource: UIEventSource<Record<string, string>>,
@ -1144,6 +1155,7 @@ export default class SpecialVisualizations {
{ {
funcName: "multi", funcName: "multi",
docs: "Given an embedded tagRendering (read only) and a key, will read the keyname as a JSON-list. Every element of this list will be considered as tags and rendered with the tagRendering", docs: "Given an embedded tagRendering (read only) and a key, will read the keyname as a JSON-list. Every element of this list will be considered as tags and rendered with the tagRendering",
needsUrls: [],
example: example:
"```json\n" + "```json\n" +
JSON.stringify( JSON.stringify(
@ -1204,6 +1216,7 @@ export default class SpecialVisualizations {
required: true, required: true,
}, },
], ],
needsUrls: [],
constr( constr(
state: SpecialVisualizationState, state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>, tagSource: UIEventSource<Record<string, string>>,

View file

@ -37,6 +37,7 @@ export class SubstitutedTranslation extends VariableUiElement {
constr: typeof value === "function" ? value : () => value, constr: typeof value === "function" ? value : () => value,
docs: "Dynamically injected input element", docs: "Dynamically injected input element",
args: [], args: [],
needsUrls: [],
example: "", example: "",
}) })
}) })

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,7 @@ import SvelteUIElement from "./src/UI/Base/SvelteUIElement"
import ThemeViewGUI from "./src/UI/ThemeViewGUI.svelte" import ThemeViewGUI from "./src/UI/ThemeViewGUI.svelte"
import LayoutConfig from "./src/Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "./src/Models/ThemeConfig/LayoutConfig";
import MetaTagging from "./src/Logic/MetaTagging"; import MetaTagging from "./src/Logic/MetaTagging";
import { FixedUiElement } from "./src/UI/Base/FixedUiElement";
function webgl_support() { function webgl_support() {
try { try {

View file

@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0, user-scalable=no" name="viewport"> <meta content="width=device-width, initial-scale=1.0, user-scalable=no" name="viewport">
<!-- CSP // disabled --> <!-- CSP -->
<link href="./css/mobile.css" rel="stylesheet"/> <link href="./css/mobile.css" rel="stylesheet"/>
<link href="./css/openinghourstable.css" rel="stylesheet"/> <link href="./css/openinghourstable.css" rel="stylesheet"/>
<link href="./css/tagrendering.css" rel="stylesheet"/> <link href="./css/tagrendering.css" rel="stylesheet"/>
@ -65,57 +65,12 @@
</div> </div>
</div> </div>
<div id="belowmap" class="absolute top-0 left-0 -z-10">Below</div> <div id="belowmap" class="absolute top-0 left-0 -z-10">Below</div>
<script async src="./src/UI/RemoveOtherLanguages.ts" type="module"></script>
<script> <script async src="./src/InstallServiceWorker.ts" type="module"></script>
<script defer src="./src/index.ts" type="module"></script>
let lang = ((navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage || 'en').substr(0, 2); <script async data-goatcounter="https://pietervdvn.goatcounter.com/count" src="https://gc.zgo.at/count.js" crossorigin="anonymous" integrity="sha384-gtO6vSydQeOAGGK19NHrlVLNtaDSJjN4aGMWschK+dwAZOdPQWbjXgL+FM5XsgFJ"></script>
function filterLangs(maindiv) {
let foundLangs = 0
for (const child of Array.from(maindiv.children)) {
if (child.attributes.lang?.value === lang) {
foundLangs++
}
}
if (foundLangs === 0) {
lang = "en"
}
for (const child of Array.from(maindiv.children)) {
const childLang = child.attributes.lang
if (childLang === undefined) {
continue
}
if (childLang.value === lang) {
continue
}
child.parentElement.removeChild(child)
}
}
filterLangs(document.getElementById("descriptions-while-loading"))
filterLangs(document.getElementById("default-title"))
</script>
<script src="./src/index.ts" type="module"></script>
<script async data-goatcounter="https://pietervdvn.goatcounter.com/count" src="//gc.zgo.at/count.js" crossorigin="anonymous" integrity="sha384-gtO6vSydQeOAGGK19NHrlVLNtaDSJjN4aGMWschK+dwAZOdPQWbjXgL+FM5XsgFJ"></script>
<script>
window.addEventListener('load', () => {
if ('serviceWorker' in navigator) {
// register service worker
navigator.serviceWorker.register('/service-worker.js').then(
() => {
console.log('Service worker registration successful');
},
err => {
console.error('Service worker registration failed', err)
});
} else {
console.log("Service workers are not supported")
}
});
</script>
</body> </body>
</html> </html>