Add ifnot-tags for multianswers, in order to indicate something is _not_ possible (e.g.: playment:coins=no)
This commit is contained in:
parent
972128516b
commit
ae9d93138b
8 changed files with 99 additions and 131 deletions
|
@ -4,6 +4,7 @@ import Translations from "../../UI/i18n/Translations";
|
||||||
import {FromJSON} from "./FromJSON";
|
import {FromJSON} from "./FromJSON";
|
||||||
import ValidatedTextField from "../../UI/Input/ValidatedTextField";
|
import ValidatedTextField from "../../UI/Input/ValidatedTextField";
|
||||||
import {Translation} from "../../UI/i18n/Translation";
|
import {Translation} from "../../UI/i18n/Translation";
|
||||||
|
import {Utils} from "../../Utils";
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* The parsed version of TagRenderingConfigJSON
|
* The parsed version of TagRenderingConfigJSON
|
||||||
|
@ -24,9 +25,10 @@ export default class TagRenderingConfig {
|
||||||
readonly multiAnswer: boolean;
|
readonly multiAnswer: boolean;
|
||||||
|
|
||||||
readonly mappings?: {
|
readonly mappings?: {
|
||||||
readonly if: TagsFilter,
|
readonly if: TagsFilter,
|
||||||
readonly then: Translation
|
readonly ifnot?: TagsFilter,
|
||||||
readonly hideInAnswer: boolean | TagsFilter
|
readonly then: Translation
|
||||||
|
readonly hideInAnswer: boolean | TagsFilter
|
||||||
}[]
|
}[]
|
||||||
readonly roaming: boolean;
|
readonly roaming: boolean;
|
||||||
|
|
||||||
|
@ -74,7 +76,10 @@ export default class TagRenderingConfig {
|
||||||
this.mappings = json.mappings.map((mapping, i) => {
|
this.mappings = json.mappings.map((mapping, i) => {
|
||||||
|
|
||||||
if (mapping.then === undefined) {
|
if (mapping.then === undefined) {
|
||||||
throw "Invalid mapping: if without body"
|
throw `${context}.mapping[${i}]: Invalid mapping: if without body`
|
||||||
|
}
|
||||||
|
if (mapping.ifnot !== undefined && !this.multiAnswer) {
|
||||||
|
throw `${context}.mapping[${i}]: Invalid mapping: ifnot defined, but the tagrendering is not a multianswer`
|
||||||
}
|
}
|
||||||
let hideInAnswer: boolean | TagsFilter = false;
|
let hideInAnswer: boolean | TagsFilter = false;
|
||||||
if (typeof mapping.hideInAnswer === "boolean") {
|
if (typeof mapping.hideInAnswer === "boolean") {
|
||||||
|
@ -82,25 +87,53 @@ export default class TagRenderingConfig {
|
||||||
} else if (mapping.hideInAnswer !== undefined) {
|
} else if (mapping.hideInAnswer !== undefined) {
|
||||||
hideInAnswer = FromJSON.Tag(mapping.hideInAnswer, `${context}.mapping[${i}].hideInAnswer`);
|
hideInAnswer = FromJSON.Tag(mapping.hideInAnswer, `${context}.mapping[${i}].hideInAnswer`);
|
||||||
}
|
}
|
||||||
return {
|
const mp = {
|
||||||
if: FromJSON.Tag(mapping.if, `${context}.mapping[${i}].if`),
|
if: FromJSON.Tag(mapping.if, `${context}.mapping[${i}].if`),
|
||||||
|
ifnot: (mapping.ifnot !== undefined ? FromJSON.Tag(mapping.ifnot, `${context}.mapping[${i}].ifnot`) : undefined),
|
||||||
then: Translations.T(mapping.then),
|
then: Translations.T(mapping.then),
|
||||||
hideInAnswer: hideInAnswer
|
hideInAnswer: hideInAnswer
|
||||||
};
|
};
|
||||||
|
if (this.question) {
|
||||||
|
if (hideInAnswer !== true && !mp.if.isUsableAsAnswer()) {
|
||||||
|
throw `${context}.mapping[${i}].if: This value cannot be used to answer a question, probably because it contains a regex or an OR. Either change it or set 'hideInAnswer'`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideInAnswer !== true && !(mp.ifnot?.isUsableAsAnswer() ?? true)) {
|
||||||
|
throw `${context}.mapping[${i}].ifnot: This value cannot be used to answer a question, probably because it contains a regex or an OR. Either change it or set 'hideInAnswer'`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mp;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.question && this.freeform?.key === undefined && this.mappings === undefined) {
|
if (this.question && this.freeform?.key === undefined && this.mappings === undefined) {
|
||||||
throw `A question is defined, but no mappings nor freeform (key) are. The question is ${this.question.txt} at ${context}`
|
throw `${context}: A question is defined, but no mappings nor freeform (key) are. The question is ${this.question.txt} at ${context}`
|
||||||
}
|
|
||||||
|
|
||||||
if(this.freeform && this.render === undefined){
|
|
||||||
throw `Detected a freeform key without rendering... Key: ${this.freeform.key} in ${context}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.multiAnswer) {
|
if (this.freeform && this.render === undefined) {
|
||||||
|
throw `${context}: Detected a freeform key without rendering... Key: ${this.freeform.key} in ${context}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.question !== undefined && json.multiAnswer) {
|
||||||
if ((this.mappings?.length ?? 0) === 0) {
|
if ((this.mappings?.length ?? 0) === 0) {
|
||||||
throw "MultiAnswer is set, but no mappings are defined"
|
throw `${context} MultiAnswer is set, but no mappings are defined`
|
||||||
|
}
|
||||||
|
|
||||||
|
let allKeys = [];
|
||||||
|
let allHaveIfNot = true;
|
||||||
|
for (const mapping of this.mappings) {
|
||||||
|
if (mapping.hideInAnswer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mapping.ifnot === undefined) {
|
||||||
|
allHaveIfNot = false;
|
||||||
|
}
|
||||||
|
allKeys = allKeys.concat(mapping.if.usedKeys());
|
||||||
|
}
|
||||||
|
allKeys = Utils.Dedup(allKeys);
|
||||||
|
if (allKeys.length > 1 && !allHaveIfNot) {
|
||||||
|
throw `${context}: A multi-answer is defined, which generates values over multiple keys. Please define ifnot-tags too on every mapping`
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,11 @@ export interface TagRenderingConfigJson {
|
||||||
*/
|
*/
|
||||||
mappings?: {
|
mappings?: {
|
||||||
if: AndOrTagConfigJson | string,
|
if: AndOrTagConfigJson | string,
|
||||||
|
/**
|
||||||
|
* Only applicable if 'multiAnswer' is set.
|
||||||
|
* This tag is applied if the respective checkbox is unset
|
||||||
|
*/
|
||||||
|
ifnot?: AndOrTagConfigJson | string,
|
||||||
then: string | any
|
then: string | any
|
||||||
hideInAnswer?: boolean
|
hideInAnswer?: boolean
|
||||||
}[]
|
}[]
|
||||||
|
|
|
@ -294,7 +294,7 @@ export default class MetaTagging {
|
||||||
MetaTagging.isOpen,
|
MetaTagging.isOpen,
|
||||||
MetaTagging.carriageWayWidth,
|
MetaTagging.carriageWayWidth,
|
||||||
MetaTagging.directionSimplified,
|
MetaTagging.directionSimplified,
|
||||||
MetaTagging.currentTime
|
// MetaTagging.currentTime
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {Utils} from "../Utils";
|
import {Utils} from "../Utils";
|
||||||
|
import {type} from "os";
|
||||||
|
|
||||||
export abstract class TagsFilter {
|
export abstract class TagsFilter {
|
||||||
abstract matches(tags: { k: string, v: string }[]): boolean
|
abstract matches(tags: { k: string, v: string }[]): boolean
|
||||||
|
@ -16,6 +17,8 @@ export abstract class TagsFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract asHumanString(linkToWiki: boolean, shorten: boolean);
|
abstract asHumanString(linkToWiki: boolean, shorten: boolean);
|
||||||
|
|
||||||
|
abstract usedKeys(): string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,6 +89,13 @@ export class RegexTag extends TagsFilter {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usedKeys(): string[] {
|
||||||
|
if (typeof this.key === "string") {
|
||||||
|
return [this.key];
|
||||||
|
}
|
||||||
|
throw "Key cannot be determined as it is a regex"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -163,6 +173,10 @@ export class Tag extends TagsFilter {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usedKeys(): string[] {
|
||||||
|
return [this.key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -228,6 +242,10 @@ export class Or extends TagsFilter {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usedKeys(): string[] {
|
||||||
|
return [].concat(this.or.map(subkeys => subkeys.usedKeys()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -343,6 +361,10 @@ export class And extends TagsFilter {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usedKeys(): string[] {
|
||||||
|
return [].concat(this.and.map(subkeys => subkeys.usedKeys()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -456,10 +478,10 @@ export class TagUtils {
|
||||||
const splitted = TagUtils.SplitKeys([tag]);
|
const splitted = TagUtils.SplitKeys([tag]);
|
||||||
for (const splitKey in splitted) {
|
for (const splitKey in splitted) {
|
||||||
const neededValues = splitted[splitKey];
|
const neededValues = splitted[splitKey];
|
||||||
if(tags[splitKey] === undefined) {
|
if (tags[splitKey] === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualValue = tags[splitKey].split(";");
|
const actualValue = tags[splitKey].split(";");
|
||||||
for (const neededValue of neededValues) {
|
for (const neededValue of neededValues) {
|
||||||
if (actualValue.indexOf(neededValue) < 0) {
|
if (actualValue.indexOf(neededValue) < 0) {
|
||||||
|
|
|
@ -107,14 +107,14 @@ export default class TagRenderingQuestion extends UIElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._configuration.multiAnswer) {
|
if (this._configuration.multiAnswer) {
|
||||||
return this.GenerateMultiAnswer(mappings, ff)
|
return this.GenerateMultiAnswer(mappings, ff, this._configuration.mappings.map(mp => mp.ifnot))
|
||||||
} else {
|
} else {
|
||||||
return new RadioButton(mappings, false)
|
return new RadioButton(mappings, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private GenerateMultiAnswer(elements: InputElement<TagsFilter>[], freeformField: InputElement<TagsFilter>): InputElement<TagsFilter> {
|
private GenerateMultiAnswer(elements: InputElement<TagsFilter>[], freeformField: InputElement<TagsFilter>, ifNotSelected: TagsFilter[]): InputElement<TagsFilter> {
|
||||||
const checkBoxes = new CheckBoxes(elements);
|
const checkBoxes = new CheckBoxes(elements);
|
||||||
const inputEl = new InputElementMap<number[], TagsFilter>(
|
const inputEl = new InputElementMap<number[], TagsFilter>(
|
||||||
checkBoxes,
|
checkBoxes,
|
||||||
|
@ -126,6 +126,18 @@ export default class TagRenderingQuestion extends UIElement {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const tags: TagsFilter[] = indices.map(i => elements[i].GetValue().data);
|
const tags: TagsFilter[] = indices.map(i => elements[i].GetValue().data);
|
||||||
|
const oppositeTags: TagsFilter[] = [];
|
||||||
|
for (let i = 0; i < ifNotSelected.length; i++) {
|
||||||
|
if(indices.indexOf(i) >= 0){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const notSelected = ifNotSelected[i];
|
||||||
|
if(notSelected === undefined){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
oppositeTags.push(notSelected);
|
||||||
|
}
|
||||||
|
tags.push(TagUtils.FlattenMultiAnswer(oppositeTags));
|
||||||
return TagUtils.FlattenMultiAnswer(tags);
|
return TagUtils.FlattenMultiAnswer(tags);
|
||||||
},
|
},
|
||||||
(tags: TagsFilter) => {
|
(tags: TagsFilter) => {
|
||||||
|
|
47
Utils.ts
47
Utils.ts
|
@ -141,21 +141,9 @@ export class Utils {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date will be undefined on failure
|
// Date will be undefined on failure
|
||||||
public static changesetDate(id: number, action: ((isFound: Date) => void)): void {
|
|
||||||
$.getJSON("https://www.openstreetmap.org/api/0.6/changeset/" + id,
|
|
||||||
function (data) {
|
|
||||||
console.log(data)
|
|
||||||
action(new Date(data.elements[0].created_at));
|
|
||||||
})
|
|
||||||
.fail(() => {
|
|
||||||
action(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public static LoadCustomCss(location: string){
|
public static LoadCustomCss(location: string){
|
||||||
var head = document.getElementsByTagName('head')[0];
|
const head = document.getElementsByTagName('head')[0];
|
||||||
var link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.id = "customCss";
|
link.id = "customCss";
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
link.type = 'text/css';
|
link.type = 'text/css';
|
||||||
|
@ -164,17 +152,6 @@ export class Utils {
|
||||||
head.appendChild(link);
|
head.appendChild(link);
|
||||||
console.log("Added custom layout ",location)
|
console.log("Added custom layout ",location)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static MatchKeys(object: any, prototype: any, context?: string){
|
|
||||||
|
|
||||||
for (const objectKey in object) {
|
|
||||||
if(prototype[objectKey] === undefined){
|
|
||||||
console.error("Key ", objectKey, "might be not supported (in context",context,")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Merge(source: any, target: any){
|
static Merge(source: any, target: any){
|
||||||
target = JSON.parse(JSON.stringify(target));
|
target = JSON.parse(JSON.stringify(target));
|
||||||
source = JSON.parse(JSON.stringify(source));
|
source = JSON.parse(JSON.stringify(source));
|
||||||
|
@ -195,24 +172,4 @@ export class Utils {
|
||||||
}
|
}
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
static ToMuchTags(source: any, toCheck: any, context: string){
|
|
||||||
|
|
||||||
for (const key in toCheck) {
|
|
||||||
const toCheckV = toCheck[key];
|
|
||||||
const sourceV = source[key];
|
|
||||||
if(sourceV === undefined){
|
|
||||||
console.error("Probably a wrong tag in ", context, ": ", key, "might be wrong")
|
|
||||||
}
|
|
||||||
if(typeof toCheckV === "object"){
|
|
||||||
if(typeof sourceV !== "object"){
|
|
||||||
console.error("Probably a wrong value in ", context, ": ", key, "is a fixed value in the source")
|
|
||||||
}else{
|
|
||||||
Utils.ToMuchTags(sourceV, toCheckV, context+"."+key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -104,14 +104,17 @@
|
||||||
"mappings": [
|
"mappings": [
|
||||||
{
|
{
|
||||||
"if": "payment:coins=yes",
|
"if": "payment:coins=yes",
|
||||||
|
"ifnot": "payment:coins=no",
|
||||||
"then": "Payment with coins is possible"
|
"then": "Payment with coins is possible"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"if": "payment:notes=yes",
|
"if": "payment:notes=yes",
|
||||||
|
"ifnot": "payment:notes=no",
|
||||||
"then": "Payment with notes is possible"
|
"then": "Payment with notes is possible"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"if": "payment:cards=yes",
|
"if": "payment:cards=yes",
|
||||||
|
"ifnot": "payment:cards=no",
|
||||||
"then": "Payment with cards is possible"
|
"then": "Payment with cards is possible"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -157,22 +160,27 @@
|
||||||
"mappings": [
|
"mappings": [
|
||||||
{
|
{
|
||||||
"if": "vending:bicycle_light=yes",
|
"if": "vending:bicycle_light=yes",
|
||||||
|
"ifnot": "vending:bicycle_light=no",
|
||||||
"then": "Bicycle lights are sold here"
|
"then": "Bicycle lights are sold here"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"if": "vending:gloves=yes",
|
"if": "vending:gloves=yes",
|
||||||
|
"ifnot": "vending:gloves=no",
|
||||||
"then": "Gloves are sold here"
|
"then": "Gloves are sold here"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"if": "vending:bicycle_repair_kit=yes",
|
"if": "vending:bicycle_repair_kit=yes",
|
||||||
|
"ifnot": "vending:bicycle_repair_kit=no",
|
||||||
"then": "Bicycle repair kits are sold here"
|
"then": "Bicycle repair kits are sold here"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"if": "vending:bicycle_pump=yes",
|
"if": "vending:bicycle_pump=yes",
|
||||||
|
"ifnot": "vending:bicycle_pump=no",
|
||||||
"then": "Bicycle pumps are sold here"
|
"then": "Bicycle pumps are sold here"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"if": "vending:bicycle_lock=yes",
|
"if": "vending:bicycle_lock=yes",
|
||||||
|
"ifnot": "vending:bicycle_lock=no",
|
||||||
"then": "Bicycle locks are sold here"
|
"then": "Bicycle locks are sold here"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
69
test.ts
69
test.ts
|
@ -13,72 +13,3 @@ const images = new UIEventSource<{ url: string, key: string }[]>(
|
||||||
new ImageCarousel(images, new UIEventSource<any>({"image:1":"https://2.bp.blogspot.com/-fQiZkz9Zlzg/T_xe2X2Ia3I/AAAAAAAAA0Q/VPS8Mb8xtIQ/s1600/cat+15.jpg"}))
|
new ImageCarousel(images, new UIEventSource<any>({"image:1":"https://2.bp.blogspot.com/-fQiZkz9Zlzg/T_xe2X2Ia3I/AAAAAAAAA0Q/VPS8Mb8xtIQ/s1600/cat+15.jpg"}))
|
||||||
.AttachTo("maindiv")
|
.AttachTo("maindiv")
|
||||||
|
|
||||||
/*/
|
|
||||||
import {Utils} from "./Utils";
|
|
||||||
import {FixedUiElement} from "./UI/Base/FixedUiElement";
|
|
||||||
|
|
||||||
|
|
||||||
function generateStats(action: (stats: string) => void) {
|
|
||||||
// Binary searches the latest changeset
|
|
||||||
function search(lowerBound: number,
|
|
||||||
upperBound: number,
|
|
||||||
onCsFound: ((id: number, lastDate: Date) => void),
|
|
||||||
depth = 0) {
|
|
||||||
if (depth > 30) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tested = Math.floor((lowerBound + upperBound) / 2);
|
|
||||||
console.log("Testing", tested)
|
|
||||||
Utils.changesetDate(tested, (createdAtDate: Date) => {
|
|
||||||
new FixedUiElement(`Searching, value between ${lowerBound} and ${upperBound}. Queries till now: ${depth}`).AttachTo('maindiv')
|
|
||||||
if (lowerBound + 1 >= upperBound) {
|
|
||||||
onCsFound(lowerBound, createdAtDate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (createdAtDate !== undefined) {
|
|
||||||
search(tested, upperBound, onCsFound, depth + 1)
|
|
||||||
} else {
|
|
||||||
search(lowerBound, tested, onCsFound, depth + 1);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
search(91000000, 100000000, (last, lastDate: Date) => {
|
|
||||||
const link = "http://osm.org/changeset/" + last;
|
|
||||||
|
|
||||||
const delta = 100000;
|
|
||||||
|
|
||||||
Utils.changesetDate(last - delta, (prevDate) => {
|
|
||||||
|
|
||||||
|
|
||||||
const diff = (lastDate.getTime() - prevDate.getTime()) / 1000;
|
|
||||||
|
|
||||||
// Diff: seconds needed/delta changesets
|
|
||||||
const secsPerCS = diff / delta;
|
|
||||||
|
|
||||||
const stillNeeded = 1000000 - (last % 1000000);
|
|
||||||
const timeNeededSeconds = Math.floor(secsPerCS * stillNeeded);
|
|
||||||
|
|
||||||
const secNeeded = timeNeededSeconds % 60;
|
|
||||||
const minNeeded = Math.floor(timeNeededSeconds / 60) % 60;
|
|
||||||
const hourNeeded = Math.floor(timeNeededSeconds / (60 * 60)) % 24;
|
|
||||||
const daysNeeded = Math.floor(timeNeededSeconds / (24 * 60 * 60));
|
|
||||||
|
|
||||||
const result = `Last changeset: <a href='${link}'>${link}</a><br/>We needed ${(Math.floor(diff / 60))} minutes for the last ${delta} changesets.<br/>
|
|
||||||
This is around ${secsPerCS} seconds/changeset.<br/> The next million (still ${stillNeeded} away) will be broken in around ${daysNeeded} days ${hourNeeded}:${minNeeded}:${secNeeded}`
|
|
||||||
action(result);
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
generateStats((stats) => {
|
|
||||||
new FixedUiElement(stats).AttachTo('maindiv')
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
//*/
|
|
Loading…
Reference in a new issue