mirror of
https://github.com/132nd-vWing/tacview-splitter.git
synced 2026-08-26 06:48:36 +02:00
introduce anyhow, test
This commit is contained in:
@@ -6,6 +6,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
zip = {version = ">= 0.6.2", default-features = false, features = ["deflate"]}
|
||||
anyhow = {version = ">= 1.0", features = ["backtrace"]}
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
+7
-47
@@ -4,61 +4,21 @@ mod reader;
|
||||
mod tacview;
|
||||
mod writer;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{process, thread};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{tacview::Coalition, writer::StringWriter};
|
||||
use crate::writer::OutputData;
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = main_inner() {
|
||||
eprintln!("{err}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn main_inner() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn main() -> Result<()> {
|
||||
let (input_filename, is_zip) = reader::find_input_file()?;
|
||||
println!("Processing {}", input_filename);
|
||||
|
||||
let lines = reader::read_data(&input_filename, is_zip)?;
|
||||
|
||||
let (header, body) = processor::split_into_header_and_body(lines)?;
|
||||
let coalition_per_line = processor::divide_body_by_coalition(&body)?;
|
||||
|
||||
let coalition_per_line = Arc::new(processor::divide_body_by_coalition(&body)?);
|
||||
|
||||
let header = Arc::new(header);
|
||||
let body = Arc::new(body);
|
||||
let input_filename = Arc::new(input_filename);
|
||||
|
||||
let coalitions = vec![Coalition::Blue, Coalition::Red, Coalition::Violet];
|
||||
|
||||
// clippy wants us to combine both ierators into one. this is not what we want, because then
|
||||
// we would spawn a thread, join it, and only then spawn a new one.
|
||||
#[allow(clippy::needless_collect)]
|
||||
let handles: Vec<_> = coalitions
|
||||
.iter()
|
||||
.map(|coalition| {
|
||||
let z = is_zip;
|
||||
let b = body.clone();
|
||||
let cpl = coalition_per_line.clone();
|
||||
let c = coalition.clone();
|
||||
let h = header.clone();
|
||||
let i = input_filename.clone();
|
||||
thread::spawn(move || {
|
||||
let mut writer = writer::create_writer(z, &*i.clone(), &c).unwrap();
|
||||
writer.write_strings(&*h).unwrap();
|
||||
writer.write_for_coalition(&*b, &*cpl, c).unwrap();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _: Vec<_> = handles
|
||||
.into_iter()
|
||||
.zip(coalitions)
|
||||
.map(|(h, c)| {
|
||||
h.join()
|
||||
.unwrap_or_else(|_| println!("could not write data for {c}"))
|
||||
})
|
||||
.collect();
|
||||
let output_data = OutputData::new(input_filename, is_zip, header, body, coalition_per_line);
|
||||
output_data.save_to_disk()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+93
-48
@@ -1,24 +1,24 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::Display;
|
||||
use std::hash::Hash;
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::tacview::{Coalition, CoalitionIDs};
|
||||
|
||||
const COMMENT: char = '#';
|
||||
const MINUS: char = '-';
|
||||
const ZERO: char = '0';
|
||||
const UTF8_BOM: char = '\u{FEFF}'; // byte order mark. a String can begin with that, in which case
|
||||
// we need to discard it.
|
||||
|
||||
pub fn split_into_header_and_body<S>(mut lines: Vec<S>) -> Result<(Vec<S>, Vec<S>), ProcessingError>
|
||||
pub fn split_into_header_and_body<S>(mut lines: Vec<S>) -> Result<(Vec<S>, Vec<S>)>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
S: AsRef<str> + From<String>,
|
||||
{
|
||||
let mut split_idx = 0;
|
||||
for line in &lines {
|
||||
for line in lines.iter() {
|
||||
if line
|
||||
.as_ref()
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or(ProcessingError::CannotSplitIntoHeaderAndBody)?
|
||||
.find(|c| *c != UTF8_BOM)
|
||||
.with_context(|| "found an empty line")?
|
||||
== COMMENT
|
||||
{
|
||||
break;
|
||||
@@ -29,9 +29,9 @@ where
|
||||
Ok((lines, body))
|
||||
}
|
||||
|
||||
pub fn divide_body_by_coalition<S>(body: &[S]) -> Result<Vec<Coalition>, Box<dyn Error>>
|
||||
pub fn divide_body_by_coalition<S>(body: &[S]) -> Result<Vec<Coalition>>
|
||||
where
|
||||
S: AsRef<str> + Hash + Eq,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut result = vec![];
|
||||
let mut coalition_ids: CoalitionIDs = CoalitionIDs::new();
|
||||
@@ -43,9 +43,18 @@ where
|
||||
result.push(processed.coalition.clone());
|
||||
old_line = processed;
|
||||
}
|
||||
sanity_check(&result, body);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn sanity_check<S, T>(v1: &[S], v2: &[T]) {
|
||||
if v1.len() != v2.len() {
|
||||
println!("Warning: data is inconsistent");
|
||||
println!("{}", v1.len());
|
||||
println!("{}", v2.len());
|
||||
}
|
||||
}
|
||||
|
||||
struct Line {
|
||||
line_type: LineType,
|
||||
continued: bool,
|
||||
@@ -57,10 +66,11 @@ impl Line {
|
||||
old_line: Line,
|
||||
current_line: &'a S,
|
||||
coalition_ids: &mut CoalitionIDs<'a>,
|
||||
) -> Result<Self, ProcessingError> {
|
||||
) -> Result<Self> {
|
||||
let line_type = match old_line.continued {
|
||||
true => old_line.line_type,
|
||||
false => LineType::find_type(current_line)?,
|
||||
false => LineType::find_type(current_line)
|
||||
.with_context(|| "could not get line type for this line: {current_line}")?,
|
||||
};
|
||||
|
||||
match line_type {
|
||||
@@ -69,12 +79,17 @@ impl Line {
|
||||
Self::will_line_continue(current_line),
|
||||
Coalition::All,
|
||||
)),
|
||||
LineType::Telemetry => Ok(Line::from_content(line_type, current_line, coalition_ids)?),
|
||||
LineType::Telemetry => Ok(Line::from_content(line_type, current_line, coalition_ids)
|
||||
.with_context(|| "could not parse telemetry line")?),
|
||||
LineType::Timestamp => Ok(Line::new(line_type, false, Coalition::All)),
|
||||
LineType::Destruction => {
|
||||
Ok(Line::from_content(line_type, current_line, coalition_ids)?)
|
||||
}
|
||||
LineType::Unknown => Err(ProcessingError::UnknownLineType),
|
||||
LineType::Destruction => Ok(Line::from_content(line_type, current_line, coalition_ids)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"could not parse this destruction line: {}",
|
||||
current_line.as_ref()
|
||||
)
|
||||
})?),
|
||||
LineType::Unknown => bail!("unknown line type"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +97,7 @@ impl Line {
|
||||
line_type: LineType,
|
||||
current_line: &'a S,
|
||||
coalition_ids: &mut CoalitionIDs<'a>,
|
||||
) -> Result<Self, ProcessingError> {
|
||||
) -> Result<Self> {
|
||||
let id = Self::get_id_from_line(current_line, &line_type)?;
|
||||
let continued = Self::will_line_continue(current_line);
|
||||
let coalition = Self::assign_id_to_coalitions(coalition_ids, current_line, id)
|
||||
@@ -109,21 +124,23 @@ impl Line {
|
||||
current_line.as_ref().ends_with('\\')
|
||||
}
|
||||
|
||||
fn get_id_from_line<'a, S>(
|
||||
line: &'a S,
|
||||
line_type: &LineType,
|
||||
) -> Result<&'a str, ProcessingError>
|
||||
fn get_id_from_line<'a, S>(line: &'a S, line_type: &LineType) -> Result<&'a str>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let local_line = line.as_ref();
|
||||
if line_type == &LineType::Destruction {
|
||||
Ok(&local_line[1..local_line.len()])
|
||||
let result = if line_type == &LineType::Destruction {
|
||||
&local_line[1..local_line.len()]
|
||||
} else {
|
||||
Ok(local_line
|
||||
local_line
|
||||
.split_once(',')
|
||||
.ok_or_else(|| ProcessingError::CannotGetIDFromLine(local_line.to_owned()))?
|
||||
.0)
|
||||
.with_context(|| format!("cannot get ID from line {local_line}"))?
|
||||
.0
|
||||
};
|
||||
if result.is_empty() {
|
||||
bail!("cannot get ID from this line\n{local_line}")
|
||||
} else {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +159,7 @@ impl Default for Line {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
enum LineType {
|
||||
Unknown,
|
||||
Timestamp,
|
||||
@@ -152,43 +169,71 @@ enum LineType {
|
||||
}
|
||||
|
||||
impl LineType {
|
||||
fn find_type<S: AsRef<str>>(line: &S) -> Result<Self, ProcessingError> {
|
||||
fn find_type<S: AsRef<str>>(line: &S) -> Result<Self> {
|
||||
let first_char = line
|
||||
.as_ref()
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or(ProcessingError::LineIsEmptyError)?;
|
||||
.find(|c| *c != UTF8_BOM)
|
||||
.with_context(|| "line in tacview file is empty")?;
|
||||
let mut buffer: [u8; 4] = [0; 4];
|
||||
first_char.encode_utf8(&mut buffer);
|
||||
if first_char == COMMENT {
|
||||
Ok(Self::Timestamp)
|
||||
} else if first_char == MINUS {
|
||||
Ok(Self::Destruction)
|
||||
} else if first_char == ZERO {
|
||||
Ok(LineType::ArbitraryData)
|
||||
} else if first_char.is_whitespace() {
|
||||
bail!("found line beginning with whitespace")
|
||||
} else {
|
||||
Ok(LineType::Telemetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProcessingError {
|
||||
LineIsEmptyError,
|
||||
UnknownLineType,
|
||||
CannotSplitIntoHeaderAndBody,
|
||||
CannotGetIDFromLine(String),
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::{Line, LineType};
|
||||
|
||||
impl Error for ProcessingError {}
|
||||
#[test]
|
||||
fn test_find_type() {
|
||||
assert_eq!(LineType::find_type(&"#0").unwrap(), LineType::Timestamp);
|
||||
assert_eq!(
|
||||
LineType::find_type(&"102,T=5.0785362|6.203").unwrap(),
|
||||
LineType::Telemetry
|
||||
);
|
||||
assert_eq!(LineType::find_type(&"-102").unwrap(), LineType::Destruction);
|
||||
assert_eq!(
|
||||
LineType::find_type(&"0,Authentication").unwrap(),
|
||||
LineType::ArbitraryData
|
||||
);
|
||||
}
|
||||
|
||||
impl Display for ProcessingError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::LineIsEmptyError => write!(f, "line in tacview file is empty"),
|
||||
Self::UnknownLineType => write!(f, "unknown line type in tacview file"),
|
||||
Self::CannotSplitIntoHeaderAndBody => {
|
||||
write!(f, "cannot split the file into header and body")
|
||||
}
|
||||
Self::CannotGetIDFromLine(l) => write!(f, "cannot get unit ID from line: \n{l}"),
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_find_type_for_space() {
|
||||
LineType::find_type(&" ").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_id_from_line() {
|
||||
let lines = vec![
|
||||
("102,T=5.0785362|6.203", "102"),
|
||||
("2d02,T=", "2d02"),
|
||||
("248b02,T=6.70283", "248b02"),
|
||||
];
|
||||
for l in lines {
|
||||
assert_eq!(
|
||||
&Line::get_id_from_line(&l.0, &LineType::Telemetry).unwrap(),
|
||||
&l.1
|
||||
)
|
||||
}
|
||||
let lines = vec![("-102", "102"), ("-2d02", "2d02"), ("-248b02", "248b02")];
|
||||
for l in lines {
|
||||
assert_eq!(
|
||||
&Line::get_id_from_line(&l.0, &LineType::Destruction).unwrap(),
|
||||
&l.1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-16
@@ -1,12 +1,17 @@
|
||||
use crate::constants::{EXTENSION_TXT, EXTENSION_ZIP};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Error, ErrorKind};
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
pub fn find_input_file() -> Result<(String, bool), Error> {
|
||||
let read_dir = fs::read_dir(".")?;
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::constants::{EXTENSION_TXT, EXTENSION_ZIP};
|
||||
|
||||
pub fn find_input_file() -> Result<(String, bool)> {
|
||||
let read_dir = fs::read_dir(".").with_context(|| "could not open the current directory")?;
|
||||
|
||||
for entry_result in read_dir {
|
||||
let path_buf = entry_result?.path();
|
||||
let path_buf = entry_result
|
||||
.with_context(|| "error while iterating over directory")?
|
||||
.path();
|
||||
let filename = path_buf.to_string_lossy().to_string();
|
||||
|
||||
if filename.ends_with(EXTENSION_TXT) {
|
||||
@@ -15,14 +20,11 @@ pub fn find_input_file() -> Result<(String, bool), Error> {
|
||||
return Ok((filename, true));
|
||||
}
|
||||
}
|
||||
Err(Error::new(
|
||||
ErrorKind::NotFound,
|
||||
"No tacview input file found in current directory.",
|
||||
))
|
||||
bail!("No tacview input file found in current directory.")
|
||||
}
|
||||
|
||||
pub fn read_data(filename: &str, is_zip: bool) -> Result<Vec<String>, Error> {
|
||||
let file = fs::File::open(filename)?;
|
||||
pub fn read_data(filename: &str, is_zip: bool) -> Result<Vec<String>> {
|
||||
let file = fs::File::open(filename).with_context(|| "could not open {filename}")?;
|
||||
let buf = BufReader::new(file);
|
||||
if is_zip {
|
||||
Ok(read_zip(buf)?)
|
||||
@@ -31,17 +33,19 @@ pub fn read_data(filename: &str, is_zip: bool) -> Result<Vec<String>, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_zip(buf: BufReader<File>) -> Result<Vec<String>, Error> {
|
||||
let mut archive = zip::ZipArchive::new(buf)?;
|
||||
let inner_file = archive.by_index(0)?;
|
||||
fn read_zip(buf: BufReader<File>) -> Result<Vec<String>> {
|
||||
let mut archive = zip::ZipArchive::new(buf).with_context(|| "could not open zip archive")?;
|
||||
let inner_file = archive
|
||||
.by_index(0)
|
||||
.with_context(|| "could not get the txt file in the zip archive")?;
|
||||
let inner_buf = BufReader::new(inner_file);
|
||||
read_txt(inner_buf)
|
||||
}
|
||||
|
||||
fn read_txt(buf: impl BufRead) -> Result<Vec<String>, Error> {
|
||||
fn read_txt(buf: impl BufRead) -> Result<Vec<String>> {
|
||||
let mut lines = vec![];
|
||||
for line in buf.lines() {
|
||||
lines.push(line?);
|
||||
lines.push(line.with_context(|| "could not read line from file, is it valid UTF-8?")?);
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
+92
-18
@@ -1,16 +1,20 @@
|
||||
use crate::constants::{EXTENSION_TXT, EXTENSION_ZIP};
|
||||
use crate::tacview::Coalition;
|
||||
use std::error::Error as ErrTrait;
|
||||
use std::fs::File;
|
||||
use std::io::{Error, Write};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use zip::write::FileOptions;
|
||||
use zip::ZipWriter;
|
||||
|
||||
use crate::constants::{EXTENSION_TXT, EXTENSION_ZIP};
|
||||
use crate::tacview::Coalition;
|
||||
|
||||
pub fn create_writer(
|
||||
is_zip: bool,
|
||||
filename: &str,
|
||||
coalition: &Coalition,
|
||||
) -> Result<Box<dyn Write>, Box<dyn ErrTrait>> {
|
||||
) -> Result<Box<dyn Write>> {
|
||||
let base_name = remove_extension(filename, is_zip);
|
||||
let txt_name = format!("{base_name}_{coalition}{EXTENSION_TXT}");
|
||||
|
||||
@@ -31,11 +35,11 @@ fn remove_extension(filename: &str, is_zip: bool) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_textwriter(filename: &str) -> Result<File, Error> {
|
||||
File::create(filename)
|
||||
fn create_textwriter(filename: &str) -> Result<File> {
|
||||
File::create(filename).with_context(|| "could not create text file")
|
||||
}
|
||||
|
||||
fn create_zipwriter(outer_name: &str, inner_name: &str) -> Result<ZipWriter<File>, Error> {
|
||||
fn create_zipwriter(outer_name: &str, inner_name: &str) -> Result<ZipWriter<File>> {
|
||||
let mut writer = ZipWriter::new(create_textwriter(outer_name)?);
|
||||
writer.start_file(
|
||||
inner_name,
|
||||
@@ -45,27 +49,27 @@ fn create_zipwriter(outer_name: &str, inner_name: &str) -> Result<ZipWriter<File
|
||||
}
|
||||
|
||||
pub trait StringWriter {
|
||||
fn write_line<S: AsRef<str>>(&mut self, line: &S) -> Result<(), Error>;
|
||||
fn write_line<S: AsRef<str>>(&mut self, line: &S) -> Result<()>;
|
||||
|
||||
fn write_strings<S: AsRef<str>>(&mut self, lines: &[S]) -> Result<(), Error>;
|
||||
fn write_strings<S: AsRef<str>>(&mut self, lines: &[S]) -> Result<()>;
|
||||
|
||||
fn write_for_coalition<S: AsRef<str>>(
|
||||
&mut self,
|
||||
lines: &[S],
|
||||
coalition_per_line: &[Coalition],
|
||||
coalition: Coalition,
|
||||
) -> Result<(), Box<dyn std::error::Error>>;
|
||||
coalition: &Coalition,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
impl<T> StringWriter for T
|
||||
where
|
||||
T: Write,
|
||||
{
|
||||
fn write_line<S: AsRef<str>>(&mut self, line: &S) -> Result<(), Error> {
|
||||
writeln!(self, "{}", line.as_ref())
|
||||
fn write_line<S: AsRef<str>>(&mut self, line: &S) -> Result<()> {
|
||||
writeln!(self, "{}", line.as_ref()).with_context(|| "could not write line")
|
||||
}
|
||||
|
||||
fn write_strings<S: AsRef<str>>(&mut self, lines: &[S]) -> Result<(), Error> {
|
||||
fn write_strings<S: AsRef<str>>(&mut self, lines: &[S]) -> Result<()> {
|
||||
for line in lines {
|
||||
self.write_line(line)?;
|
||||
}
|
||||
@@ -76,14 +80,14 @@ where
|
||||
&mut self,
|
||||
lines: &[S],
|
||||
coalition_per_line: &[Coalition],
|
||||
coalition: Coalition,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
coalition: &Coalition,
|
||||
) -> Result<()> {
|
||||
assert_eq!(lines.len(), coalition_per_line.len());
|
||||
|
||||
let iter = lines
|
||||
.iter()
|
||||
.zip(coalition_per_line)
|
||||
.filter(|(_, c)| **c == coalition || **c == Coalition::All)
|
||||
.filter(|(_, c)| *c == coalition || **c == Coalition::All)
|
||||
.map(|(l, _)| l);
|
||||
|
||||
for line in iter {
|
||||
@@ -93,3 +97,73 @@ where
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OutputData {
|
||||
input_filename: Arc<String>,
|
||||
is_zip: bool,
|
||||
header: Arc<Vec<String>>,
|
||||
body: Arc<Vec<String>>,
|
||||
coalition_per_line: Arc<Vec<Coalition>>,
|
||||
}
|
||||
|
||||
impl OutputData {
|
||||
pub fn new(
|
||||
input_filename: String,
|
||||
is_zip: bool,
|
||||
header: Vec<String>,
|
||||
body: Vec<String>,
|
||||
coalition_per_line: Vec<Coalition>,
|
||||
) -> Self {
|
||||
let body = Arc::new(body);
|
||||
let coalition_per_line = Arc::new(coalition_per_line);
|
||||
let header = Arc::new(header);
|
||||
let input_filename = Arc::new(input_filename);
|
||||
|
||||
Self {
|
||||
is_zip,
|
||||
body,
|
||||
coalition_per_line,
|
||||
header,
|
||||
input_filename,
|
||||
}
|
||||
}
|
||||
|
||||
// clippy wants us to combine both ierators into one. this is not what we want, because then
|
||||
// we would spawn a thread, join it, and only then spawn a new one.
|
||||
#[allow(clippy::needless_collect)]
|
||||
pub fn save_to_disk(&self) -> Result<()> {
|
||||
let coalitions = vec![Coalition::Blue, Coalition::Red, Coalition::Violet];
|
||||
|
||||
let handles: Vec<_> = coalitions
|
||||
.iter()
|
||||
.map(|coalition| {
|
||||
let z = self.is_zip;
|
||||
let b = self.body.clone();
|
||||
let cpl = self.coalition_per_line.clone();
|
||||
let c = coalition.clone();
|
||||
let h = self.header.clone();
|
||||
let i = self.input_filename.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut writer = create_writer(z, &*i.clone(), &c)
|
||||
.with_context(|| format!("could not create writer for {c}"))
|
||||
.unwrap();
|
||||
writer
|
||||
.write_strings(&*h)
|
||||
.with_context(|| format!("could not write header for {c}"))
|
||||
.unwrap();
|
||||
writer
|
||||
.write_for_coalition(&*b, &*cpl, &c)
|
||||
.with_context(|| format!("could not write body for {c}"))
|
||||
.unwrap();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
handles.into_iter().zip(coalitions).for_each(|(h, c)| {
|
||||
h.join()
|
||||
.unwrap_or_else(|_| println!("could not write data for {c}"))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user