Merge master

This commit is contained in:
Pieter Vander Vennet 2023-07-28 00:29:21 +02:00
commit 80168f5d0d
919 changed files with 95585 additions and 8504 deletions

View file

@ -0,0 +1,10 @@
<script lang="ts">
/**
* Simple wrapper around the HTML-color field.
*/
import { UIEventSource } from "../../../Logic/UIEventSource"
export let value: UIEventSource<undefined | string>
</script>
<input bind:value={$value} type="color" />

View file

@ -0,0 +1,10 @@
<script lang="ts">
/**
* Simple wrapper around the HTML-date field.
*/
import { UIEventSource } from "../../../Logic/UIEventSource"
export let value: UIEventSource<undefined | string>
</script>
<input bind:value={$value} type="date" />

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { UIEventSource } from "../../../Logic/UIEventSource"
import type { MapProperties } from "../../../Models/MapProperties"
import { Map as MlMap } from "maplibre-gl"
import { MapLibreAdaptor } from "../../Map/MapLibreAdaptor"
import MaplibreMap from "../../Map/MaplibreMap.svelte"
import ToSvelte from "../../Base/ToSvelte.svelte"
import Svg from "../../../Svg.js"
/**
* A visualisation to pick a direction on a map background.
*/
export let value: UIEventSource<undefined | string>
export let mapProperties: Partial<MapProperties> & {
readonly location: UIEventSource<{ lon: number; lat: number }>
}
let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let mla = new MapLibreAdaptor(map, mapProperties)
mla.allowMoving.setData(false)
mla.allowZooming.setData(false)
let directionElem: HTMLElement | undefined
$: value.addCallbackAndRunD((degrees) => {
if (directionElem === undefined) {
return
}
directionElem.style.rotate = degrees + "deg"
})
let mainElem: HTMLElement
function onPosChange(x: number, y: number) {
const rect = mainElem.getBoundingClientRect()
const dx = -(rect.left + rect.right) / 2 + x
const dy = (rect.top + rect.bottom) / 2 - y
const angle = (180 * Math.atan2(dy, dx)) / Math.PI
const angleGeo = Math.floor((450 - angle) % 360)
value.setData("" + angleGeo)
}
let isDown = false
</script>
<div
bind:this={mainElem}
class="relative h-48 w-48 cursor-pointer overflow-hidden"
on:click={(e) => onPosChange(e.x, e.y)}
on:mousedown={(e) => {
isDown = true
onPosChange(e.clientX, e.clientY)
}}
on:mousemove={(e) => {
if (isDown) {
onPosChange(e.clientX, e.clientY)
e.preventDefault()
}
}}
on:mouseup={() => {
isDown = false
}}
on:touchmove={(e) => {
onPosChange(e.touches[0].clientX, e.touches[0].clientY)
e.preventDefault()
}}
on:touchstart={(e) => onPosChange(e.touches[0].clientX, e.touches[0].clientY)}
>
<div class="absolute top-0 left-0 h-full w-full cursor-pointer">
<MaplibreMap {map} attribution={false} />
</div>
<div bind:this={directionElem} class="absolute top-0 left-0 h-full w-full">
<ToSvelte construct={Svg.direction_stroke_svg} />
</div>
</div>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { twJoin } from "tailwind-merge"
import { Store, Stores, UIEventSource } from "../../../Logic/UIEventSource"
/**
* Given the available floors, shows an elevator to pick a single one
*
* This is but the input element, the logic of handling the filter is in 'LevelSelector'
*/
export let floors: Store<string[]>
export let value: UIEventSource<string>
const HEIGHT = 40
let initialIndex = Math.max(0, floors?.data?.findIndex((f) => f === value?.data) ?? 0)
let index: UIEventSource<number> = new UIEventSource<number>(initialIndex)
let forceIndex: number | undefined = undefined
let top = Math.max(0, initialIndex) * HEIGHT
let elevator: HTMLImageElement
let mouseDown = false
let container: HTMLElement
$: {
if (top > 0 || forceIndex !== undefined) {
index.setData(closestFloorIndex())
value.setData(floors.data[forceIndex ?? closestFloorIndex()])
}
}
function unclick() {
mouseDown = false
}
function click() {
mouseDown = true
}
function closestFloorIndex() {
return Math.min(floors.data.length - 1, Math.max(0, Math.round(top / HEIGHT)))
}
function onMove(e: { movementY: number }) {
if (mouseDown) {
forceIndex = undefined
const containerY = container.clientTop
const containerMax = containerY + (floors.data.length - 1) * HEIGHT
top = Math.min(Math.max(0, top + e.movementY), containerMax)
}
}
let momentum = 0
function stabilize() {
// Automatically move the elevator to the closes floor
if (mouseDown) {
return
}
const target = (forceIndex ?? index.data) * HEIGHT
let diff = target - top
if (diff > 1) {
diff /= 3
}
const sign = Math.sign(diff)
momentum = momentum + sign
let diffR = Math.min(Math.abs(momentum), forceIndex !== undefined ? 9 : 3, Math.abs(diff))
momentum = Math.sign(momentum) * Math.min(diffR, Math.abs(momentum))
top += sign * diffR
if (index.data === forceIndex) {
forceIndex = undefined
}
top = Math.max(top, 0)
}
Stores.Chronic(50).addCallback((_) => stabilize())
floors.addCallback((floors) => {
forceIndex = floors.findIndex((s) => s === value.data)
})
let image: HTMLImageElement
$: {
if (image) {
let lastY = 0
image.ontouchstart = (e: TouchEvent) => {
mouseDown = true
lastY = e.changedTouches[0].clientY
}
image.ontouchmove = (e) => {
const y = e.changedTouches[0].clientY
console.log(y)
const movementY = y - lastY
lastY = y
onMove({ movementY })
}
image.ontouchend = unclick
}
}
</script>
<div
bind:this={container}
class="relative"
style={`height: calc(${HEIGHT}px * ${$floors.length}); width: 96px`}
>
<div class="absolute right-0 h-full w-min">
{#each $floors as floor, i}
<button
style={`height: ${HEIGHT}px; width: ${HEIGHT}px`}
class={twJoin(
"content-box m-0 flex items-center justify-center border-2 border-gray-300",
i === (forceIndex ?? $index) && "selected"
)}
on:click={() => {
forceIndex = i
}}
>
{floor}
</button>
{/each}
</div>
<div style={`width: ${HEIGHT}px`}>
<img
bind:this={image}
class="draggable"
draggable="false"
on:mousedown={click}
src="./assets/svg/elevator.svg"
style={`top: ${top}px;`}
/>
</div>
</div>
<svelte:window on:mousemove={onMove} on:mouseup={unclick} />
<style>
.draggable {
user-select: none;
cursor: move;
position: absolute;
user-drag: none;
height: 72px;
margin-top: -15px;
margin-bottom: -15px;
margin-left: -18px;
-webkit-user-drag: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
</style>

View file

@ -0,0 +1,10 @@
<script lang="ts">
import {UIEventSource} from "../../../Logic/UIEventSource";
/**
* Simply shows the image
*/
export let value: UIEventSource<undefined | string>
</script>
<img src={$value}/>

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import type { MapProperties } from "../../../Models/MapProperties"
import { Map as MlMap } from "maplibre-gl"
import { MapLibreAdaptor } from "../../Map/MapLibreAdaptor"
import MaplibreMap from "../../Map/MaplibreMap.svelte"
import DragInvitation from "../../Base/DragInvitation.svelte"
import { GeoOperations } from "../../../Logic/GeoOperations"
import ShowDataLayer from "../../Map/ShowDataLayer"
import * as boundsdisplay from "../../../../assets/layers/range/range.json"
import StaticFeatureSource from "../../../Logic/FeatureSource/Sources/StaticFeatureSource"
import * as turf from "@turf/turf"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { createEventDispatcher, onDestroy } from "svelte"
/**
* A visualisation to pick a location on a map background
*/
export let value: UIEventSource<{ lon: number; lat: number }>
export let initialCoordinate: { lon: number; lat: number }
initialCoordinate = initialCoordinate ?? value.data
export let maxDistanceInMeters: number = undefined
export let mapProperties: Partial<MapProperties> & {
readonly location: UIEventSource<{ lon: number; lat: number }>
} = undefined
/**
* Called when setup is done, can be used to add more layers to the map
*/
export let onCreated: (
value: Store<{
lon: number
lat: number
}>,
map: Store<MlMap>,
mapProperties: MapProperties
) => void = undefined
const dispatch = createEventDispatcher<{ click: { lon: number; lat: number } }>()
export let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let mla = new MapLibreAdaptor(map, mapProperties)
mla.lastClickLocation.addCallbackAndRunD((lastClick) => {
dispatch("click", lastClick)
})
mapProperties.location.syncWith(value)
if (onCreated) {
onCreated(value, map, mla)
}
let rangeIsShown = false
if (maxDistanceInMeters) {
onDestroy(
mla.location.addCallbackD((newLocation) => {
const l = [newLocation.lon, newLocation.lat]
const c: [number, number] = [initialCoordinate.lon, initialCoordinate.lat]
const d = GeoOperations.distanceBetween(l, c)
console.log("distance is", d, l, c)
if (d <= maxDistanceInMeters) {
return
}
// This is too far away - let's move back
const correctLocation = GeoOperations.along(c, l, maxDistanceInMeters - 10)
window.setTimeout(() => {
mla.location.setData({ lon: correctLocation[0], lat: correctLocation[1] })
}, 25)
if (!rangeIsShown) {
new ShowDataLayer(map, {
layer: new LayerConfig(boundsdisplay),
features: new StaticFeatureSource([
turf.circle(c, maxDistanceInMeters, {
units: "meters",
properties: { range: "yes", id: "0" },
}),
]),
})
rangeIsShown = true
}
})
)
}
</script>
<div class="min-h-32 relative h-full cursor-pointer overflow-hidden">
<div class="absolute top-0 left-0 h-full w-full cursor-pointer">
<MaplibreMap center={{ lng: initialCoordinate.lon, lat: initialCoordinate.lat }} {map} />
</div>
<div
class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center p-8 opacity-50"
>
<img class="h-full max-h-24" src="./assets/svg/move-arrows.svg" />
</div>
<DragInvitation hideSignal={mla.location} />
</div>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { UIEventSource } from "../../../Logic/UIEventSource";
import LanguageUtils from "../../../Utils/LanguageUtils";
import { onDestroy } from "svelte";
import ValidatedInput from "../ValidatedInput.svelte";
export let value: UIEventSource<string> = new UIEventSource<string>("");
let translations: UIEventSource<Record<string, string>> = value.sync((s) => {
try {
return JSON.parse(s);
} catch (e) {
return {};
}
}, [], v => JSON.stringify(v));
const allLanguages: string[] = LanguageUtils.usedLanguagesSorted;
let currentLang = new UIEventSource("en");
const currentVal = new UIEventSource<string>("");
function update() {
const v = currentVal.data;
const l = currentLang.data;
if (translations.data[l] === v) {
return;
}
translations.data[l] = v;
translations.ping();
}
onDestroy(currentLang.addCallbackAndRunD(currentLang => {
console.log("Applying current lang:", currentLang);
translations.data[currentLang] = translations.data[currentLang] ?? "";
currentVal.setData(translations.data[currentLang]);
}));
onDestroy(currentVal.addCallbackAndRunD(v => {
update();
}));
</script>
<div class="flex">
<select bind:value={$currentLang}>
{#each allLanguages as language}
<option value={language}>
{language}
</option>
{/each}
</select>
<ValidatedInput type="string" value={currentVal} />
</div>

View file

@ -0,0 +1,33 @@
<script lang="ts">
/**
* Constructs an input helper element for the given type.
* Note that all values are stringified
*/
import { UIEventSource } from "../../Logic/UIEventSource"
import type { ValidatorType } from "./Validators"
import InputHelpers from "./InputHelpers"
import ToSvelte from "../Base/ToSvelte.svelte"
import type { Feature } from "geojson"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
export let type: ValidatorType
export let value: UIEventSource<string>
export let feature: Feature
export let args: (string | number | boolean)[] = undefined
let properties = { feature, args: args ?? [] }
let construct = new UIEventSource<(value, extraProperties) => BaseUIElement>(undefined)
$: {
construct.setData(InputHelpers.AvailableInputHelpers[type])
}
</script>
{#if construct !== undefined}
<ToSvelte
construct={() =>
new VariableUiElement(construct.mapD((construct) => construct(value, properties)))}
/>
{/if}

View file

@ -0,0 +1,156 @@
import { ValidatorType } from "./Validators"
import { UIEventSource } from "../../Logic/UIEventSource"
import SvelteUIElement from "../Base/SvelteUIElement"
import DirectionInput from "./Helpers/DirectionInput.svelte"
import { MapProperties } from "../../Models/MapProperties"
import DateInput from "./Helpers/DateInput.svelte"
import ColorInput from "./Helpers/ColorInput.svelte"
import BaseUIElement from "../BaseUIElement"
import OpeningHoursInput from "../OpeningHours/OpeningHoursInput"
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox"
import Wikidata from "../../Logic/Web/Wikidata"
import { Utils } from "../../Utils"
import Locale from "../i18n/Locale"
import { Feature } from "geojson"
import { GeoOperations } from "../../Logic/GeoOperations"
import ImageHelper from "./Helpers/ImageHelper.svelte"
import TranslationInput from "./Helpers/TranslationInput.svelte"
export interface InputHelperProperties {
/**
* Extra arguments which might be used by the helper component
*/
args?: (string | number | boolean)[]
/**
* Used for map-based helpers, such as 'direction'
*/
mapProperties?: Partial<MapProperties> & {
readonly location: UIEventSource<{ lon: number; lat: number }>
}
/**
* The feature that this question is about
* Used by the wikidata-input to read properties, which in turn is used to read the name to pre-populate the text field.
* Additionally, used for direction input to set the default location if no mapProperties with location are given
*/
feature?: Feature
}
export default class InputHelpers {
public static readonly AvailableInputHelpers: Readonly<
Partial<
Record<
ValidatorType,
(
value: UIEventSource<string>,
extraProperties?: InputHelperProperties
) => BaseUIElement
>
>
> = {
direction: (value, properties) =>
new SvelteUIElement(DirectionInput, {
value,
mapProperties: InputHelpers.constructMapProperties(properties),
}),
date: (value) => new SvelteUIElement(DateInput, { value }),
color: (value) => new SvelteUIElement(ColorInput, { value }),
opening_hours: (value) => new OpeningHoursInput(value),
wikidata: InputHelpers.constructWikidataHelper,
image: (value) => new SvelteUIElement(ImageHelper, { value }),
translation: (value) => new SvelteUIElement(TranslationInput, { value }),
} as const
/**
* Constructs a mapProperties-object for the given properties.
* Assumes that the first helper-args contains the desired zoom-level
* @param properties
* @private
*/
private static constructMapProperties(
properties: InputHelperProperties
): Partial<MapProperties> {
let location = properties?.mapProperties?.location
if (!location) {
const [lon, lat] = GeoOperations.centerpointCoordinates(properties.feature)
location = new UIEventSource<{ lon: number; lat: number }>({ lon, lat })
}
let mapProperties: Partial<MapProperties> = properties?.mapProperties ?? { location }
if (!mapProperties.location) {
mapProperties = { ...mapProperties, location }
}
let zoom = 17
if (properties?.args?.[0] !== undefined) {
zoom = Number(properties.args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
if (!mapProperties.zoom) {
mapProperties = { ...mapProperties, zoom: new UIEventSource<number>(zoom) }
}
return mapProperties
}
private static constructWikidataHelper(
value: UIEventSource<string>,
props: InputHelperProperties
) {
const inputHelperOptions = props
const args = inputHelperOptions.args ?? []
const searchKey = <string>args[0] ?? "name"
const searchFor = <string>(
(inputHelperOptions.feature?.properties[searchKey]?.toLowerCase() ?? "")
)
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor)
const options: any = args[1]
if (searchFor !== undefined && options !== undefined) {
const prefixes = <string[] | Record<string, string[]>>options["removePrefixes"] ?? []
const postfixes = <string[] | Record<string, string[]>>options["removePostfixes"] ?? []
const defaultValueCandidate = Locale.language.map((lg) => {
const prefixesUnrwapped: RegExp[] = (
Array.isArray(prefixes) ? prefixes : prefixes[lg] ?? []
).map((s) => new RegExp("^" + s, "i"))
const postfixesUnwrapped: RegExp[] = (
Array.isArray(postfixes) ? postfixes : postfixes[lg] ?? []
).map((s) => new RegExp(s + "$", "i"))
let clipped = searchFor
for (const postfix of postfixesUnwrapped) {
const match = searchFor.match(postfix)
if (match !== null) {
clipped = searchFor.substring(0, searchFor.length - match[0].length)
break
}
}
for (const prefix of prefixesUnrwapped) {
const match = searchFor.match(prefix)
if (match !== null) {
clipped = searchFor.substring(match[0].length)
break
}
}
return clipped
})
defaultValueCandidate.addCallbackAndRun((clipped) => searchForValue.setData(clipped))
}
let instanceOf: number[] = Utils.NoNull(
(options?.instanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
)
let notInstanceOf: number[] = Utils.NoNull(
(options?.notInstanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
)
return new WikidataSearchBox({
value,
searchText: searchForValue,
instanceOf,
notInstanceOf,
})
}
}

View file

@ -0,0 +1,128 @@
<script lang="ts">
import {UIEventSource} from "../../Logic/UIEventSource"
import type {ValidatorType} from "./Validators"
import Validators from "./Validators"
import {ExclamationIcon} from "@rgossiaux/svelte-heroicons/solid"
import {Translation} from "../i18n/Translation"
import {createEventDispatcher, onDestroy} from "svelte"
import {Validator} from "./Validator"
import {Unit} from "../../Models/Unit"
import UnitInput from "../Popup/UnitInput.svelte"
import {Utils} from "../../Utils";
export let type: ValidatorType
export let feedback: UIEventSource<Translation> | undefined
export let getCountry: () => string | undefined
export let placeholder: string | Translation | undefined
export let unit: Unit = undefined
export let value: UIEventSource<string>
/**
* Internal state bound to the input element.
*
* This is only copied to 'value' when appropriate so that no invalid values leak outside;
* Additionally, the unit is added when copying
*/
let _value = new UIEventSource(value.data ?? "")
let validator: Validator = Validators.get(type ?? "string")
if(validator === undefined){
console.warn("Didn't find a validator for type", type)
}
let selectedUnit: UIEventSource<string> = new UIEventSource<string>(undefined)
let _placeholder = placeholder ?? validator?.getPlaceholder() ?? type
function initValueAndDenom() {
if (unit && value.data) {
const [v, denom] = unit?.findDenomination(value.data, getCountry)
if (denom) {
_value.setData(v)
selectedUnit.setData(denom.canonical)
} else {
_value.setData(value.data ?? "")
}
} else {
_value.setData(value.data ?? "")
}
}
initValueAndDenom()
$: {
// The type changed -> reset some values
validator = Validators.get(type ?? "string")
_placeholder = placeholder ?? validator?.getPlaceholder() ?? type
feedback?.setData(validator?.getFeedback(_value.data, getCountry))
initValueAndDenom()
}
function setValues() {
// Update the value stores
const v = _value.data
if (!validator?.isValid(v, getCountry) || v === "") {
feedback?.setData(validator?.getFeedback(v, getCountry))
value.setData("")
return
}
if (unit && isNaN(Number(v))) {
value.setData(undefined)
return
}
feedback?.setData(undefined)
value.setData(v + (selectedUnit.data ?? ""))
}
onDestroy(_value.addCallbackAndRun((_) => setValues()))
onDestroy(value.addCallbackAndRunD(fromUpstream => {
if(_value.data !== fromUpstream){
_value.setData(fromUpstream)
}
}))
onDestroy(selectedUnit.addCallback((_) => setValues()))
if (validator === undefined) {
throw "Not a valid type (no validator found) for type '" + type+"'; did you perhaps mean one of: "+Utils.sortedByLevenshteinDistance(type, Validators.AllValidators.map(v => v.name), v => v).slice(0, 5).join(", ")
}
const isValid = _value.map((v) => validator?.isValid(v, getCountry) ?? true)
let htmlElem: HTMLInputElement
let dispatch = createEventDispatcher<{ selected, submit }>()
$: {
if (htmlElem !== undefined) {
htmlElem.onfocus = () => dispatch("selected")
}
}
</script>
{#if validator?.textArea}
<form on:submit|preventDefault={() => dispatch("submit")}>
<textarea
class="w-full"
bind:value={$_value}
inputmode={validator?.inputmode ?? "text"}
placeholder={_placeholder}></textarea>
</form>
{:else}
<form class="inline-flex" on:submit={() => dispatch("submit")}>
<input
bind:this={htmlElem}
bind:value={$_value}
class="w-full"
inputmode={validator?.inputmode ?? "text"}
placeholder={_placeholder}
/>
{#if !$isValid}
<ExclamationIcon class="-ml-6 h-6 w-6"/>
{/if}
{#if unit !== undefined}
<UnitInput {unit} {selectedUnit} textValue={_value} upstreamValue={value}/>
{/if}
</form>
{/if}

View file

@ -0,0 +1,74 @@
import BaseUIElement from "../BaseUIElement"
import { Translation } from "../i18n/Translation"
import Translations from "../i18n/Translations"
/**
* A 'TextFieldValidator' contains various methods to check and cleanup an entered value or to give feedback.
* They also double as an index of supported types for textfields in MapComplete
*/
export abstract class Validator {
public readonly name: string
/*
* An explanation for the theme builder.
* This can indicate which special input element is used, ...
* */
public readonly explanation: string
/**
* What HTML-inputmode to use
*/
public readonly inputmode?: string
public readonly textArea: boolean
constructor(
name: string,
explanation: string | BaseUIElement,
inputmode?: string,
textArea?: false | boolean
) {
this.name = name
this.inputmode = inputmode
this.textArea = textArea ?? false
if (this.name.endsWith("textfield")) {
this.name = this.name.substr(0, this.name.length - "TextField".length)
}
if (this.name.endsWith("textfielddef")) {
this.name = this.name.substr(0, this.name.length - "TextFieldDef".length)
}
if (typeof explanation === "string") {
this.explanation = explanation
} else {
this.explanation = explanation.AsMarkdown()
}
}
/**
* Gets a piece of feedback. By default, validation.<type> will be used, resulting in a generic 'not a valid <type>'.
* However, inheritors might overwrite this to give more specific feedback
*
* Returns 'undefined' if the element is valid
*/
public getFeedback(s: string, _?: () => string): Translation | undefined {
if (this.isValid(s)) {
return undefined
}
const tr = Translations.t.validation[this.name]
if (tr !== undefined) {
return tr["feedback"]
}
}
public getPlaceholder() {
return Translations.t.validation[this.name].description
}
public isValid(key: string, getCountry?: () => string): boolean {
return true
}
/**
* Reformats for the human
*/
public reformat(s: string, _?: () => string): string {
return s
}
}

View file

@ -0,0 +1,98 @@
import { Validator } from "./Validator"
import StringValidator from "./Validators/StringValidator"
import TextValidator from "./Validators/TextValidator"
import DateValidator from "./Validators/DateValidator"
import NatValidator from "./Validators/NatValidator"
import IntValidator from "./Validators/IntValidator"
import LengthValidator from "./Validators/LengthValidator"
import DirectionValidator from "./Validators/DirectionValidator"
import WikidataValidator from "./Validators/WikidataValidator"
import PNatValidator from "./Validators/PNatValidator"
import FloatValidator from "./Validators/FloatValidator"
import PFloatValidator from "./Validators/PFloatValidator"
import EmailValidator from "./Validators/EmailValidator"
import UrlValidator from "./Validators/UrlValidator"
import PhoneValidator from "./Validators/PhoneValidator"
import OpeningHoursValidator from "./Validators/OpeningHoursValidator"
import ColorValidator from "./Validators/ColorValidator"
import BaseUIElement from "../BaseUIElement"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import SimpleTagValidator from "./Validators/SimpleTagValidator"
import ImageUrlValidator from "./Validators/ImageUrlValidator"
import TagKeyValidator from "./Validators/TagKeyValidator"
import TranslationValidator from "./Validators/TranslationValidator"
export type ValidatorType = (typeof Validators.availableTypes)[number]
export default class Validators {
public static readonly availableTypes = [
"string",
"text",
"date",
"nat",
"int",
"distance",
"direction",
"wikidata",
"pnat",
"float",
"pfloat",
"email",
"url",
"phone",
"opening_hours",
"color",
"image",
"simple_tag",
"key",
"translation",
] as const
public static readonly AllValidators: ReadonlyArray<Validator> = [
new StringValidator(),
new TextValidator(),
new DateValidator(),
new NatValidator(),
new IntValidator(),
new LengthValidator(),
new DirectionValidator(),
new WikidataValidator(),
new PNatValidator(),
new FloatValidator(),
new PFloatValidator(),
new EmailValidator(),
new UrlValidator(),
new PhoneValidator(),
new OpeningHoursValidator(),
new ColorValidator(),
new ImageUrlValidator(),
new SimpleTagValidator(),
new TagKeyValidator(),
new TranslationValidator(),
]
private static _byType = Validators._byTypeConstructor()
private static _byTypeConstructor(): Map<ValidatorType, Validator> {
const map = new Map<ValidatorType, Validator>()
for (const validator of Validators.AllValidators) {
map.set(<ValidatorType>validator.name, validator)
}
return map
}
public static HelpText(): BaseUIElement {
const explanations: BaseUIElement[] = Validators.AllValidators.map((type) =>
new Combine([new Title(type.name, 3), type.explanation]).SetClass("flex flex-col")
)
return new Combine([
new Title("Available types for text fields", 1),
"The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them",
...explanations,
]).SetClass("flex flex-col")
}
static get(type: ValidatorType): Validator {
return Validators._byType.get(type)
}
}

View file

@ -0,0 +1,7 @@
import { Validator } from "../Validator"
export default class ColorValidator extends Validator {
constructor() {
super("color", "Shows a color picker")
}
}

View file

@ -0,0 +1,28 @@
import { Validator } from "../Validator"
export default class DateValidator extends Validator {
constructor() {
super("date", "A date with date picker")
}
isValid(str: string): boolean {
return !isNaN(new Date(str).getTime())
}
reformat(str: string) {
console.log("Reformatting", str)
if (!this.isValid(str)) {
// The date is invalid - we return the string as is
return str
}
const d = new Date(str)
let month = "" + (d.getMonth() + 1)
let day = "" + d.getDate()
const year = d.getFullYear()
if (month.length < 2) month = "0" + month
if (day.length < 2) day = "0" + day
return [year, month, day].join("-")
}
}

View file

@ -0,0 +1,29 @@
import IntValidator from "./IntValidator"
export default class DirectionValidator extends IntValidator {
constructor() {
super(
"direction",
[
"A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl).",
"### Input helper",
"This element has an input helper showing a map and 'viewport' indicating the direction. By default, this map is zoomed to zoomlevel 17, but this can be changed with the first argument",
].join("\n\n")
)
}
isValid(str): boolean {
if (str.endsWith("°")) {
str = str.substring(0, str.length - 1)
}
return super.isValid(str)
}
reformat(str): string {
if (str.endsWith("°")) {
str = str.substring(0, str.length - 1)
}
const n = Number(str) % 360
return "" + n
}
}

View file

@ -0,0 +1,40 @@
import { Translation } from "../../i18n/Translation.js"
import Translations from "../../i18n/Translations.js"
import * as emailValidatorLibrary from "email-validator"
import { Validator } from "../Validator"
export default class EmailValidator extends Validator {
constructor() {
super("email", "An email adress", "email")
}
isValid = (str) => {
if (str === undefined) {
return false
}
str = str.trim()
if (str.startsWith("mailto:")) {
str = str.substring("mailto:".length)
}
return emailValidatorLibrary.validate(str)
}
reformat = (str) => {
if (str === undefined) {
return undefined
}
str = str.trim()
if (str.startsWith("mailto:")) {
str = str.substring("mailto:".length)
}
return str
}
getFeedback(s: string): Translation {
if (s.indexOf("@") < 0) {
return Translations.t.validation.email.noAt
}
return super.getFeedback(s)
}
}

View file

@ -0,0 +1,27 @@
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import { Validator } from "../Validator"
export default class FloatValidator extends Validator {
inputmode = "decimal"
constructor(name?: string, explanation?: string) {
super(name ?? "float", explanation ?? "A decimal number", "decimal")
}
isValid(str) {
return !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(",")
}
reformat(str): string {
return "" + Number(str)
}
getFeedback(s: string): Translation {
if (isNaN(Number(s))) {
return Translations.t.validation.nat.notANumber
}
return undefined
}
}

View file

@ -0,0 +1,39 @@
import UrlValidator from "./UrlValidator"
import { Translation } from "../../i18n/Translation"
export default class ImageUrlValidator extends UrlValidator {
private static readonly allowedExtensions = ["jpg", "jpeg", "svg", "png"]
constructor() {
super(
"image",
"Same as the URL-parameter, except that it checks that the URL ends with `.jpg`, `.png` or some other typical image format"
)
}
private static hasValidExternsion(str: string): boolean {
str = str.toLowerCase()
return ImageUrlValidator.allowedExtensions.some((ext) => str.endsWith(ext))
}
getFeedback(s: string, _?: () => string): Translation | undefined {
const superF = super.getFeedback(s, _)
if (superF) {
return superF
}
if (!ImageUrlValidator.hasValidExternsion(s)) {
return new Translation(
"This URL does not end with one of the allowed extensions. These are: " +
ImageUrlValidator.allowedExtensions.join(", ")
)
}
return undefined
}
isValid(str: string): boolean {
if (!super.isValid(str)) {
return false
}
return ImageUrlValidator.hasValidExternsion(str)
}
}

View file

@ -0,0 +1,29 @@
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import { Validator } from "../Validator"
export default class IntValidator extends Validator {
constructor(name?: string, explanation?: string) {
super(
name ?? "int",
explanation ?? "A whole number, either positive, negative or zero",
"numeric"
)
}
isValid(str): boolean {
str = "" + str
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str))
}
getFeedback(s: string): Translation {
const n = Number(s)
if (isNaN(n)) {
return Translations.t.validation.nat.notANumber
}
if (Math.floor(n) !== n) {
return Translations.t.validation.nat.mustBeWhole
}
return undefined
}
}

View file

@ -0,0 +1,16 @@
import { Validator } from "../Validator"
export default class LengthValidator extends Validator {
constructor() {
super(
"distance",
'A geographical distance in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]',
"decimal"
)
}
isValid = (str) => {
const t = Number(str)
return !isNaN(t)
}
}

View file

@ -0,0 +1,30 @@
import IntValidator from "./IntValidator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
export default class NatValidator extends IntValidator {
constructor(name?: string, explanation?: string) {
super(name ?? "nat", explanation ?? "A whole, positive number or zero")
}
isValid(str): boolean {
if (str === undefined) {
return false
}
str = "" + str
return str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0
}
getFeedback(s: string): Translation {
const spr = super.getFeedback(s)
if (spr !== undefined) {
return spr
}
const n = Number(s)
if (n < 0) {
return Translations.t.validation.nat.mustBePositive
}
return undefined
}
}

View file

@ -0,0 +1,54 @@
import Combine from "../../Base/Combine"
import Title from "../../Base/Title"
import Table from "../../Base/Table"
import { Validator } from "../Validator"
export default class OpeningHoursValidator extends Validator {
constructor() {
super(
"opening_hours",
new Combine([
"Has extra elements to easily input when a POI is opened.",
new Title("Helper arguments"),
new Table(
["name", "doc"],
[
[
"options",
new Combine([
"A JSON-object of type `{ prefix: string, postfix: string }`. ",
new Table(
["subarg", "doc"],
[
[
"prefix",
"Piece of text that will always be added to the front of the generated opening hours. If the OSM-data does not start with this, it will fail to parse.",
],
[
"postfix",
"Piece of text that will always be added to the end of the generated opening hours",
],
]
),
]),
],
]
),
new Title("Example usage"),
"To add a conditional (based on time) access restriction:\n\n```\n" +
`
"freeform": {
"key": "access:conditional",
"type": "opening_hours",
"helperArgs": [
{
"prefix":"no @ (",
"postfix":")"
}
]
}` +
"\n```\n\n*Don't forget to pass the prefix and postfix in the rendering as well*: `{opening_hours_table(opening_hours,yes @ &LPARENS, &RPARENS )`",
])
)
}
}

View file

@ -0,0 +1,23 @@
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import { Validator } from "../Validator"
export default class PFloatValidator extends Validator {
constructor() {
super("pfloat", "A positive decimal number or zero")
}
isValid = (str) =>
!isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(",")
getFeedback(s: string): Translation {
const spr = super.getFeedback(s)
if (spr !== undefined) {
return spr
}
if (Number(s) < 0) {
return Translations.t.validation.nat.mustBePositive
}
return undefined
}
}

View file

@ -0,0 +1,27 @@
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import NatValidator from "./NatValidator"
export default class PNatValidator extends NatValidator {
constructor() {
super("pnat", "A strict positive number")
}
getFeedback(s: string): Translation {
const spr = super.getFeedback(s)
if (spr !== undefined) {
return spr
}
if (Number(s) === 0) {
return Translations.t.validation.pnat.noZero
}
return undefined
}
isValid = (str) => {
if (!super.isValid(str)) {
return false
}
return Number(str) > 0
}
}

View file

@ -0,0 +1,54 @@
import { parsePhoneNumberFromString } from "libphonenumber-js"
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
export default class PhoneValidator extends Validator {
constructor() {
super("phone", "A phone number", "tel")
}
getFeedback(s: string, requestCountry?: () => string): Translation {
if (this.isValid(s, requestCountry)) {
return undefined
}
const tr = Translations.t.validation.phone
const generic = tr.feedback
if (requestCountry) {
const country = requestCountry()
if (country) {
return tr.feedbackCountry.Subs({ country })
}
}
return generic
}
public isValid(str, country: () => string): boolean {
if (str === undefined) {
return false
}
if (str.startsWith("tel:")) {
str = str.substring("tel:".length)
}
let countryCode = undefined
if (country !== undefined) {
countryCode = country()?.toUpperCase()
}
return parsePhoneNumberFromString(str, countryCode)?.isValid() ?? false
}
public reformat(str, country: () => string) {
if (str.startsWith("tel:")) {
str = str.substring("tel:".length)
}
let countryCode = undefined
if (country) {
countryCode = country()
}
return parsePhoneNumberFromString(
str,
countryCode?.toUpperCase() as any
)?.formatInternational()
}
}

View file

@ -0,0 +1,51 @@
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import TagKeyValidator from "./TagKeyValidator"
/**
* Checks that the input conforms `key=value`, where `key` and `value` don't have too much weird characters
*/
export default class SimpleTagValidator extends Validator {
private static readonly KeyValidator = new TagKeyValidator()
constructor() {
super(
"simple_tag",
"A simple tag of the format `key=value` where `key` conforms to a normal key `"
)
}
getFeedback(tag: string, _): Translation | undefined {
const parts = tag.split("=")
if (parts.length < 2) {
return Translations.T("A tag should contain a = to separate the 'key' and 'value'")
}
if (parts.length > 2) {
return Translations.T(
"A tag should contain precisely one `=` to separate the 'key' and 'value', but " +
(parts.length - 1) +
" equal signs were found"
)
}
const [key, value] = parts
const keyFeedback = SimpleTagValidator.KeyValidator.getFeedback(key, _)
if (keyFeedback) {
return keyFeedback
}
if (value.length > 255) {
return Translations.T("A `value should be at most 255 characters")
}
if (value.length == 0) {
return Translations.T("A `value should not be empty")
}
return undefined
}
isValid(tag: string, _): boolean {
return this.getFeedback(tag, _) === undefined
}
}

View file

@ -0,0 +1,7 @@
import { Validator } from "../Validator"
export default class StringValidator extends Validator {
constructor() {
super("string", "A simple piece of text")
}
}

View file

@ -0,0 +1,30 @@
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
export default class TagKeyValidator extends Validator {
constructor() {
super("key", "Validates a key, mostly that no weird characters are used")
}
getFeedback(key: string, _?: () => string): Translation | undefined {
if (key.length > 255) {
return Translations.T("A `key` should be at most 255 characters")
}
if (key.length == 0) {
return Translations.T("A `key` should not be empty")
}
const keyRegex = /[a-zA-Z0-9:_]+/
if (!key.match(keyRegex)) {
return Translations.T(
"A `key` should only have the characters `a-zA-Z0-9`, `:` or `_`"
)
}
return undefined
}
isValid(key: string, getCountry?: () => string): boolean {
return this.getFeedback(key, getCountry) === undefined
}
}

View file

@ -0,0 +1,12 @@
import { Validator } from "../Validator"
export default class TextValidator extends Validator {
constructor() {
super(
"text",
"A longer piece of text. Uses an textArea instead of a textField",
"text",
true
)
}
}

View file

@ -0,0 +1,16 @@
import { Validator } from "../Validator"
export default class TranslationValidator extends Validator {
constructor() {
super("translation", "Makes sure the the string is of format `Record<string, string>` ")
}
isValid(value: string, getCountry?: () => string): boolean {
try {
JSON.parse(value)
return true
} catch (e) {
return false
}
}
}

View file

@ -0,0 +1,71 @@
import { Validator } from "../Validator"
export default class UrlValidator extends Validator {
constructor(name?: string, explanation?: string) {
super(
name ??"url",
explanation?? "The validatedTextField will format URLs to always be valid and have a https://-header (even though the 'https'-part will be hidden from the user. Furthermore, some tracking parameters will be removed",
"url"
)
}
reformat(str: string): string {
try {
let url: URL
// str = str.toLowerCase() // URLS are case sensitive. Lowercasing them might break some URLS. See #763
if (
!str.startsWith("http://") &&
!str.startsWith("https://") &&
!str.startsWith("http:")
) {
url = new URL("https://" + str)
} else {
url = new URL(str)
}
const blacklistedTrackingParams = [
"fbclid", // Oh god, how I hate the fbclid. Let it burn, burn in hell!
"gclid",
"cmpid",
"agid",
"utm",
"utm_source",
"utm_medium",
"campaignid",
"campaign",
"AdGroupId",
"AdGroup",
"TargetId",
"msclkid",
]
for (const dontLike of blacklistedTrackingParams) {
url.searchParams.delete(dontLike.toLowerCase())
}
let cleaned = url.toString()
if (cleaned.endsWith("/") && !str.endsWith("/")) {
// Do not add a trailing '/' if it wasn't typed originally
cleaned = cleaned.substr(0, cleaned.length - 1)
}
return cleaned
} catch (e) {
console.error(e)
return undefined
}
}
isValid(str: string): boolean {
try {
if (
!str.startsWith("http://") &&
!str.startsWith("https://") &&
!str.startsWith("http:")
) {
str = "https://" + str
}
const url = new URL(str)
const dotIndex = url.host.indexOf(".")
return dotIndex > 0 && url.host[url.host.length - 1] !== "."
} catch (e) {
return false
}
}
}

View file

@ -0,0 +1,34 @@
import Combine from "../../Base/Combine"
import Wikidata from "../../../Logic/Web/Wikidata"
import WikidataSearchBox from "../../Wikipedia/WikidataSearchBox"
import { Validator } from "../Validator"
export default class WikidataValidator extends Validator {
constructor() {
super("wikidata", new Combine(["A wikidata identifier, e.g. Q42.", WikidataSearchBox.docs]))
}
public isValid(str): boolean {
if (str === undefined) {
return false
}
if (str.length <= 2) {
return false
}
return !str.split(";").some((str) => Wikidata.ExtractKey(str) === undefined)
}
public reformat(str) {
if (str === undefined) {
return undefined
}
let out = str
.split(";")
.map((str) => Wikidata.ExtractKey(str))
.join("; ")
if (str.endsWith(";")) {
out = out + ";"
}
return out
}
}