forked from MapComplete/MapComplete
More refactoring
This commit is contained in:
parent
5d0fe31c41
commit
41e6a2c760
147 changed files with 1540 additions and 1797 deletions
7
UI/InputElement/Validators/ColorValidator.ts
Normal file
7
UI/InputElement/Validators/ColorValidator.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
export default class ColorValidator extends Validator {
|
||||
constructor() {
|
||||
super("color", "Shows a color picker")
|
||||
}
|
||||
}
|
23
UI/InputElement/Validators/DateValidator.ts
Normal file
23
UI/InputElement/Validators/DateValidator.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
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) {
|
||||
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("-")
|
||||
}
|
||||
}
|
17
UI/InputElement/Validators/DirectionValidator.ts
Normal file
17
UI/InputElement/Validators/DirectionValidator.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
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)"
|
||||
)
|
||||
}
|
||||
|
||||
reformat(str): string {
|
||||
const n = Number(str) % 360
|
||||
return "" + n
|
||||
}
|
||||
|
||||
}
|
39
UI/InputElement/Validators/EmailValidator.ts
Normal file
39
UI/InputElement/Validators/EmailValidator.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { Validator } from "../ValidatedTextField.js"
|
||||
import { Translation } from "../../i18n/Translation.js"
|
||||
import Translations from "../../i18n/Translations.js"
|
||||
import * as emailValidatorLibrary from "email-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)
|
||||
}
|
||||
}
|
27
UI/InputElement/Validators/FloatValidator.ts
Normal file
27
UI/InputElement/Validators/FloatValidator.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
29
UI/InputElement/Validators/IntValidator.ts
Normal file
29
UI/InputElement/Validators/IntValidator.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
16
UI/InputElement/Validators/LengthValidator.ts
Normal file
16
UI/InputElement/Validators/LengthValidator.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
30
UI/InputElement/Validators/NatValidator.ts
Normal file
30
UI/InputElement/Validators/NatValidator.ts
Normal 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
|
||||
}
|
||||
}
|
54
UI/InputElement/Validators/OpeningHoursValidator.ts
Normal file
54
UI/InputElement/Validators/OpeningHoursValidator.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
import Combine from "../../Base/Combine"
|
||||
import Title from "../../Base/Title"
|
||||
import Table from "../../Base/Table"
|
||||
|
||||
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 )`",
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
23
UI/InputElement/Validators/PFloatValidator.ts
Normal file
23
UI/InputElement/Validators/PFloatValidator.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Translation } from "../../i18n/Translation"
|
||||
import Translations from "../../i18n/Translations"
|
||||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
27
UI/InputElement/Validators/PNatValidator.ts
Normal file
27
UI/InputElement/Validators/PNatValidator.ts
Normal 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
|
||||
}
|
||||
}
|
32
UI/InputElement/Validators/PhoneValidator.ts
Normal file
32
UI/InputElement/Validators/PhoneValidator.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
import { parsePhoneNumberFromString } from "libphonenumber-js"
|
||||
|
||||
export default class PhoneValidator extends Validator {
|
||||
constructor() {
|
||||
super("phone", "A phone number", "tel")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
reformat = (str, country: () => string) => {
|
||||
if (str.startsWith("tel:")) {
|
||||
str = str.substring("tel:".length)
|
||||
}
|
||||
return parsePhoneNumberFromString(
|
||||
str,
|
||||
country()?.toUpperCase() as any
|
||||
)?.formatInternational()
|
||||
}
|
||||
}
|
8
UI/InputElement/Validators/StringValidator.ts
Normal file
8
UI/InputElement/Validators/StringValidator.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
export default class StringValidator extends Validator {
|
||||
constructor() {
|
||||
super("string", "A simple piece of text")
|
||||
}
|
||||
|
||||
}
|
7
UI/InputElement/Validators/TextValidator.ts
Normal file
7
UI/InputElement/Validators/TextValidator.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
export default class TextValidator extends Validator {
|
||||
constructor() {
|
||||
super("text", "A longer piece of text. Uses an textArea instead of a textField", "text")
|
||||
}
|
||||
}
|
75
UI/InputElement/Validators/UrlValidator.ts
Normal file
75
UI/InputElement/Validators/UrlValidator.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (!str.startsWith("http") && cleaned.startsWith("https://")) {
|
||||
cleaned = cleaned.substr("https://".length)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
179
UI/InputElement/Validators/WikidataValidator.ts
Normal file
179
UI/InputElement/Validators/WikidataValidator.ts
Normal file
|
@ -0,0 +1,179 @@
|
|||
import Combine from "../../Base/Combine"
|
||||
import Title from "../../Base/Title"
|
||||
import Table from "../../Base/Table"
|
||||
import Wikidata from "../../../Logic/Web/Wikidata"
|
||||
import { UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import Locale from "../../i18n/Locale"
|
||||
import { Utils } from "../../../Utils"
|
||||
import WikidataSearchBox from "../../Wikipedia/WikidataSearchBox"
|
||||
import { Validator } from "../ValidatedTextField"
|
||||
|
||||
export default class WikidataValidator extends Validator {
|
||||
constructor() {
|
||||
super(
|
||||
"wikidata",
|
||||
new Combine([
|
||||
"A wikidata identifier, e.g. Q42.",
|
||||
new Title("Helper arguments"),
|
||||
new Table(
|
||||
["name", "doc"],
|
||||
[
|
||||
["key", "the value of this tag will initialize search (default: name)"],
|
||||
[
|
||||
"options",
|
||||
new Combine([
|
||||
"A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.",
|
||||
new Table(
|
||||
["subarg", "doc"],
|
||||
[
|
||||
[
|
||||
"removePrefixes",
|
||||
"remove these snippets of text from the start of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes",
|
||||
],
|
||||
[
|
||||
"removePostfixes",
|
||||
"remove these snippets of text from the end of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes.",
|
||||
],
|
||||
[
|
||||
"instanceOf",
|
||||
"A list of Q-identifier which indicates that the search results _must_ be an entity of this type, e.g. [`Q5`](https://www.wikidata.org/wiki/Q5) for humans",
|
||||
],
|
||||
[
|
||||
"notInstanceof",
|
||||
"A list of Q-identifiers which indicates that the search results _must not_ be an entity of this type, e.g. [`Q79007`](https://www.wikidata.org/wiki/Q79007) to filter away all streets from the search results",
|
||||
],
|
||||
]
|
||||
),
|
||||
]),
|
||||
],
|
||||
]
|
||||
),
|
||||
new Title("Example usage"),
|
||||
`The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name
|
||||
|
||||
\`\`\`json
|
||||
"freeform": {
|
||||
"key": "name:etymology:wikidata",
|
||||
"type": "wikidata",
|
||||
"helperArgs": [
|
||||
"name",
|
||||
{
|
||||
"removePostfixes": {"en": [
|
||||
"street",
|
||||
"boulevard",
|
||||
"path",
|
||||
"square",
|
||||
"plaza",
|
||||
],
|
||||
"nl": ["straat","plein","pad","weg",laan"],
|
||||
"fr":["route (de|de la|de l'| de le)"]
|
||||
},
|
||||
|
||||
"#": "Remove streets and parks from the search results:"
|
||||
"notInstanceOf": ["Q79007","Q22698"]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Another example is to search for species and trees:
|
||||
|
||||
\`\`\`json
|
||||
"freeform": {
|
||||
"key": "species:wikidata",
|
||||
"type": "wikidata",
|
||||
"helperArgs": [
|
||||
"species",
|
||||
{
|
||||
"instanceOf": [10884, 16521]
|
||||
}]
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
public inputHelper(currentValue, inputHelperOptions) {
|
||||
const args = inputHelperOptions.args ?? []
|
||||
const searchKey = 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: currentValue,
|
||||
searchText: searchForValue,
|
||||
instanceOf,
|
||||
notInstanceOf,
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue