Merge branches

This commit is contained in:
Pieter Vander Vennet 2023-07-17 01:43:53 +02:00
commit 7eeac66471
554 changed files with 8193 additions and 7079 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,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,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,151 @@
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"
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,
} 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,116 @@
<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"
export let type: ValidatorType
export let feedback: UIEventSource<Translation> | undefined = 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")
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 = feedback?.setData(validator?.getFeedback(_value.data, getCountry))
initValueAndDenom()
}
function setValues() {
// Update the value stores
const v = _value.data
if (!validator.isValid(v, getCountry) || v === "") {
value.setData(undefined)
feedback?.setData(validator.getFeedback(v, getCountry))
return
}
if (unit && isNaN(Number(v))) {
value.setData(undefined)
return
}
feedback?.setData(undefined)
value.setData(v + (selectedUnit.data ?? ""))
}
onDestroy(_value.addCallbackAndRun((_) => setValues()))
onDestroy(selectedUnit.addCallback((_) => setValues()))
if (validator === undefined) {
throw "Not a valid type for a validator:" + type
}
const isValid = _value.map((v) => validator.isValid(v, getCountry))
let htmlElem: HTMLInputElement
let dispatch = createEventDispatcher<{ selected }>()
$: {
if (htmlElem !== undefined) {
htmlElem.onfocus = () => dispatch("selected")
}
}
</script>
{#if validator.textArea}
<textarea
class="w-full"
bind:value={$_value}
inputmode={validator.inputmode ?? "text"}
placeholder={_placeholder}
/>
{:else}
<span class="inline-flex">
<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}
</span>
{/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(_: string, __?: () => string): boolean {
return true
}
/**
* Reformats for the human
*/
public reformat(s: string, _?: () => string): string {
return s
}
}

View file

@ -0,0 +1,86 @@
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"
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",
] 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(),
]
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,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,7 @@
import { Validator } from "../Validator"
export default class StringValidator extends Validator {
constructor() {
super("string", "A simple piece of text")
}
}

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,71 @@
import { Validator } from "../Validator"
export default class UrlValidator extends Validator {
constructor() {
super(
"url",
"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
}
}