Refactored Mode API into its own module and added it to prelude.
This commit is contained in:
parent
d9f4f82051
commit
eecbe1630c
5 changed files with 315 additions and 293 deletions
|
@ -11,6 +11,7 @@ pub mod prelude {
|
|||
pub use client::server::{IrcServer, Server};
|
||||
pub use client::server::utils::ServerExt;
|
||||
pub use proto::{Capability, Command, Message, NegotiationVersion, Response};
|
||||
pub use proto::{ChannelMode, Mode, UserMode};
|
||||
|
||||
pub use futures::{Future, Stream};
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ use std::borrow::ToOwned;
|
|||
use error::Result;
|
||||
use proto::{Capability, Command, Mode, NegotiationVersion};
|
||||
use proto::command::Command::*;
|
||||
use proto::command::ModeType;
|
||||
use proto::mode::ModeType;
|
||||
use client::server::Server;
|
||||
use proto::command::CapSubCommand::{END, LS, REQ};
|
||||
#[cfg(feature = "ctcp")]
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
//! Enumeration of all available client commands.
|
||||
use std::ascii::AsciiExt;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use error;
|
||||
use proto::Response;
|
||||
use proto::{ChannelMode, Mode, Response, UserMode};
|
||||
|
||||
/// List of all client commands as defined in [RFC 2812](http://tools.ietf.org/html/rfc2812). This
|
||||
/// also includes commands from the
|
||||
|
@ -1755,295 +1754,6 @@ impl FromStr for BatchSubCommand {
|
|||
}
|
||||
}
|
||||
|
||||
/// A marker trait for different kinds of Modes.
|
||||
pub trait ModeType: fmt::Display + fmt::Debug + Clone + PartialEq {
|
||||
/// Creates a command of this kind.
|
||||
fn mode(target: &str, modes: &[Mode<Self>]) -> Command;
|
||||
|
||||
/// Returns true if this mode takes an argument, and false otherwise.
|
||||
fn takes_arg(&self) -> bool;
|
||||
}
|
||||
|
||||
/// User modes for the MODE command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum UserMode {
|
||||
/// a - user is flagged as away
|
||||
Away,
|
||||
/// i - marks a users as invisible
|
||||
Invisible,
|
||||
/// w - user receives wallops
|
||||
Wallops,
|
||||
/// r - restricted user connection
|
||||
Restricted,
|
||||
/// o - operator flag
|
||||
Oper,
|
||||
/// O - local operator flag
|
||||
LocalOper,
|
||||
/// s - marks a user for receipt of server notices
|
||||
ServerNotices,
|
||||
|
||||
/// Any other unknown-to-the-crate mode.
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl ModeType for UserMode {
|
||||
fn mode(target: &str, modes: &[Mode<Self>]) -> Command {
|
||||
Command::UserMODE(target.to_owned(), modes.to_owned())
|
||||
}
|
||||
|
||||
fn takes_arg(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl UserMode {
|
||||
fn from_char(c: char) -> error::Result<UserMode> {
|
||||
use self::UserMode::*;
|
||||
|
||||
Ok(match c {
|
||||
'a' => Away,
|
||||
'i' => Invisible,
|
||||
'w' => Wallops,
|
||||
'r' => Restricted,
|
||||
'o' => Oper,
|
||||
'O' => LocalOper,
|
||||
's' => ServerNotices,
|
||||
_ => Unknown(c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
use self::UserMode::*;
|
||||
|
||||
write!(f, "{}", match *self {
|
||||
Away => 'a',
|
||||
Invisible => 'i',
|
||||
Wallops => 'w',
|
||||
Restricted => 'r',
|
||||
Oper => 'o',
|
||||
LocalOper => 'O',
|
||||
ServerNotices => 's',
|
||||
Unknown(c) => c,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel modes for the MODE command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ChannelMode {
|
||||
/// b - ban the user from joining or speaking in the channel
|
||||
Ban,
|
||||
/// e - exemptions from bans
|
||||
Exception,
|
||||
/// l - limit the maximum number of users in a channel
|
||||
Limit,
|
||||
/// i - channel becomes invite-only
|
||||
InviteOnly,
|
||||
/// I - exception to invite-only rule
|
||||
InviteException,
|
||||
/// k - specify channel key
|
||||
Key,
|
||||
/// m - channel is in moderated mode
|
||||
Moderated,
|
||||
/// s - channel is hidden from listings
|
||||
Secret,
|
||||
/// t - require permissions to edit topic
|
||||
ProtectedTopic,
|
||||
/// n - users must join channels to message them
|
||||
NoExternalMessages,
|
||||
|
||||
/// q - user gets founder permission
|
||||
Founder,
|
||||
/// a - user gets admin or protected permission
|
||||
Admin,
|
||||
/// o - user gets oper permission
|
||||
Oper,
|
||||
/// h - user gets halfop permission
|
||||
Halfop,
|
||||
/// v - user gets voice permission
|
||||
Voice,
|
||||
|
||||
/// Any other unknown-to-the-crate mode.
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl ModeType for ChannelMode {
|
||||
fn mode(target: &str, modes: &[Mode<Self>]) -> Command {
|
||||
Command::ChannelMODE(target.to_owned(), modes.to_owned())
|
||||
}
|
||||
|
||||
fn takes_arg(&self) -> bool {
|
||||
use self::ChannelMode::*;
|
||||
|
||||
match *self {
|
||||
Ban | Exception | Limit | InviteException | Key | Founder | Admin | Oper | Halfop |
|
||||
Voice => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelMode {
|
||||
fn from_char(c: char) -> error::Result<ChannelMode> {
|
||||
use self::ChannelMode::*;
|
||||
|
||||
Ok(match c {
|
||||
'b' => Ban,
|
||||
'e' => Exception,
|
||||
'l' => Limit,
|
||||
'i' => InviteOnly,
|
||||
'I' => InviteException,
|
||||
'k' => Key,
|
||||
'm' => Moderated,
|
||||
's' => Secret,
|
||||
't' => ProtectedTopic,
|
||||
'n' => NoExternalMessages,
|
||||
'q' => Founder,
|
||||
'a' => Admin,
|
||||
'o' => Oper,
|
||||
'h' => Halfop,
|
||||
'v' => Voice,
|
||||
_ => Unknown(c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ChannelMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
use self::ChannelMode::*;
|
||||
|
||||
write!(f, "{}", match *self {
|
||||
Ban => 'b',
|
||||
Exception => 'e',
|
||||
Limit => 'l',
|
||||
InviteOnly => 'i',
|
||||
InviteException => 'I',
|
||||
Key => 'k',
|
||||
Moderated => 'm',
|
||||
Secret => 's',
|
||||
ProtectedTopic => 't',
|
||||
NoExternalMessages => 'n',
|
||||
Founder => 'q',
|
||||
Admin => 'a',
|
||||
Oper => 'o',
|
||||
Halfop => 'h',
|
||||
Voice => 'v',
|
||||
Unknown(c) => c,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A mode argument for the MODE command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Mode<T>
|
||||
where
|
||||
T: ModeType,
|
||||
{
|
||||
/// Adding the specified mode, optionally with an argument.
|
||||
Plus(T, Option<String>),
|
||||
/// Removing the specified mode, optionally with an argument.
|
||||
Minus(T, Option<String>),
|
||||
}
|
||||
|
||||
impl<T> fmt::Display for Mode<T>
|
||||
where
|
||||
T: ModeType,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
&Mode::Plus(ref mode, Some(ref arg)) => write!(f, "{}{} {}", "+", mode, arg),
|
||||
&Mode::Minus(ref mode, Some(ref arg)) => write!(f, "{}{} {}", "-", mode, arg),
|
||||
&Mode::Plus(ref mode, None) => write!(f, "{}{}", "+", mode),
|
||||
&Mode::Minus(ref mode, None) => write!(f, "{}{}", "-", mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlusMinus {
|
||||
Plus,
|
||||
Minus,
|
||||
}
|
||||
|
||||
// MODE user [modes]
|
||||
impl Mode<UserMode> {
|
||||
// TODO: turning more edge cases into errors.
|
||||
/// Parses the specified mode string as user modes.
|
||||
pub fn as_user_modes(s: &str) -> error::Result<Vec<Mode<UserMode>>> {
|
||||
use self::PlusMinus::*;
|
||||
|
||||
let mut res = vec![];
|
||||
let mut pieces = s.split(" ");
|
||||
for term in pieces.clone() {
|
||||
if term.starts_with("+") || term.starts_with("-") {
|
||||
let _ = pieces.next();
|
||||
|
||||
let mut chars = term.chars();
|
||||
let init = match chars.next() {
|
||||
Some('+') => Plus,
|
||||
Some('-') => Minus,
|
||||
_ => return Err(error::ErrorKind::ModeParsingFailed.into()),
|
||||
};
|
||||
|
||||
for c in chars {
|
||||
let mode = UserMode::from_char(c)?;
|
||||
let arg = if mode.takes_arg() {
|
||||
pieces.next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
res.push(match init {
|
||||
Plus => Mode::Plus(mode, arg.map(|s| s.to_owned())),
|
||||
Minus => Mode::Minus(mode, arg.map(|s| s.to_owned())),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
// MODE channel [modes [modeparams]]
|
||||
impl Mode<ChannelMode> {
|
||||
// TODO: turning more edge cases into errors.
|
||||
/// Parses the specified mode string as channel modes.
|
||||
pub fn as_channel_modes(s: &str) -> error::Result<Vec<Mode<ChannelMode>>> {
|
||||
use self::PlusMinus::*;
|
||||
|
||||
let mut res = vec![];
|
||||
let mut pieces = s.split(" ");
|
||||
for term in pieces.clone() {
|
||||
if term.starts_with("+") || term.starts_with("-") {
|
||||
let _ = pieces.next();
|
||||
|
||||
let mut chars = term.chars();
|
||||
let init = match chars.next() {
|
||||
Some('+') => Plus,
|
||||
Some('-') => Minus,
|
||||
_ => return Err(error::ErrorKind::ModeParsingFailed.into()),
|
||||
};
|
||||
|
||||
for c in chars {
|
||||
let mode = ChannelMode::from_char(c)?;
|
||||
let arg = if mode.takes_arg() {
|
||||
pieces.next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
res.push(match init {
|
||||
Plus => Mode::Plus(mode, arg.map(|s| s.to_owned())),
|
||||
Minus => Mode::Minus(mode, arg.map(|s| s.to_owned())),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// An extension trait giving strings a function to check if they are a channel.
|
||||
pub trait ChannelExt {
|
||||
/// Returns true if the specified name is a channel name.
|
||||
|
|
|
@ -5,10 +5,12 @@ pub mod command;
|
|||
pub mod irc;
|
||||
pub mod line;
|
||||
pub mod message;
|
||||
pub mod mode;
|
||||
pub mod response;
|
||||
|
||||
pub use self::caps::{Capability, NegotiationVersion};
|
||||
pub use self::command::{BatchSubCommand, CapSubCommand, ChannelMode, Command, Mode, UserMode};
|
||||
pub use self::command::{BatchSubCommand, CapSubCommand, Command};
|
||||
pub use self::irc::IrcCodec;
|
||||
pub use self::message::Message;
|
||||
pub use self::mode::{ChannelMode, Mode, UserMode};
|
||||
pub use self::response::Response;
|
||||
|
|
309
src/proto/mode.rs
Normal file
309
src/proto/mode.rs
Normal file
|
@ -0,0 +1,309 @@
|
|||
//! A module defining an API for IRC user and channel modes.
|
||||
use std::fmt;
|
||||
|
||||
use error;
|
||||
use proto::Command;
|
||||
|
||||
/// A marker trait for different kinds of Modes.
|
||||
pub trait ModeType: fmt::Display + fmt::Debug + Clone + PartialEq {
|
||||
/// Creates a command of this kind.
|
||||
fn mode(target: &str, modes: &[Mode<Self>]) -> Command;
|
||||
|
||||
/// Returns true if this mode takes an argument, and false otherwise.
|
||||
fn takes_arg(&self) -> bool;
|
||||
}
|
||||
|
||||
/// User modes for the MODE command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum UserMode {
|
||||
/// a - user is flagged as away
|
||||
Away,
|
||||
/// i - marks a users as invisible
|
||||
Invisible,
|
||||
/// w - user receives wallops
|
||||
Wallops,
|
||||
/// r - restricted user connection
|
||||
Restricted,
|
||||
/// o - operator flag
|
||||
Oper,
|
||||
/// O - local operator flag
|
||||
LocalOper,
|
||||
/// s - marks a user for receipt of server notices
|
||||
ServerNotices,
|
||||
|
||||
/// Any other unknown-to-the-crate mode.
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl ModeType for UserMode {
|
||||
fn mode(target: &str, modes: &[Mode<Self>]) -> Command {
|
||||
Command::UserMODE(target.to_owned(), modes.to_owned())
|
||||
}
|
||||
|
||||
fn takes_arg(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl UserMode {
|
||||
fn from_char(c: char) -> error::Result<UserMode> {
|
||||
use self::UserMode::*;
|
||||
|
||||
Ok(match c {
|
||||
'a' => Away,
|
||||
'i' => Invisible,
|
||||
'w' => Wallops,
|
||||
'r' => Restricted,
|
||||
'o' => Oper,
|
||||
'O' => LocalOper,
|
||||
's' => ServerNotices,
|
||||
_ => Unknown(c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
use self::UserMode::*;
|
||||
|
||||
write!(f, "{}", match *self {
|
||||
Away => 'a',
|
||||
Invisible => 'i',
|
||||
Wallops => 'w',
|
||||
Restricted => 'r',
|
||||
Oper => 'o',
|
||||
LocalOper => 'O',
|
||||
ServerNotices => 's',
|
||||
Unknown(c) => c,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel modes for the MODE command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ChannelMode {
|
||||
/// b - ban the user from joining or speaking in the channel
|
||||
Ban,
|
||||
/// e - exemptions from bans
|
||||
Exception,
|
||||
/// l - limit the maximum number of users in a channel
|
||||
Limit,
|
||||
/// i - channel becomes invite-only
|
||||
InviteOnly,
|
||||
/// I - exception to invite-only rule
|
||||
InviteException,
|
||||
/// k - specify channel key
|
||||
Key,
|
||||
/// m - channel is in moderated mode
|
||||
Moderated,
|
||||
/// s - channel is hidden from listings
|
||||
Secret,
|
||||
/// t - require permissions to edit topic
|
||||
ProtectedTopic,
|
||||
/// n - users must join channels to message them
|
||||
NoExternalMessages,
|
||||
|
||||
/// q - user gets founder permission
|
||||
Founder,
|
||||
/// a - user gets admin or protected permission
|
||||
Admin,
|
||||
/// o - user gets oper permission
|
||||
Oper,
|
||||
/// h - user gets halfop permission
|
||||
Halfop,
|
||||
/// v - user gets voice permission
|
||||
Voice,
|
||||
|
||||
/// Any other unknown-to-the-crate mode.
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl ModeType for ChannelMode {
|
||||
fn mode(target: &str, modes: &[Mode<Self>]) -> Command {
|
||||
Command::ChannelMODE(target.to_owned(), modes.to_owned())
|
||||
}
|
||||
|
||||
fn takes_arg(&self) -> bool {
|
||||
use self::ChannelMode::*;
|
||||
|
||||
match *self {
|
||||
Ban | Exception | Limit | InviteException | Key | Founder | Admin | Oper | Halfop |
|
||||
Voice => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelMode {
|
||||
fn from_char(c: char) -> error::Result<ChannelMode> {
|
||||
use self::ChannelMode::*;
|
||||
|
||||
Ok(match c {
|
||||
'b' => Ban,
|
||||
'e' => Exception,
|
||||
'l' => Limit,
|
||||
'i' => InviteOnly,
|
||||
'I' => InviteException,
|
||||
'k' => Key,
|
||||
'm' => Moderated,
|
||||
's' => Secret,
|
||||
't' => ProtectedTopic,
|
||||
'n' => NoExternalMessages,
|
||||
'q' => Founder,
|
||||
'a' => Admin,
|
||||
'o' => Oper,
|
||||
'h' => Halfop,
|
||||
'v' => Voice,
|
||||
_ => Unknown(c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ChannelMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
use self::ChannelMode::*;
|
||||
|
||||
write!(f, "{}", match *self {
|
||||
Ban => 'b',
|
||||
Exception => 'e',
|
||||
Limit => 'l',
|
||||
InviteOnly => 'i',
|
||||
InviteException => 'I',
|
||||
Key => 'k',
|
||||
Moderated => 'm',
|
||||
Secret => 's',
|
||||
ProtectedTopic => 't',
|
||||
NoExternalMessages => 'n',
|
||||
Founder => 'q',
|
||||
Admin => 'a',
|
||||
Oper => 'o',
|
||||
Halfop => 'h',
|
||||
Voice => 'v',
|
||||
Unknown(c) => c,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A mode argument for the MODE command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Mode<T>
|
||||
where
|
||||
T: ModeType,
|
||||
{
|
||||
/// Adding the specified mode, optionally with an argument.
|
||||
Plus(T, Option<String>),
|
||||
/// Removing the specified mode, optionally with an argument.
|
||||
Minus(T, Option<String>),
|
||||
}
|
||||
|
||||
impl<T> Mode<T>
|
||||
where
|
||||
T: ModeType,
|
||||
{
|
||||
/// Creates a plus mode with an `&str` argument.
|
||||
pub fn plus(inner: T, arg: Option<&str>) -> Mode<T> {
|
||||
Mode::Plus(inner, arg.map(|s| s.to_owned()))
|
||||
}
|
||||
|
||||
/// Creates a minus mode with an `&str` argument.
|
||||
pub fn minus(inner: T, arg: Option<&str>) -> Mode<T> {
|
||||
Mode::Minus(inner, arg.map(|s| s.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Display for Mode<T>
|
||||
where
|
||||
T: ModeType,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
&Mode::Plus(ref mode, Some(ref arg)) => write!(f, "{}{} {}", "+", mode, arg),
|
||||
&Mode::Minus(ref mode, Some(ref arg)) => write!(f, "{}{} {}", "-", mode, arg),
|
||||
&Mode::Plus(ref mode, None) => write!(f, "{}{}", "+", mode),
|
||||
&Mode::Minus(ref mode, None) => write!(f, "{}{}", "-", mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlusMinus {
|
||||
Plus,
|
||||
Minus,
|
||||
}
|
||||
|
||||
// MODE user [modes]
|
||||
impl Mode<UserMode> {
|
||||
// TODO: turning more edge cases into errors.
|
||||
/// Parses the specified mode string as user modes.
|
||||
pub fn as_user_modes(s: &str) -> error::Result<Vec<Mode<UserMode>>> {
|
||||
use self::PlusMinus::*;
|
||||
|
||||
let mut res = vec![];
|
||||
let mut pieces = s.split(" ");
|
||||
for term in pieces.clone() {
|
||||
if term.starts_with("+") || term.starts_with("-") {
|
||||
let _ = pieces.next();
|
||||
|
||||
let mut chars = term.chars();
|
||||
let init = match chars.next() {
|
||||
Some('+') => Plus,
|
||||
Some('-') => Minus,
|
||||
_ => return Err(error::ErrorKind::ModeParsingFailed.into()),
|
||||
};
|
||||
|
||||
for c in chars {
|
||||
let mode = UserMode::from_char(c)?;
|
||||
let arg = if mode.takes_arg() {
|
||||
pieces.next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
res.push(match init {
|
||||
Plus => Mode::Plus(mode, arg.map(|s| s.to_owned())),
|
||||
Minus => Mode::Minus(mode, arg.map(|s| s.to_owned())),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
// MODE channel [modes [modeparams]]
|
||||
impl Mode<ChannelMode> {
|
||||
// TODO: turning more edge cases into errors.
|
||||
/// Parses the specified mode string as channel modes.
|
||||
pub fn as_channel_modes(s: &str) -> error::Result<Vec<Mode<ChannelMode>>> {
|
||||
use self::PlusMinus::*;
|
||||
|
||||
let mut res = vec![];
|
||||
let mut pieces = s.split(" ");
|
||||
for term in pieces.clone() {
|
||||
if term.starts_with("+") || term.starts_with("-") {
|
||||
let _ = pieces.next();
|
||||
|
||||
let mut chars = term.chars();
|
||||
let init = match chars.next() {
|
||||
Some('+') => Plus,
|
||||
Some('-') => Minus,
|
||||
_ => return Err(error::ErrorKind::ModeParsingFailed.into()),
|
||||
};
|
||||
|
||||
for c in chars {
|
||||
let mode = ChannelMode::from_char(c)?;
|
||||
let arg = if mode.takes_arg() {
|
||||
pieces.next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
res.push(match init {
|
||||
Plus => Mode::Plus(mode, arg.map(|s| s.to_owned())),
|
||||
Minus => Mode::Minus(mode, arg.map(|s| s.to_owned())),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue