mirror of
https://github.com/G2-Games/minidisc-cli.git
synced 2025-04-19 11:42:53 -05:00
Added proper error types
This commit is contained in:
parent
9bdd7ddb39
commit
e55736344d
7 changed files with 340 additions and 201 deletions
|
@ -34,6 +34,7 @@ cbc = "0.1"
|
||||||
ecb = "0.1"
|
ecb = "0.1"
|
||||||
tokio = { version = "1.36", features = ["sync"] }
|
tokio = { version = "1.36", features = ["sync"] }
|
||||||
unicode-jp = { git = "https://github.com/uzabase/unicode-jp-rs.git" }
|
unicode-jp = { git = "https://github.com/uzabase/unicode-jp-rs.git" }
|
||||||
|
thiserror = "1.0.57"
|
||||||
|
|
||||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||||
gloo = { version = "0.11.0", features = ["futures"] }
|
gloo = { version = "0.11.0", features = ["futures"] }
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
/*!
|
/// A crate for controlling NetMD and Hi-MD devices.
|
||||||
* This crate is an interface in rust to control NetMD and Hi-MD minidisc devices.
|
///
|
||||||
*
|
/// To use this library, first you need to get a device from [cross-usb] and then open a [netmd::interface::NetMDInterface]
|
||||||
* Documentation coming soon
|
|
||||||
*/
|
|
||||||
|
|
||||||
pub mod netmd;
|
pub mod netmd;
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
#![cfg_attr(debug_assertions, allow(dead_code))]
|
#![cfg_attr(debug_assertions, allow(dead_code))]
|
||||||
use nofmt;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::error::Error;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use nofmt;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
// USB stuff
|
// USB stuff
|
||||||
use cross_usb::usb::{ControlIn, ControlOut, ControlType, Device, Interface, Recipient};
|
use cross_usb::usb::{ControlIn, ControlOut, ControlType, Device, Interface, Recipient, UsbError};
|
||||||
use cross_usb::{UsbDevice, UsbInterface};
|
use cross_usb::{UsbDevice, UsbInterface};
|
||||||
|
|
||||||
use super::utils::cross_sleep;
|
use super::utils::cross_sleep;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::new(10000, 0);
|
|
||||||
const BULK_WRITE_ENDPOINT: u8 = 0x02;
|
const BULK_WRITE_ENDPOINT: u8 = 0x02;
|
||||||
const BULK_READ_ENDPOINT: u8 = 0x81;
|
const BULK_READ_ENDPOINT: u8 = 0x81;
|
||||||
|
|
||||||
|
@ -89,13 +89,32 @@ pub enum Status {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ID of a device, including the name
|
/// The ID of a device, including the name
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct DeviceId {
|
pub struct DeviceId {
|
||||||
vendor_id: u16,
|
vendor_id: u16,
|
||||||
product_id: u16,
|
product_id: u16,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A connection to a NetMD device
|
#[derive(Error, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum NetMDError {
|
||||||
|
#[error("communication timed out")]
|
||||||
|
Timeout,
|
||||||
|
|
||||||
|
#[error("invalid usb result")]
|
||||||
|
InvalidResult,
|
||||||
|
|
||||||
|
#[error("the device is not ready")]
|
||||||
|
NotReady,
|
||||||
|
|
||||||
|
#[error("could not find device")]
|
||||||
|
UnknownDevice(DeviceId),
|
||||||
|
|
||||||
|
#[error("usb connection error")]
|
||||||
|
UsbError(#[from] UsbError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A USB connection to a NetMD device
|
||||||
pub struct NetMD {
|
pub struct NetMD {
|
||||||
usb_interface: UsbInterface,
|
usb_interface: UsbInterface,
|
||||||
model: DeviceId,
|
model: DeviceId,
|
||||||
|
@ -105,7 +124,7 @@ impl NetMD {
|
||||||
const READ_REPLY_RETRY_INTERVAL: u32 = 10;
|
const READ_REPLY_RETRY_INTERVAL: u32 = 10;
|
||||||
|
|
||||||
/// Creates a new interface to a NetMD device
|
/// Creates a new interface to a NetMD device
|
||||||
pub async fn new(usb_device: &UsbDevice) -> Result<Self, Box<dyn Error>> {
|
pub async fn new(usb_device: &UsbDevice) -> Result<Self, NetMDError> {
|
||||||
let mut model = DeviceId {
|
let mut model = DeviceId {
|
||||||
vendor_id: usb_device.vendor_id().await,
|
vendor_id: usb_device.vendor_id().await,
|
||||||
product_id: usb_device.product_id().await,
|
product_id: usb_device.product_id().await,
|
||||||
|
@ -122,7 +141,7 @@ impl NetMD {
|
||||||
}
|
}
|
||||||
|
|
||||||
match model.name {
|
match model.name {
|
||||||
None => return Err("Could not find device in list".into()),
|
None => return Err(NetMDError::UnknownDevice(model)),
|
||||||
Some(_) => (),
|
Some(_) => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,7 +170,7 @@ impl NetMD {
|
||||||
|
|
||||||
/// Poll the device to get either the result
|
/// Poll the device to get either the result
|
||||||
/// of the previous command, or the status
|
/// of the previous command, or the status
|
||||||
pub async fn poll(&mut self) -> Result<(u16, [u8; 4]), Box<dyn Error>> {
|
pub async fn poll(&mut self) -> Result<(u16, [u8; 4]), NetMDError> {
|
||||||
// Create an array to store the result of the poll
|
// Create an array to store the result of the poll
|
||||||
let poll_result = match self
|
let poll_result = match self
|
||||||
.usb_interface
|
.usb_interface
|
||||||
|
@ -173,17 +192,17 @@ impl NetMD {
|
||||||
|
|
||||||
let poll_result: [u8; 4] = match poll_result.try_into() {
|
let poll_result: [u8; 4] = match poll_result.try_into() {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(_) => return Err("could not convert result".into()),
|
Err(_) => return Err(NetMDError::InvalidResult),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((length_bytes, poll_result))
|
Ok((length_bytes, poll_result))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_command(&mut self, command: Vec<u8>) -> Result<(), Box<dyn Error>> {
|
pub async fn send_command(&mut self, command: Vec<u8>) -> Result<(), NetMDError> {
|
||||||
self._send_command(command, false).await
|
self._send_command(command, false).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_factory_command(&mut self, command: Vec<u8>) -> Result<(), Box<dyn Error>> {
|
pub async fn send_factory_command(&mut self, command: Vec<u8>) -> Result<(), NetMDError> {
|
||||||
self._send_command(command, true).await
|
self._send_command(command, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,12 +211,12 @@ impl NetMD {
|
||||||
&mut self,
|
&mut self,
|
||||||
command: Vec<u8>,
|
command: Vec<u8>,
|
||||||
use_factory_command: bool,
|
use_factory_command: bool,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), NetMDError> {
|
||||||
// First poll to ensure the device is ready
|
// First poll to ensure the device is ready
|
||||||
match self.poll().await {
|
match self.poll().await {
|
||||||
Ok(buffer) => match buffer.1[2] {
|
Ok(buffer) => match buffer.1[2] {
|
||||||
0 => 0,
|
0 => 0,
|
||||||
_ => return Err("Device not ready!".into()),
|
_ => return Err(NetMDError::NotReady),
|
||||||
},
|
},
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
|
@ -227,14 +246,14 @@ impl NetMD {
|
||||||
pub async fn read_reply(
|
pub async fn read_reply(
|
||||||
&mut self,
|
&mut self,
|
||||||
override_length: Option<i32>,
|
override_length: Option<i32>,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, NetMDError> {
|
||||||
self._read_reply(false, override_length).await
|
self._read_reply(false, override_length).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_factory_reply(
|
pub async fn read_factory_reply(
|
||||||
&mut self,
|
&mut self,
|
||||||
override_length: Option<i32>,
|
override_length: Option<i32>,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, NetMDError> {
|
||||||
self._read_reply(true, override_length).await
|
self._read_reply(true, override_length).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -243,12 +262,12 @@ impl NetMD {
|
||||||
&mut self,
|
&mut self,
|
||||||
use_factory_command: bool,
|
use_factory_command: bool,
|
||||||
override_length: Option<i32>,
|
override_length: Option<i32>,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, NetMDError> {
|
||||||
let mut length = 0;
|
let mut length = 0;
|
||||||
|
|
||||||
for attempt in 0..40 {
|
for attempt in 0..40 {
|
||||||
if attempt == 39 {
|
if attempt == 39 {
|
||||||
return Err("Failed to get response length".into());
|
return Err(NetMDError::Timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
length = self.poll().await?.0;
|
length = self.poll().await?.0;
|
||||||
|
@ -260,7 +279,7 @@ impl NetMD {
|
||||||
// Back off while trying again
|
// Back off while trying again
|
||||||
let sleep_time = Self::READ_REPLY_RETRY_INTERVAL * (u32::pow(2, attempt) - 1);
|
let sleep_time = Self::READ_REPLY_RETRY_INTERVAL * (u32::pow(2, attempt) - 1);
|
||||||
|
|
||||||
cross_sleep(sleep_time).await;
|
cross_sleep(Duration::from_millis(sleep_time as u64)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = override_length {
|
if let Some(value) = override_length {
|
||||||
|
@ -293,7 +312,7 @@ impl NetMD {
|
||||||
&mut self,
|
&mut self,
|
||||||
length: usize,
|
length: usize,
|
||||||
chunksize: usize,
|
chunksize: usize,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, NetMDError> {
|
||||||
let result = self.read_bulk_to_array(length, chunksize).await?;
|
let result = self.read_bulk_to_array(length, chunksize).await?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
@ -303,7 +322,7 @@ impl NetMD {
|
||||||
&mut self,
|
&mut self,
|
||||||
length: usize,
|
length: usize,
|
||||||
chunksize: usize,
|
chunksize: usize,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, NetMDError> {
|
||||||
let mut final_result: Vec<u8> = Vec::new();
|
let mut final_result: Vec<u8> = Vec::new();
|
||||||
let mut done = 0;
|
let mut done = 0;
|
||||||
|
|
||||||
|
@ -317,7 +336,7 @@ impl NetMD {
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(error) => return Err(format!("USB error: {:?}", error).into()),
|
Err(error) => return Err(NetMDError::UsbError(error)),
|
||||||
};
|
};
|
||||||
|
|
||||||
final_result.extend_from_slice(&res);
|
final_result.extend_from_slice(&res);
|
||||||
|
@ -326,7 +345,7 @@ impl NetMD {
|
||||||
Ok(final_result)
|
Ok(final_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_bulk(&mut self, data: &[u8]) -> Result<usize, Box<dyn Error>> {
|
pub async fn write_bulk(&mut self, data: &[u8]) -> Result<usize, NetMDError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.usb_interface
|
.usb_interface
|
||||||
.bulk_out(BULK_WRITE_ENDPOINT, data)
|
.bulk_out(BULK_WRITE_ENDPOINT, data)
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
use num_derive::FromPrimitive;
|
use num_derive::FromPrimitive;
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::interface::{MDSession, MDTrack, NetMDInterface};
|
use super::interface::{MDSession, MDTrack, NetMDInterface};
|
||||||
use super::utils::cross_sleep;
|
use super::utils::cross_sleep;
|
||||||
|
@ -69,7 +70,7 @@ pub async fn prepare_download(interface: &mut NetMDInterface) -> Result<(), Box<
|
||||||
.state
|
.state
|
||||||
.unwrap_or(OperatingStatus::NoDisc),
|
.unwrap_or(OperatingStatus::NoDisc),
|
||||||
) {
|
) {
|
||||||
cross_sleep(200).await;
|
cross_sleep(Duration::from_millis(200)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = interface.session_key_forget().await;
|
let _ = interface.session_key_forget().await;
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,7 @@
|
||||||
use crate::netmd::utils;
|
use crate::netmd::utils;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::collections::hash_map::HashMap;
|
use std::collections::hash_map::HashMap;
|
||||||
use std::error::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
/// %b, w, d, q - explained above (can have endiannes overriden by '>' and '<' operators, f. ex. %>d %<q)
|
/// %b, w, d, q - explained above (can have endiannes overriden by '>' and '<' operators, f. ex. %>d %<q)
|
||||||
|
@ -27,24 +27,73 @@ pub enum QueryValue {
|
||||||
Array(Vec<u8>),
|
Array(Vec<u8>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueryValue {
|
#[derive(Error, Debug, Eq, PartialEq, PartialOrd, Ord)]
|
||||||
pub fn to_vec(&self) -> Result<Vec<u8>, Box<dyn Error>> {
|
pub enum ValueError {
|
||||||
match self {
|
#[error("type mismatch: expected {expected}, got {actual}")]
|
||||||
QueryValue::Array(a) => Ok(a.to_vec()),
|
TypeMismatch {
|
||||||
_ => Err("QueryValue type mismatch! Expected Vec<u8>, got i64".into()),
|
expected: String,
|
||||||
|
actual: String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_i64(&self) -> Result<i64, Box<dyn Error>> {
|
impl QueryValue {
|
||||||
|
pub fn from_array<const S: usize>(value: [u8; S]) -> Self {
|
||||||
|
Self::Array(value.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_array<const S: usize>(&self) -> Result<[u8; S], ValueError> {
|
||||||
|
let mut array = [0u8; S];
|
||||||
|
match self {
|
||||||
|
QueryValue::Array(a) => {
|
||||||
|
for (i, byte) in a.iter().take(S).enumerate() {
|
||||||
|
array[i] = *byte
|
||||||
|
}
|
||||||
|
Ok(array)
|
||||||
|
},
|
||||||
|
_ => Err(ValueError::TypeMismatch {
|
||||||
|
expected: String::from("Vec<u8>"),
|
||||||
|
actual: format!("{:?}", self)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_vec(&self) -> Result<Vec<u8>, ValueError> {
|
||||||
|
match self {
|
||||||
|
QueryValue::Array(a) => Ok(a.to_vec()),
|
||||||
|
_ => Err(ValueError::TypeMismatch {
|
||||||
|
expected: String::from("Vec<u8>"),
|
||||||
|
actual: format!("{:?}", self)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_i64(&self) -> Result<i64, ValueError> {
|
||||||
match self {
|
match self {
|
||||||
QueryValue::Number(a) => Ok(*a),
|
QueryValue::Number(a) => Ok(*a),
|
||||||
_ => Err("QueryValue type mismatch! Expected i64, got Vec<u8>".into()),
|
_ => Err(ValueError::TypeMismatch {
|
||||||
|
expected: String::from("i64"),
|
||||||
|
actual: format!("{:?}", self)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug, Eq, PartialEq, PartialOrd, Ord)]
|
||||||
|
pub enum QueryError {
|
||||||
|
#[error("unrecognized format character: `{0}`")]
|
||||||
|
UnrecognizedChar(char),
|
||||||
|
|
||||||
|
#[error("Format and input mismatch at {index}: expected {expected:#04x}, got {actual:#04x} (format {format_string})")]
|
||||||
|
InputMismatch {
|
||||||
|
index: usize,
|
||||||
|
expected: u8,
|
||||||
|
actual: u8,
|
||||||
|
format_string: String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Formats a query using a standard input to send to the player
|
/// Formats a query using a standard input to send to the player
|
||||||
pub fn format_query(format: String, args: Vec<QueryValue>) -> Result<Vec<u8>, Box<dyn Error>> {
|
pub fn format_query(format: String, args: Vec<QueryValue>) -> Result<Vec<u8>, QueryError> {
|
||||||
if DEBUG {
|
if DEBUG {
|
||||||
println!("SENT>>> F: {}", format);
|
println!("SENT>>> F: {}", format);
|
||||||
}
|
}
|
||||||
|
@ -114,7 +163,7 @@ pub fn format_query(format: String, args: Vec<QueryValue>) -> Result<Vec<u8>, Bo
|
||||||
}
|
}
|
||||||
result.push((converted & 0xFF) as u8);
|
result.push((converted & 0xFF) as u8);
|
||||||
}
|
}
|
||||||
_ => return Err(format!("Unrecognized format char {}", character).into()),
|
_ => return Err(QueryError::UnrecognizedChar(character)),
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -142,7 +191,7 @@ pub fn format_query(format: String, args: Vec<QueryValue>) -> Result<Vec<u8>, Bo
|
||||||
pub fn scan_query(
|
pub fn scan_query(
|
||||||
query_result: Vec<u8>,
|
query_result: Vec<u8>,
|
||||||
format: String,
|
format: String,
|
||||||
) -> Result<Vec<QueryValue>, Box<dyn Error>> {
|
) -> Result<Vec<QueryValue>, QueryError> {
|
||||||
let mut result: Vec<QueryValue> = Vec::new();
|
let mut result: Vec<QueryValue> = Vec::new();
|
||||||
|
|
||||||
let initial_length = query_result.len();
|
let initial_length = query_result.len();
|
||||||
|
@ -224,7 +273,7 @@ pub fn scan_query(
|
||||||
| input_stack.next().unwrap() as i32;
|
| input_stack.next().unwrap() as i32;
|
||||||
result.push(QueryValue::Number(utils::bcd_to_int(v) as i64));
|
result.push(QueryValue::Number(utils::bcd_to_int(v) as i64));
|
||||||
}
|
}
|
||||||
_ => return Err(format!("Unrecognized format char {}", character).into()),
|
_ => return Err(QueryError::UnrecognizedChar(character)),
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -244,7 +293,12 @@ pub fn scan_query(
|
||||||
u8::from_str_radix(&String::from_iter([half.unwrap(), character]), 16).unwrap();
|
u8::from_str_radix(&String::from_iter([half.unwrap(), character]), 16).unwrap();
|
||||||
if format_value != input_value {
|
if format_value != input_value {
|
||||||
let i = initial_length - input_stack.len() - 1;
|
let i = initial_length - input_stack.len() - 1;
|
||||||
return Err(format!("Format and input mismatch at {i}: expected {format_value:#04x}, got {input_value:#04x} (format {format})").into());
|
return Err(QueryError::InputMismatch {
|
||||||
|
index: i,
|
||||||
|
expected: format_value,
|
||||||
|
actual: input_value,
|
||||||
|
format_string: format
|
||||||
|
});
|
||||||
}
|
}
|
||||||
half = None;
|
half = None;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,19 +2,19 @@ use crate::netmd::mappings::{ALLOWED_HW_KANA, MAPPINGS_DE, MAPPINGS_HW, MAPPINGS
|
||||||
use diacritics;
|
use diacritics;
|
||||||
use encoding_rs::SHIFT_JIS;
|
use encoding_rs::SHIFT_JIS;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::{collections::hash_map::HashMap, error::Error, vec::IntoIter};
|
use std::{collections::hash_map::HashMap, error::Error, vec::IntoIter, time::Duration};
|
||||||
use unicode_normalization::UnicodeNormalization;
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
extern crate kana;
|
extern crate kana;
|
||||||
use kana::*;
|
use kana::*;
|
||||||
|
|
||||||
/// Sleep for a specified number of milliseconds on any platform
|
/// Sleep for a specified number of milliseconds on any platform
|
||||||
pub async fn cross_sleep(millis: u32) {
|
pub async fn cross_sleep(duration: Duration) {
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
std::thread::sleep(std::time::Duration::from_millis(millis as u64));
|
std::thread::sleep(duration);
|
||||||
|
|
||||||
#[cfg(target_family = "wasm")]
|
#[cfg(target_family = "wasm")]
|
||||||
gloo::timers::future::TimeoutFuture::new(millis).await;
|
gloo::timers::future::TimeoutFuture::new(duration.as_millis()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bcd_to_int(mut bcd: i32) -> i32 {
|
pub fn bcd_to_int(mut bcd: i32) -> i32 {
|
||||||
|
|
Loading…
Reference in a new issue