refactor mime
This commit is contained in:
parent
16006781d0
commit
40ab7e33b6
9 changed files with 96 additions and 98 deletions
|
@ -101,7 +101,7 @@ IANA
|
||||||
## State of the art / alternatives
|
## State of the art / alternatives
|
||||||
|
|
||||||
*The following review is not an objective, neutral, impartial review. Instead, it's a temptative
|
*The following review is not an objective, neutral, impartial review. Instead, it's a temptative
|
||||||
to explain why I wrote this
|
to explain why I wrote this library. If you find something outdated or objectively wrong, feel free to open a PR or an issue to fix it.*
|
||||||
|
|
||||||
`stalwartlab/mail_parser`
|
`stalwartlab/mail_parser`
|
||||||
|
|
||||||
|
@ -115,7 +115,6 @@ to explain why I wrote this
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
```
|
|
||||||
eml-codec
|
eml-codec
|
||||||
Copyright (C) The eml-codec Contributors
|
Copyright (C) The eml-codec Contributors
|
||||||
|
|
||||||
|
@ -131,4 +130,3 @@ GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
```
|
|
||||||
|
|
12
src/lib.rs
12
src/lib.rs
|
@ -1,14 +1,14 @@
|
||||||
#![doc = include_str!("../README.md")]
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
mod error;
|
pub mod error;
|
||||||
mod header;
|
mod header;
|
||||||
mod imf;
|
pub mod imf;
|
||||||
mod mime;
|
pub mod mime;
|
||||||
mod part;
|
pub mod part;
|
||||||
mod text;
|
pub mod text;
|
||||||
|
|
||||||
pub fn email(input: &[u8]) -> Result<part::composite::Message, error::EMLError> {
|
pub fn email(input: &[u8]) -> Result<part::composite::Message, error::EMLError> {
|
||||||
part::composite::message(mime::mime::Message::default())(input)
|
part::composite::message(mime::Message::default())(input)
|
||||||
.map(|(_, v)| v)
|
.map(|(_, v)| v)
|
||||||
.map_err(error::EMLError::ParseError)
|
.map_err(error::EMLError::ParseError)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use nom::{
|
||||||
use crate::header::{field_name, CompFieldList};
|
use crate::header::{field_name, CompFieldList};
|
||||||
use crate::imf::identification::{msg_id, MessageID};
|
use crate::imf::identification::{msg_id, MessageID};
|
||||||
use crate::mime::mechanism::{mechanism, Mechanism};
|
use crate::mime::mechanism::{mechanism, Mechanism};
|
||||||
use crate::mime::mime::AnyMIME;
|
use crate::mime::AnyMIME;
|
||||||
use crate::mime::r#type::{naive_type, NaiveType};
|
use crate::mime::r#type::{naive_type, NaiveType};
|
||||||
use crate::text::misc_token::{unstructured, Unstructured};
|
use crate::text::misc_token::{unstructured, Unstructured};
|
||||||
use crate::text::whitespace::obs_crlf;
|
use crate::text::whitespace::obs_crlf;
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
use crate::imf::identification::MessageID;
|
|
||||||
use crate::mime::field::Content;
|
|
||||||
use crate::mime::mechanism::Mechanism;
|
|
||||||
use crate::mime::r#type::{self as ctype, AnyType};
|
|
||||||
use crate::text::misc_token::Unstructured; //Multipart, Message, Text, Binary};
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
|
||||||
pub struct Multipart<'a>(pub ctype::Multipart, pub Generic<'a>);
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone, Default)]
|
|
||||||
pub struct Message<'a>(pub ctype::Message, pub Generic<'a>);
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone, Default)]
|
|
||||||
pub struct Text<'a>(pub ctype::Text, pub Generic<'a>);
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
|
||||||
pub struct Binary<'a>(pub ctype::Binary, pub Generic<'a>);
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
|
||||||
pub enum AnyMIME<'a> {
|
|
||||||
Mult(Multipart<'a>),
|
|
||||||
Msg(Message<'a>),
|
|
||||||
Txt(Text<'a>),
|
|
||||||
Bin(Binary<'a>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> AnyMIME<'a> {
|
|
||||||
pub fn from_pair(at: AnyType, gen: Generic<'a>) -> Self {
|
|
||||||
match at {
|
|
||||||
AnyType::Multipart(m) => AnyMIME::Mult(Multipart(m, gen)),
|
|
||||||
AnyType::Message(m) => AnyMIME::Msg(Message(m, gen)),
|
|
||||||
AnyType::Text(m) => AnyMIME::Txt(Text(m, gen)),
|
|
||||||
AnyType::Binary(m) => AnyMIME::Bin(Binary(m, gen)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> FromIterator<Content<'a>> for AnyMIME<'a> {
|
|
||||||
fn from_iter<I: IntoIterator<Item = Content<'a>>>(it: I) -> Self {
|
|
||||||
let (at, gen) = it.into_iter().fold(
|
|
||||||
(AnyType::default(), Generic::default()),
|
|
||||||
|(mut at, mut section), field| {
|
|
||||||
match field {
|
|
||||||
Content::Type(v) => at = v.to_type(),
|
|
||||||
Content::TransferEncoding(v) => section.transfer_encoding = v,
|
|
||||||
Content::ID(v) => section.id = Some(v),
|
|
||||||
Content::Description(v) => section.description = Some(v),
|
|
||||||
};
|
|
||||||
(at, section)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Self::from_pair(at, gen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Default, Clone)]
|
|
||||||
pub struct Generic<'a> {
|
|
||||||
pub transfer_encoding: Mechanism<'a>,
|
|
||||||
pub id: Option<MessageID<'a>>,
|
|
||||||
pub description: Option<Unstructured<'a>>,
|
|
||||||
}
|
|
|
@ -1,5 +1,67 @@
|
||||||
pub mod charset;
|
pub mod charset;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
pub mod mechanism;
|
pub mod mechanism;
|
||||||
pub mod mime;
|
|
||||||
pub mod r#type;
|
pub mod r#type;
|
||||||
|
|
||||||
|
use crate::imf::identification::MessageID;
|
||||||
|
use crate::mime::field::Content;
|
||||||
|
use crate::mime::mechanism::Mechanism;
|
||||||
|
use crate::mime::r#type::{self as ctype, AnyType};
|
||||||
|
use crate::text::misc_token::Unstructured; //Multipart, Message, Text, Binary};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
|
pub struct Multipart<'a>(pub ctype::Multipart, pub Generic<'a>);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Default)]
|
||||||
|
pub struct Message<'a>(pub ctype::Message, pub Generic<'a>);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Default)]
|
||||||
|
pub struct Text<'a>(pub ctype::Text, pub Generic<'a>);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
|
pub struct Binary<'a>(pub ctype::Binary, pub Generic<'a>);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
|
pub enum AnyMIME<'a> {
|
||||||
|
Mult(Multipart<'a>),
|
||||||
|
Msg(Message<'a>),
|
||||||
|
Txt(Text<'a>),
|
||||||
|
Bin(Binary<'a>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> AnyMIME<'a> {
|
||||||
|
pub fn from_pair(at: AnyType, gen: Generic<'a>) -> Self {
|
||||||
|
match at {
|
||||||
|
AnyType::Multipart(m) => AnyMIME::Mult(Multipart(m, gen)),
|
||||||
|
AnyType::Message(m) => AnyMIME::Msg(Message(m, gen)),
|
||||||
|
AnyType::Text(m) => AnyMIME::Txt(Text(m, gen)),
|
||||||
|
AnyType::Binary(m) => AnyMIME::Bin(Binary(m, gen)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FromIterator<Content<'a>> for AnyMIME<'a> {
|
||||||
|
fn from_iter<I: IntoIterator<Item = Content<'a>>>(it: I) -> Self {
|
||||||
|
let (at, gen) = it.into_iter().fold(
|
||||||
|
(AnyType::default(), Generic::default()),
|
||||||
|
|(mut at, mut section), field| {
|
||||||
|
match field {
|
||||||
|
Content::Type(v) => at = v.to_type(),
|
||||||
|
Content::TransferEncoding(v) => section.transfer_encoding = v,
|
||||||
|
Content::ID(v) => section.id = Some(v),
|
||||||
|
Content::Description(v) => section.description = Some(v),
|
||||||
|
};
|
||||||
|
(at, section)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Self::from_pair(at, gen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Default, Clone)]
|
||||||
|
pub struct Generic<'a> {
|
||||||
|
pub transfer_encoding: Mechanism<'a>,
|
||||||
|
pub id: Option<MessageID<'a>>,
|
||||||
|
pub description: Option<Unstructured<'a>>,
|
||||||
|
}
|
||||||
|
|
|
@ -9,12 +9,12 @@ use crate::text::boundary::{boundary, Delimiter};
|
||||||
//--- Multipart
|
//--- Multipart
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub struct Multipart<'a> {
|
pub struct Multipart<'a> {
|
||||||
pub interpreted: mime::mime::Multipart<'a>,
|
pub interpreted: mime::Multipart<'a>,
|
||||||
pub children: Vec<AnyPart<'a>>,
|
pub children: Vec<AnyPart<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn multipart<'a>(
|
pub fn multipart<'a>(
|
||||||
m: mime::mime::Multipart<'a>,
|
m: mime::Multipart<'a>,
|
||||||
) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], Multipart<'a>> {
|
) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], Multipart<'a>> {
|
||||||
let m = m.clone();
|
let m = m.clone();
|
||||||
|
|
||||||
|
@ -64,13 +64,13 @@ pub fn multipart<'a>(
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub struct Message<'a> {
|
pub struct Message<'a> {
|
||||||
pub interpreted: mime::mime::Message<'a>,
|
pub interpreted: mime::Message<'a>,
|
||||||
pub imf: imf::Imf<'a>,
|
pub imf: imf::Imf<'a>,
|
||||||
pub child: Box<AnyPart<'a>>,
|
pub child: Box<AnyPart<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn message<'a>(
|
pub fn message<'a>(
|
||||||
m: mime::mime::Message<'a>,
|
m: mime::Message<'a>,
|
||||||
) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], Message<'a>> {
|
) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], Message<'a>> {
|
||||||
move |input: &[u8]| {
|
move |input: &[u8]| {
|
||||||
let (input, fields): (_, CompFieldList<part::field::MixedField>) =
|
let (input, fields): (_, CompFieldList<part::field::MixedField>) =
|
||||||
|
@ -101,12 +101,12 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multipart() {
|
fn test_multipart() {
|
||||||
let base_mime = mime::mime::Multipart(
|
let base_mime = mime::Multipart(
|
||||||
mime::r#type::Multipart {
|
mime::r#type::Multipart {
|
||||||
subtype: mime::r#type::MultipartSubtype::Alternative,
|
subtype: mime::r#type::MultipartSubtype::Alternative,
|
||||||
boundary: "simple boundary".to_string(),
|
boundary: "simple boundary".to_string(),
|
||||||
},
|
},
|
||||||
mime::mime::Generic::default(),
|
mime::Generic::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
@ -133,22 +133,22 @@ This is the epilogue. It is also to be ignored.
|
||||||
interpreted: base_mime,
|
interpreted: base_mime,
|
||||||
children: vec![
|
children: vec![
|
||||||
AnyPart::Txt(Text {
|
AnyPart::Txt(Text {
|
||||||
interpreted: mime::mime::Text(
|
interpreted: mime::Text(
|
||||||
mime::r#type::Text {
|
mime::r#type::Text {
|
||||||
subtype: mime::r#type::TextSubtype::Plain,
|
subtype: mime::r#type::TextSubtype::Plain,
|
||||||
charset: mime::charset::EmailCharset::US_ASCII,
|
charset: mime::charset::EmailCharset::US_ASCII,
|
||||||
},
|
},
|
||||||
mime::mime::Generic::default(),
|
mime::Generic::default(),
|
||||||
),
|
),
|
||||||
body: &b"This is implicitly typed plain US-ASCII text.\nIt does NOT end with a linebreak."[..],
|
body: &b"This is implicitly typed plain US-ASCII text.\nIt does NOT end with a linebreak."[..],
|
||||||
}),
|
}),
|
||||||
AnyPart::Txt(Text {
|
AnyPart::Txt(Text {
|
||||||
interpreted: mime::mime::Text(
|
interpreted: mime::Text(
|
||||||
mime::r#type::Text {
|
mime::r#type::Text {
|
||||||
subtype: mime::r#type::TextSubtype::Plain,
|
subtype: mime::r#type::TextSubtype::Plain,
|
||||||
charset: mime::charset::EmailCharset::US_ASCII,
|
charset: mime::charset::EmailCharset::US_ASCII,
|
||||||
},
|
},
|
||||||
mime::mime::Generic::default(),
|
mime::Generic::default(),
|
||||||
),
|
),
|
||||||
body: &b"This is explicitly typed plain US-ASCII text.\nIt DOES end with a linebreak.\n"[..],
|
body: &b"This is explicitly typed plain US-ASCII text.\nIt DOES end with a linebreak.\n"[..],
|
||||||
}),
|
}),
|
||||||
|
@ -205,7 +205,7 @@ OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />
|
||||||
"#
|
"#
|
||||||
.as_bytes();
|
.as_bytes();
|
||||||
|
|
||||||
let base_mime = mime::mime::Message::default();
|
let base_mime = mime::Message::default();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
message(base_mime.clone())(fullmail),
|
message(base_mime.clone())(fullmail),
|
||||||
Ok((
|
Ok((
|
||||||
|
@ -278,34 +278,34 @@ OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />
|
||||||
..imf::Imf::default()
|
..imf::Imf::default()
|
||||||
},
|
},
|
||||||
child: Box::new(AnyPart::Mult(Multipart {
|
child: Box::new(AnyPart::Mult(Multipart {
|
||||||
interpreted: mime::mime::Multipart(
|
interpreted: mime::Multipart(
|
||||||
mime::r#type::Multipart {
|
mime::r#type::Multipart {
|
||||||
subtype: mime::r#type::MultipartSubtype::Alternative,
|
subtype: mime::r#type::MultipartSubtype::Alternative,
|
||||||
boundary: "b1_e376dc71bafc953c0b0fdeb9983a9956".to_string(),
|
boundary: "b1_e376dc71bafc953c0b0fdeb9983a9956".to_string(),
|
||||||
},
|
},
|
||||||
mime::mime::Generic::default(),
|
mime::Generic::default(),
|
||||||
),
|
),
|
||||||
children: vec![
|
children: vec![
|
||||||
AnyPart::Txt(Text {
|
AnyPart::Txt(Text {
|
||||||
interpreted: mime::mime::Text(
|
interpreted: mime::Text(
|
||||||
mime::r#type::Text {
|
mime::r#type::Text {
|
||||||
subtype: mime::r#type::TextSubtype::Plain,
|
subtype: mime::r#type::TextSubtype::Plain,
|
||||||
charset: mime::charset::EmailCharset::UTF_8,
|
charset: mime::charset::EmailCharset::UTF_8,
|
||||||
},
|
},
|
||||||
mime::mime::Generic {
|
mime::Generic {
|
||||||
transfer_encoding: mime::mechanism::Mechanism::QuotedPrintable,
|
transfer_encoding: mime::mechanism::Mechanism::QuotedPrintable,
|
||||||
..mime::mime::Generic::default()
|
..mime::Generic::default()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
body: &b"GZ\nOoOoO\noOoOoOoOo\noOoOoOoOoOoOoOoOo\noOoOoOoOoOoOoOoOoOoOoOo\noOoOoOoOoOoOoOoOoOoOoOoOoOoOo\nOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO\n"[..],
|
body: &b"GZ\nOoOoO\noOoOoOoOo\noOoOoOoOoOoOoOoOo\noOoOoOoOoOoOoOoOoOoOoOo\noOoOoOoOoOoOoOoOoOoOoOoOoOoOo\nOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO\n"[..],
|
||||||
}),
|
}),
|
||||||
AnyPart::Txt(Text {
|
AnyPart::Txt(Text {
|
||||||
interpreted: mime::mime::Text(
|
interpreted: mime::Text(
|
||||||
mime::r#type::Text {
|
mime::r#type::Text {
|
||||||
subtype: mime::r#type::TextSubtype::Html,
|
subtype: mime::r#type::TextSubtype::Html,
|
||||||
charset: mime::charset::EmailCharset::US_ASCII,
|
charset: mime::charset::EmailCharset::US_ASCII,
|
||||||
},
|
},
|
||||||
mime::mime::Generic::default(),
|
mime::Generic::default(),
|
||||||
),
|
),
|
||||||
body: &br#"<div style="text-align: center;"><strong>GZ</strong><br />
|
body: &br#"<div style="text-align: center;"><strong>GZ</strong><br />
|
||||||
OoOoO<br />
|
OoOoO<br />
|
||||||
|
|
|
@ -4,7 +4,7 @@ use crate::mime;
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
pub struct Text<'a> {
|
pub struct Text<'a> {
|
||||||
pub interpreted: mime::mime::Text<'a>,
|
pub interpreted: mime::Text<'a>,
|
||||||
pub body: &'a [u8],
|
pub body: &'a [u8],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ impl<'a> fmt::Debug for Text<'a> {
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
pub struct Binary<'a> {
|
pub struct Binary<'a> {
|
||||||
pub interpreted: mime::mime::Binary<'a>,
|
pub interpreted: mime::Binary<'a>,
|
||||||
pub body: &'a [u8],
|
pub body: &'a [u8],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,14 +36,14 @@ impl<'a> MixedField<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl<'a> CompFieldList<'a, MixedField<'a>> {
|
impl<'a> CompFieldList<'a, MixedField<'a>> {
|
||||||
pub fn sections(self) -> (mime::mime::AnyMIME<'a>, imf::Imf<'a>) {
|
pub fn sections(self) -> (mime::AnyMIME<'a>, imf::Imf<'a>) {
|
||||||
let k = self.known();
|
let k = self.known();
|
||||||
let (v1, v2): (Vec<MixedField>, Vec<MixedField>) =
|
let (v1, v2): (Vec<MixedField>, Vec<MixedField>) =
|
||||||
k.into_iter().partition(|v| v.mime().is_some());
|
k.into_iter().partition(|v| v.mime().is_some());
|
||||||
let mime = v1
|
let mime = v1
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|v| v.to_mime())
|
.filter_map(|v| v.to_mime())
|
||||||
.collect::<mime::mime::AnyMIME>();
|
.collect::<mime::AnyMIME>();
|
||||||
let imf = v2
|
let imf = v2
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|v| v.to_imf())
|
.filter_map(|v| v.to_imf())
|
||||||
|
|
|
@ -13,7 +13,7 @@ use nom::{
|
||||||
|
|
||||||
use crate::header::CompFieldList;
|
use crate::header::CompFieldList;
|
||||||
use crate::mime;
|
use crate::mime;
|
||||||
use crate::mime::mime::AnyMIME;
|
use crate::mime::AnyMIME;
|
||||||
use crate::part::{
|
use crate::part::{
|
||||||
composite::{message, multipart, Message, Multipart},
|
composite::{message, multipart, Message, Multipart},
|
||||||
discrete::{Binary, Text},
|
discrete::{Binary, Text},
|
||||||
|
@ -35,14 +35,14 @@ pub fn to_anypart<'a>(m: AnyMIME<'a>, rpart: &'a [u8]) -> AnyPart<'a> {
|
||||||
AnyMIME::Mult(a) => map(multipart(a), AnyPart::Mult)(rpart)
|
AnyMIME::Mult(a) => map(multipart(a), AnyPart::Mult)(rpart)
|
||||||
.map(|v| v.1)
|
.map(|v| v.1)
|
||||||
.unwrap_or(AnyPart::Txt(Text {
|
.unwrap_or(AnyPart::Txt(Text {
|
||||||
interpreted: mime::mime::Text::default(),
|
interpreted: mime::Text::default(),
|
||||||
body: rpart,
|
body: rpart,
|
||||||
})),
|
})),
|
||||||
AnyMIME::Msg(a) => {
|
AnyMIME::Msg(a) => {
|
||||||
map(message(a), AnyPart::Msg)(rpart)
|
map(message(a), AnyPart::Msg)(rpart)
|
||||||
.map(|v| v.1)
|
.map(|v| v.1)
|
||||||
.unwrap_or(AnyPart::Txt(Text {
|
.unwrap_or(AnyPart::Txt(Text {
|
||||||
interpreted: mime::mime::Text::default(),
|
interpreted: mime::Text::default(),
|
||||||
body: rpart,
|
body: rpart,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue