Style dataclasses
This commit is contained in:
parent
69be299199
commit
7983afde45
4 changed files with 286 additions and 186 deletions
|
@ -85,3 +85,16 @@ def sorted_dict(d: Mapping[_T, _T_Sortable]) -> dict[_T, _T_Sortable]:
|
|||
class IProperty(Protocol[_T_covariant]):
|
||||
def __get__(self, __instance: Any, __owner: type | None = None, /) -> _T_covariant:
|
||||
...
|
||||
|
||||
|
||||
_K = TypeVar("_K")
|
||||
_V = TypeVar("_V")
|
||||
|
||||
|
||||
class NoClobberDict(dict[_K, _V]):
|
||||
"""Dict which raises KeyError if the key being assigned to is already present."""
|
||||
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
if key in self:
|
||||
raise KeyError(f"Key {key} already exists in dict")
|
||||
super().__setitem__(key, value)
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
import io
|
||||
from dataclasses import InitVar, dataclass
|
||||
from html import escape
|
||||
from typing import IO, Any
|
||||
from typing import Any
|
||||
|
||||
from hexcasting import HexBook
|
||||
from hexcasting.abstract_hex_pages import PageWithPattern
|
||||
|
@ -19,6 +17,7 @@ from patchouli.page import (
|
|||
SpotlightPage,
|
||||
TextPage,
|
||||
)
|
||||
from patchouli.text.formatting import Stream
|
||||
|
||||
# extra info :(
|
||||
# TODO: properties.toml
|
||||
|
@ -27,94 +26,6 @@ repo_names = {
|
|||
}
|
||||
|
||||
|
||||
# TODO: type
|
||||
def tag_args(kwargs: dict[str, Any]):
|
||||
return "".join(
|
||||
f" {'class' if key == 'clazz' else key.replace('_', '-')}={repr(escape(str(value)))}"
|
||||
for key, value in kwargs.items()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairTag:
|
||||
stream: IO[str]
|
||||
name: str
|
||||
args: InitVar[dict[str, Any]]
|
||||
|
||||
def __post_init__(self, args: dict[str, Any]):
|
||||
self.args_str = tag_args(args)
|
||||
|
||||
def __enter__(self):
|
||||
print(f"<{self.name}{self.args_str}>", file=self.stream, end="")
|
||||
|
||||
def __exit__(self, _1: Any, _2: Any, _3: Any):
|
||||
print(f"</{self.name}>", file=self.stream, end="")
|
||||
|
||||
|
||||
class Empty:
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, _1: Any, _2: Any, _3: Any):
|
||||
pass
|
||||
|
||||
|
||||
class Stream:
|
||||
__slots__ = ["stream"]
|
||||
|
||||
def __init__(self, stream: IO[str]):
|
||||
self.stream = stream
|
||||
|
||||
def tag(self, name: str, **kwargs: Any):
|
||||
keywords = tag_args(kwargs)
|
||||
print(f"<{name}{keywords} />", file=self.stream, end="")
|
||||
return self
|
||||
|
||||
def pair_tag(self, name: str, **kwargs: Any):
|
||||
return PairTag(self.stream, name, kwargs)
|
||||
|
||||
def pair_tag_if(self, cond: Any, name: str, **kwargs: Any):
|
||||
return self.pair_tag(name, **kwargs) if cond else Empty()
|
||||
|
||||
def empty_pair_tag(self, name: str, **kwargs: Any):
|
||||
with self.pair_tag(name, **kwargs):
|
||||
pass
|
||||
|
||||
def text(self, txt: str | LocalizedStr):
|
||||
print(escape(str(txt)), file=self.stream, end="")
|
||||
return self
|
||||
|
||||
|
||||
# TODO: move
|
||||
def get_format(out: Stream, ty: str, value: Any):
|
||||
if ty == "para":
|
||||
return out.pair_tag("p", **value)
|
||||
if ty == "color":
|
||||
return out.pair_tag("span", style=f"color: #{value}")
|
||||
if ty == "link":
|
||||
link = value
|
||||
if "://" not in link:
|
||||
link = "#" + link.replace("#", "@")
|
||||
return out.pair_tag("a", href=link)
|
||||
if ty == "tooltip":
|
||||
return out.pair_tag("span", clazz="has-tooltip", title=value)
|
||||
if ty == "cmd_click":
|
||||
return out.pair_tag(
|
||||
"span", clazz="has-cmd_click", title="When clicked, would execute: " + value
|
||||
)
|
||||
if ty == "obf":
|
||||
return out.pair_tag("span", clazz="obfuscated")
|
||||
if ty == "bold":
|
||||
return out.pair_tag("strong")
|
||||
if ty == "italic":
|
||||
return out.pair_tag("i")
|
||||
if ty == "strikethrough":
|
||||
return out.pair_tag("s")
|
||||
if ty == "underline":
|
||||
return out.pair_tag("span", style="text-decoration: underline")
|
||||
raise ValueError("Unknown format type: " + ty)
|
||||
|
||||
|
||||
def entry_spoilered(root_info: HexBook, entry: Entry):
|
||||
if entry.advancement is None:
|
||||
return False
|
||||
|
@ -136,13 +47,7 @@ def write_block(out: Stream, block: FormatTree | str | LocalizedStr):
|
|||
first = True
|
||||
out.text(line)
|
||||
return
|
||||
sty_type = block.style.type
|
||||
if sty_type == "base":
|
||||
for child in block.children:
|
||||
write_block(out, child)
|
||||
return
|
||||
tag = get_format(out, sty_type, block.style.value)
|
||||
with tag:
|
||||
with block.style.tag(out):
|
||||
for child in block.children:
|
||||
write_block(out, child)
|
||||
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import re
|
||||
from typing import NamedTuple, Self, cast
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import InitVar, dataclass as py_dataclass
|
||||
from html import escape
|
||||
from typing import IO, Any, Callable, ContextManager, Self, cast
|
||||
|
||||
from pydantic import ValidationInfo, model_validator
|
||||
from pydantic.dataclasses import dataclass
|
||||
from pydantic.functional_validators import ModelWrapValidatorHandler
|
||||
|
||||
from common.model import DEFAULT_CONFIG
|
||||
from common.types import NoClobberDict
|
||||
from minecraft.i18n import I18nContext, LocalizedStr
|
||||
|
||||
DEFAULT_MACROS = {
|
||||
|
@ -28,8 +35,8 @@ DEFAULT_MACROS = {
|
|||
"$(thing)": "$(#490)",
|
||||
}
|
||||
|
||||
_COLORS: dict[str, str | None] = {
|
||||
"0": None,
|
||||
_COLORS = {
|
||||
# "0": None, # TODO: find an actual value for this
|
||||
"1": "00a",
|
||||
"2": "0a0",
|
||||
"3": "0aa",
|
||||
|
@ -47,75 +54,254 @@ _COLORS: dict[str, str | None] = {
|
|||
"f": "fff",
|
||||
}
|
||||
|
||||
_TYPES = {
|
||||
"k": "obf",
|
||||
"l": "bold",
|
||||
"m": "strikethrough",
|
||||
"n": "underline",
|
||||
"o": "italic",
|
||||
}
|
||||
|
||||
_KEYS = {
|
||||
"use": "Right Click",
|
||||
"sneak": "Left Shift",
|
||||
}
|
||||
|
||||
|
||||
class Style(NamedTuple):
|
||||
type: str
|
||||
value: str | bool | dict[str, str] | None
|
||||
# TODO: type
|
||||
def tag_args(kwargs: dict[str, Any]):
|
||||
return "".join(
|
||||
f" {'class' if key == 'clazz' else key.replace('_', '-')}={repr(escape(str(value)))}"
|
||||
for key, value in kwargs.items()
|
||||
)
|
||||
|
||||
|
||||
# TODO: make Style a dataclass, subclass for each type
|
||||
def parse_style(style_text: str) -> Style | str:
|
||||
if style_text in _TYPES:
|
||||
return Style(_TYPES[style_text], True)
|
||||
if style_text in _COLORS:
|
||||
return Style("color", _COLORS[style_text])
|
||||
if style_text.startswith("#") and len(style_text) in [4, 7]:
|
||||
return Style("color", style_text[1:])
|
||||
@py_dataclass
|
||||
class PairTag:
|
||||
stream: IO[str]
|
||||
name: str
|
||||
args: InitVar[dict[str, Any]]
|
||||
|
||||
# try matching the entire string
|
||||
match style_text:
|
||||
# replacements
|
||||
def __post_init__(self, args: dict[str, Any]):
|
||||
self.args_str = tag_args(args)
|
||||
|
||||
def __enter__(self):
|
||||
# TODO: self.stream.write??????????
|
||||
print(f"<{self.name}{self.args_str}>", file=self.stream, end="")
|
||||
|
||||
def __exit__(self, _1: Any, _2: Any, _3: Any):
|
||||
print(f"</{self.name}>", file=self.stream, end="")
|
||||
|
||||
|
||||
class Empty:
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, _1: Any, _2: Any, _3: Any):
|
||||
pass
|
||||
|
||||
|
||||
class Stream:
|
||||
__slots__ = ["stream"]
|
||||
|
||||
def __init__(self, stream: IO[str]):
|
||||
self.stream = stream
|
||||
|
||||
def tag(self, name: str, **kwargs: Any):
|
||||
keywords = tag_args(kwargs)
|
||||
print(f"<{name}{keywords} />", file=self.stream, end="")
|
||||
return self
|
||||
|
||||
def pair_tag(self, name: str, **kwargs: Any):
|
||||
return PairTag(self.stream, name, kwargs)
|
||||
|
||||
def pair_tag_if(self, cond: Any, name: str, **kwargs: Any):
|
||||
return self.pair_tag(name, **kwargs) if cond else Empty()
|
||||
|
||||
def empty_pair_tag(self, name: str, **kwargs: Any):
|
||||
with self.pair_tag(name, **kwargs):
|
||||
pass
|
||||
|
||||
def text(self, txt: str | LocalizedStr):
|
||||
print(escape(str(txt)), file=self.stream, end="")
|
||||
return self
|
||||
|
||||
|
||||
_COMMAND_LOOKUPS: list[CommandLookup] = []
|
||||
_COMMANDS: dict[str, StyleCommand] = NoClobberDict()
|
||||
_FUNCTIONS: dict[str, StyleFunction] = NoClobberDict()
|
||||
|
||||
|
||||
def command_lookup(fn: CommandLookup) -> CommandLookup:
|
||||
_COMMAND_LOOKUPS.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
# TODO: refactor literally all of everything, this is still pretty disgusting
|
||||
@dataclass(config=DEFAULT_CONFIG)
|
||||
class Style(ABC):
|
||||
@classmethod
|
||||
def parse(cls, style_text: str) -> Style | str:
|
||||
# command lookups (includes commands and functions)
|
||||
for lookup in _COMMAND_LOOKUPS:
|
||||
if (style := lookup(style_text)) is not None:
|
||||
return style
|
||||
|
||||
# oopsies
|
||||
raise ValueError(f"Unhandled style: {style_text}")
|
||||
|
||||
@abstractmethod
|
||||
def tag(self, out: Stream) -> ContextManager[None]:
|
||||
...
|
||||
|
||||
def can_close(self, other: Style) -> bool:
|
||||
return isinstance(self, type(other)) or isinstance(other, type(self))
|
||||
|
||||
|
||||
@command_lookup
|
||||
def replacement_processor(name: str):
|
||||
match name:
|
||||
case "br":
|
||||
return "\n"
|
||||
case "playername":
|
||||
return "[Playername]"
|
||||
# styles
|
||||
case "br2":
|
||||
return Style("para", {})
|
||||
case "li":
|
||||
return Style("para", {"clazz": "fake-li"})
|
||||
case "/l":
|
||||
return Style("link", None)
|
||||
case "/t":
|
||||
return Style("tooltip", None)
|
||||
case "/c":
|
||||
return Style("cmd_click", None)
|
||||
case "reset" | "":
|
||||
# TODO: this was "r" before, but patchouli's code has "reset"
|
||||
# the tests pass either way so I don't think we're using it
|
||||
return Style("base", None)
|
||||
case _:
|
||||
pass
|
||||
return None
|
||||
|
||||
# command prefixes
|
||||
command, rest = style_text[:2], style_text[2:]
|
||||
match command:
|
||||
# replacement
|
||||
case "k:":
|
||||
return _KEYS[rest]
|
||||
# styles
|
||||
case "l:":
|
||||
return Style("link", rest)
|
||||
case "t:":
|
||||
return Style("tooltip", rest)
|
||||
case "c:":
|
||||
return Style("cmd_click", rest)
|
||||
case _:
|
||||
# TODO more style parse
|
||||
raise ValueError("Unknown style: " + style_text)
|
||||
|
||||
@command_lookup
|
||||
def command_processor(name: str):
|
||||
if style_type := _COMMANDS.get(name):
|
||||
return style_type()
|
||||
|
||||
|
||||
@command_lookup
|
||||
def function_processor(style_text: str):
|
||||
if ":" in style_text:
|
||||
name, param = style_text.split(":", 1)
|
||||
if style_type := _FUNCTIONS.get(name):
|
||||
return style_type(param)
|
||||
raise ValueError(f"Unhandled function: {style_text}")
|
||||
|
||||
|
||||
@dataclass(config=DEFAULT_CONFIG)
|
||||
class BaseStyleCommand(Style):
|
||||
def __init_subclass__(cls, names: list[str]) -> None:
|
||||
for name in names:
|
||||
_COMMANDS[name] = cls
|
||||
|
||||
|
||||
@dataclass(config=DEFAULT_CONFIG)
|
||||
class EndFunctionStyle(BaseStyleCommand, names=[]):
|
||||
function_type: type[BaseStyleFunction]
|
||||
|
||||
def tag(self, out: Stream):
|
||||
return contextlib.nullcontext()
|
||||
|
||||
def can_close(self, other: Style) -> bool:
|
||||
return super().can_close(other) or isinstance(other, self.function_type)
|
||||
|
||||
|
||||
@dataclass(config=DEFAULT_CONFIG)
|
||||
class BaseStyleFunction(Style):
|
||||
value: str | None
|
||||
|
||||
def __init_subclass__(cls, names: list[str]) -> None:
|
||||
for name in names:
|
||||
_FUNCTIONS[name] = cls
|
||||
_COMMANDS[f"/{name}"] = lambda: EndFunctionStyle(cls)
|
||||
|
||||
|
||||
CommandLookup = Callable[[str], Style | str | None]
|
||||
|
||||
StyleCommand = Callable[[], Style | str]
|
||||
|
||||
StyleFunction = Callable[[str], BaseStyleFunction | str]
|
||||
|
||||
|
||||
def style_function(name: str):
|
||||
def wrap(fn: StyleFunction) -> StyleFunction:
|
||||
_FUNCTIONS[name] = fn
|
||||
return fn
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
class ClearStyle(BaseStyleCommand, names=[""]):
|
||||
def tag(self, out: Stream):
|
||||
return contextlib.nullcontext()
|
||||
|
||||
|
||||
class ParagraphStyle(BaseStyleCommand, names=["br2"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("p")
|
||||
|
||||
|
||||
class ListItemStyle(ParagraphStyle, names=["li"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("p", clazz="fake-li")
|
||||
|
||||
|
||||
class ObfuscatedStyle(BaseStyleCommand, names=["k"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("span", clazz="obfuscated")
|
||||
|
||||
|
||||
class BoldStyle(BaseStyleCommand, names=["l"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("strong")
|
||||
|
||||
|
||||
class StrikethroughStyle(BaseStyleCommand, names=["m"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("s")
|
||||
|
||||
|
||||
class UnderlineStyle(BaseStyleCommand, names=["n"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("span", style="text-decoration: underline")
|
||||
|
||||
|
||||
class ItalicStyle(BaseStyleCommand, names=["o"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("i")
|
||||
|
||||
|
||||
@style_function("k")
|
||||
def get_keybind_key(param: str):
|
||||
if (key := _KEYS.get(param)) is not None:
|
||||
return key
|
||||
raise ValueError(f"Unhandled key: {param}")
|
||||
|
||||
|
||||
# TODO: this should use Color but i'm pretty sure that will fail the snapshots
|
||||
class ColorStyle(BaseStyleFunction, names=[]):
|
||||
@command_lookup
|
||||
@staticmethod
|
||||
def processor(param: str):
|
||||
if param in _COLORS:
|
||||
return ColorStyle(_COLORS[param])
|
||||
if param.startswith("#") and len(param) in [4, 7]:
|
||||
return ColorStyle(param[1:])
|
||||
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("span", style=f"color: #{self.value}")
|
||||
|
||||
|
||||
class LinkStyle(BaseStyleFunction, names=["l"]):
|
||||
def tag(self, out: Stream):
|
||||
href = self.value
|
||||
if href is not None and not href.startswith(("http:", "https:")):
|
||||
href = "#" + href.replace("#", "@")
|
||||
return out.pair_tag("a", href=href)
|
||||
|
||||
|
||||
class TooltipStyle(BaseStyleFunction, names=["t"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag("span", clazz="has-tooltip", title=self.value)
|
||||
|
||||
|
||||
class CmdClickStyle(BaseStyleFunction, names=["c"]):
|
||||
def tag(self, out: Stream):
|
||||
return out.pair_tag(
|
||||
"span",
|
||||
clazz="has-cmd_click",
|
||||
title=f"When clicked, would execute: {self.value}",
|
||||
)
|
||||
|
||||
|
||||
# class GangnamStyle: pass
|
||||
|
||||
|
||||
_FORMAT_RE = re.compile(r"\$\(([^)]*)\)")
|
||||
|
@ -132,7 +318,7 @@ class FormatTree:
|
|||
|
||||
@classmethod
|
||||
def empty(cls) -> Self:
|
||||
return cls(Style("base", None), [])
|
||||
return cls(ClearStyle(), [])
|
||||
|
||||
@classmethod
|
||||
def format(cls, string: str, macros: dict[str, str]) -> Self:
|
||||
|
@ -156,7 +342,7 @@ class FormatTree:
|
|||
text_since_prev_style.append(leading_text)
|
||||
last_end = match.end()
|
||||
|
||||
match parse_style(match[1]):
|
||||
match Style.parse(match[1]):
|
||||
case str(replacement):
|
||||
# str means "use this instead of the original value"
|
||||
text_since_prev_style.append(replacement)
|
||||
|
@ -171,29 +357,32 @@ class FormatTree:
|
|||
|
||||
# parse
|
||||
style_stack = [
|
||||
FormatTree(Style("base", True), []),
|
||||
FormatTree(Style("para", {}), [first_node]),
|
||||
FormatTree(ClearStyle(), []),
|
||||
FormatTree(ParagraphStyle(), [first_node]),
|
||||
]
|
||||
for style, text in zip(styles, text_nodes):
|
||||
tmp_stylestack: list[Style] = []
|
||||
if style.type == "base":
|
||||
while style_stack[-1].style.type != "para":
|
||||
if isinstance(style, ClearStyle):
|
||||
while not isinstance(style_stack[-1].style, ParagraphStyle):
|
||||
last_node = style_stack.pop()
|
||||
style_stack[-1].children.append(last_node)
|
||||
elif any(tree.style.type == style.type for tree in style_stack):
|
||||
elif any(style.can_close(tree.style) for tree in style_stack):
|
||||
while len(style_stack) >= 2:
|
||||
last_node = style_stack.pop()
|
||||
style_stack[-1].children.append(last_node)
|
||||
if last_node.style.type == style.type:
|
||||
if style.can_close(last_node.style):
|
||||
break
|
||||
tmp_stylestack.append(last_node.style)
|
||||
|
||||
for sty in tmp_stylestack:
|
||||
style_stack.append(FormatTree(sty, []))
|
||||
if style.value is None:
|
||||
|
||||
if isinstance(style, (EndFunctionStyle, ClearStyle)):
|
||||
if text:
|
||||
style_stack[-1].children.append(text)
|
||||
else:
|
||||
style_stack.append(FormatTree(style, [text] if text else []))
|
||||
|
||||
while len(style_stack) >= 2:
|
||||
last_node = style_stack.pop()
|
||||
style_stack[-1].children.append(last_node)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# pyright: reportPrivateUsage=false
|
||||
from patchouli.text import DEFAULT_MACROS, FormatTree, Style
|
||||
from patchouli.text import DEFAULT_MACROS, FormatTree
|
||||
from patchouli.text.formatting import ClearStyle, ColorStyle, LinkStyle, ParagraphStyle
|
||||
|
||||
|
||||
def test_format_string():
|
||||
|
@ -12,65 +13,57 @@ def test_format_string():
|
|||
# assert
|
||||
# TODO: possibly make this less lazy
|
||||
assert tree == FormatTree(
|
||||
style=Style(type="base", value=True),
|
||||
style=ClearStyle(),
|
||||
children=[
|
||||
FormatTree(
|
||||
style=Style(type="para", value={}),
|
||||
style=ParagraphStyle(),
|
||||
children=[
|
||||
"Write the given iota to my ",
|
||||
FormatTree(
|
||||
style=Style(
|
||||
type="link",
|
||||
value="patterns/readwrite#hexcasting:write/local",
|
||||
),
|
||||
style=LinkStyle("patterns/readwrite#hexcasting:write/local"),
|
||||
children=[
|
||||
FormatTree(
|
||||
style=Style(type="color", value="490"),
|
||||
style=ColorStyle("490"),
|
||||
children=["local"],
|
||||
)
|
||||
],
|
||||
),
|
||||
".\nThe ",
|
||||
FormatTree(
|
||||
style=Style(
|
||||
type="link",
|
||||
value="patterns/readwrite#hexcasting:write/local",
|
||||
),
|
||||
style=LinkStyle("patterns/readwrite#hexcasting:write/local"),
|
||||
children=[
|
||||
FormatTree(
|
||||
style=Style(type="color", value="490"),
|
||||
style=ColorStyle("490"),
|
||||
children=["local"],
|
||||
)
|
||||
],
|
||||
),
|
||||
" is a lot like a ",
|
||||
FormatTree(
|
||||
style=Style(type="link", value="items/focus"),
|
||||
style=LinkStyle("items/focus"),
|
||||
children=[
|
||||
FormatTree(
|
||||
style=Style(type="color", value="b0b"),
|
||||
style=ColorStyle("b0b"),
|
||||
children=["Focus"],
|
||||
)
|
||||
],
|
||||
),
|
||||
". It's cleared when I stop casting a Hex, starts with ",
|
||||
FormatTree(
|
||||
style=Style(type="link", value="casting/influences"),
|
||||
style=LinkStyle("casting/influences"),
|
||||
children=[
|
||||
FormatTree(
|
||||
style=Style(type="color", value="490"),
|
||||
style=ColorStyle("490"),
|
||||
children=["Null"],
|
||||
)
|
||||
],
|
||||
),
|
||||
" in it, and is preserved between casts of ",
|
||||
FormatTree(
|
||||
style=Style(
|
||||
type="link", value="patterns/meta#hexcasting:for_each"
|
||||
),
|
||||
style=LinkStyle("patterns/meta#hexcasting:for_each"),
|
||||
children=[
|
||||
FormatTree(
|
||||
style=Style(type="color", value="fc77be"),
|
||||
style=ColorStyle("fc77be"),
|
||||
children=["Thoth's Gambit"],
|
||||
)
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue