Compare commits
4 Commits
853e735fcd
...
0c954962cc
Author | SHA1 | Date |
---|---|---|
Moritz Böhme | 0c954962cc | |
Moritz Böhme | 103c6d779e | |
Moritz Böhme | dfd0b52d50 | |
Moritz Böhme | b3885f80dd |
File diff suppressed because it is too large
Load Diff
|
@ -8,6 +8,8 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.71"
|
anyhow = "1.0.71"
|
||||||
clap = { version = "4.3.4", features = ["derive"] }
|
clap = { version = "4.3.4", features = ["derive"] }
|
||||||
|
humantime = "2.1.0"
|
||||||
|
notify-rust = "4.8.0"
|
||||||
serde = { version = "1.0.164", features = ["derive"] }
|
serde = { version = "1.0.164", features = ["derive"] }
|
||||||
serde_cbor = "0.11.2"
|
serde_cbor = "0.11.2"
|
||||||
thiserror = "1.0.44"
|
thiserror = "1.0.44"
|
||||||
|
|
37
src/cli.rs
37
src/cli.rs
|
@ -1,8 +1,9 @@
|
||||||
use crate::daemon::{Answer, Command as OtherCommand, AnswerErr};
|
use crate::daemon::{Answer, AnswerErr, Command as OtherCommand};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::net::Shutdown;
|
use std::net::Shutdown;
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "timers")]
|
#[command(name = "timers")]
|
||||||
|
@ -18,10 +19,35 @@ pub struct Cli {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Daemon,
|
Daemon {
|
||||||
Add { name: String, duration_seconds: u64 },
|
#[arg(short, long)]
|
||||||
|
notify: bool,
|
||||||
|
},
|
||||||
|
Add {
|
||||||
|
name: String,
|
||||||
|
duration: humantime::Duration,
|
||||||
|
},
|
||||||
List,
|
List,
|
||||||
Remove { name: String },
|
Remove {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
#[command(subcommand)]
|
||||||
|
Pomodoro(PomodoroCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum PomodoroCommand {
|
||||||
|
Start {
|
||||||
|
#[clap(default_value_t = Duration::from_secs(25 * 60).into())]
|
||||||
|
work: humantime::Duration,
|
||||||
|
#[clap(default_value_t = Duration::from_secs(5 * 60).into())]
|
||||||
|
pause: humantime::Duration,
|
||||||
|
#[clap(default_value_t = Duration::from_secs(10 * 60).into())]
|
||||||
|
long_pause: humantime::Duration,
|
||||||
|
#[clap(default_value_t = 3)]
|
||||||
|
pauses_till_long: u64,
|
||||||
|
},
|
||||||
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_stream(socket_path: &String) -> Result<UnixStream> {
|
fn get_stream(socket_path: &String) -> Result<UnixStream> {
|
||||||
|
@ -35,7 +61,8 @@ pub fn send_command(socket_path: &String, command: OtherCommand) -> Result<()> {
|
||||||
stream
|
stream
|
||||||
.shutdown(Shutdown::Write)
|
.shutdown(Shutdown::Write)
|
||||||
.context("Could not shutdown write!")?;
|
.context("Could not shutdown write!")?;
|
||||||
let answer: Result<Answer, AnswerErr> = serde_cbor::from_reader(&stream).context("Could not read answer!")?;
|
let answer: Result<Answer, AnswerErr> =
|
||||||
|
serde_cbor::from_reader(&stream).context("Could not read answer!")?;
|
||||||
match answer {
|
match answer {
|
||||||
Ok(answer) => println!("{}", answer),
|
Ok(answer) => println!("{}", answer),
|
||||||
Err(err) => println!("Error: {}", err),
|
Err(err) => println!("Error: {}", err),
|
||||||
|
|
114
src/daemon.rs
114
src/daemon.rs
|
@ -1,5 +1,7 @@
|
||||||
|
use crate::pomodoro::Pomodoro;
|
||||||
pub use crate::timer::Timer;
|
pub use crate::timer::Timer;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use notify_rust::Notification;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::{
|
use std::{
|
||||||
|
@ -11,28 +13,40 @@ use std::{
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Add(String, Duration),
|
Add(Box<str>, Duration),
|
||||||
Remove(String),
|
Remove(Box<str>),
|
||||||
List,
|
List,
|
||||||
|
PomodoroStart {
|
||||||
|
work: Duration,
|
||||||
|
pause: Duration,
|
||||||
|
long_pause: Duration,
|
||||||
|
pauses_till_long: u64,
|
||||||
|
},
|
||||||
|
PomodoroStop
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum Answer {
|
pub enum Answer {
|
||||||
Ok,
|
Ok,
|
||||||
Timers(Vec<Timer>),
|
Timers(Vec<Timer>, Option<Pomodoro>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Answer {
|
impl Display for Answer {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
|
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||||
match self {
|
match self {
|
||||||
Answer::Ok => write!(f, "Ok"),
|
Answer::Ok => write!(f, "Ok"),
|
||||||
Answer::Timers(timers) => {
|
Answer::Timers(timers, pomodoro) => {
|
||||||
if timers.is_empty() {
|
if timers.is_empty() {
|
||||||
write!(f, "No timers running.")
|
writeln!(f, "No timers running.")?;
|
||||||
} else {
|
} else {
|
||||||
let strings: Vec<String> =
|
let strings: Vec<String> =
|
||||||
timers.iter().map(|timer| timer.to_string()).collect();
|
timers.iter().map(|timer| timer.to_string()).collect();
|
||||||
write!(f, "{}", strings.join("\n"))
|
writeln!(f, "{}", strings.join("\n"))?;
|
||||||
|
};
|
||||||
|
|
||||||
|
match pomodoro {
|
||||||
|
Some(p) => write!(f, "{}", p),
|
||||||
|
None => write!(f, "No pomodoro running."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,19 +56,20 @@ impl Display for Answer {
|
||||||
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
|
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
|
||||||
pub enum AnswerErr {
|
pub enum AnswerErr {
|
||||||
#[error("Timer with name '{}' already exists", .0)]
|
#[error("Timer with name '{}' already exists", .0)]
|
||||||
TimerAlreadyExist(String),
|
TimerAlreadyExist(Box<str>),
|
||||||
#[error("No timer with the name '{}' exists", .0)]
|
#[error("No timer with the name '{}' exists", .0)]
|
||||||
NoSuchTimer(String),
|
NoSuchTimer(Box<str>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub struct Daemon {
|
pub struct Daemon {
|
||||||
listener: UnixListener,
|
listener: UnixListener,
|
||||||
timers: Vec<Timer>,
|
timers: Vec<Timer>,
|
||||||
|
pomodoro: Option<Pomodoro>,
|
||||||
|
notify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Daemon {
|
impl Daemon {
|
||||||
pub fn new(socket_path: String) -> anyhow::Result<Self> {
|
pub fn new(socket_path: String, notify: bool) -> anyhow::Result<Self> {
|
||||||
let path = std::path::Path::new(&socket_path);
|
let path = std::path::Path::new(&socket_path);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::remove_file(path)
|
std::fs::remove_file(path)
|
||||||
|
@ -65,21 +80,42 @@ impl Daemon {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
listener,
|
listener,
|
||||||
timers: Vec::new(),
|
timers: Vec::new(),
|
||||||
|
pomodoro: None,
|
||||||
|
notify,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_timer(&mut self, name: &String) -> bool {
|
fn has_timer(&mut self, name: &str) -> bool {
|
||||||
self.timers.iter().any(|other| &other.name == name)
|
self.timers.iter().any(|other| other.name.as_ref() == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_command(&mut self, command: Command) -> Result<Answer, AnswerErr> {
|
fn handle_command(&mut self, command: Command) -> Result<Answer, AnswerErr> {
|
||||||
println!("Received command {:?}", command);
|
println!("Received command {:?}", command);
|
||||||
match command {
|
match command {
|
||||||
Command::List => Ok(Answer::Timers(self.timers.to_vec())),
|
Command::List => Ok(Answer::Timers(self.timers.clone(), self.pomodoro.clone())),
|
||||||
Command::Add(name, duration) => {
|
Command::Add(name, duration) => {
|
||||||
if self.has_timer(&name) {
|
if self.has_timer(&name) {
|
||||||
return Err(AnswerErr::TimerAlreadyExist(name));
|
return Err(AnswerErr::TimerAlreadyExist(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.notify {
|
||||||
|
match Notification::new()
|
||||||
|
.summary(" Timers")
|
||||||
|
.body(
|
||||||
|
format!(
|
||||||
|
"Started timer {} for {}",
|
||||||
|
&name,
|
||||||
|
humantime::format_duration(duration)
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
{
|
||||||
|
Ok(_) => println!("Sent notification sucessfully."),
|
||||||
|
Err(_) => println!("Failed to send notification."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let timer = Timer::new(name, duration);
|
let timer = Timer::new(name, duration);
|
||||||
self.timers.push(timer);
|
self.timers.push(timer);
|
||||||
Ok(Answer::Ok)
|
Ok(Answer::Ok)
|
||||||
|
@ -88,14 +124,31 @@ impl Daemon {
|
||||||
if !self.has_timer(&name) {
|
if !self.has_timer(&name) {
|
||||||
return Err(AnswerErr::NoSuchTimer(name));
|
return Err(AnswerErr::NoSuchTimer(name));
|
||||||
}
|
}
|
||||||
self.timers = self
|
self.timers
|
||||||
.timers
|
.retain(|other| other.name.as_ref() != name.as_ref());
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.filter(|other| other.name != name)
|
|
||||||
.collect();
|
|
||||||
Ok(Answer::Ok)
|
Ok(Answer::Ok)
|
||||||
}
|
}
|
||||||
|
Command::PomodoroStart {
|
||||||
|
work,
|
||||||
|
pause,
|
||||||
|
long_pause,
|
||||||
|
pauses_till_long,
|
||||||
|
} => {
|
||||||
|
match Notification::new()
|
||||||
|
.summary(" Timers")
|
||||||
|
.body("Started pomodoro.")
|
||||||
|
.show()
|
||||||
|
{
|
||||||
|
Ok(_) => println!("Sent notification sucessfully."),
|
||||||
|
Err(_) => println!("Failed to send notification."),
|
||||||
|
};
|
||||||
|
self.pomodoro = Some(Pomodoro::new(work, pause, long_pause, pauses_till_long));
|
||||||
|
Ok(Answer::Ok)
|
||||||
|
}
|
||||||
|
Command::PomodoroStop => {
|
||||||
|
self.pomodoro = None;
|
||||||
|
Ok(Answer::Ok)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,18 +161,19 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_timers(&mut self) {
|
fn check_timers(&mut self) {
|
||||||
self.timers = self
|
self.timers.retain(|timer| {
|
||||||
.timers
|
if timer.is_expired() {
|
||||||
.iter()
|
timer.handle_expiration(self.notify);
|
||||||
.cloned()
|
}
|
||||||
.filter(|timer| {
|
|
||||||
let expired = timer.is_expired();
|
!timer.is_expired()
|
||||||
if expired {
|
});
|
||||||
println!("Timer {} is expired!", timer.name);
|
|
||||||
|
if let Some(pomodoro) = &mut self.pomodoro {
|
||||||
|
if pomodoro.is_expired() {
|
||||||
|
pomodoro.handle_expiration(self.notify);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
!expired
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(&mut self) -> anyhow::Result<()> {
|
pub fn run(&mut self) -> anyhow::Result<()> {
|
||||||
|
|
29
src/main.rs
29
src/main.rs
|
@ -1,24 +1,37 @@
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
|
pub mod pomodoro;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::cli::{send_command, Cli, Command as CliCommand};
|
use crate::cli::{send_command, Cli, Command as CliCommand};
|
||||||
use crate::daemon::{Command as DaemonCommand, Daemon};
|
use crate::daemon::{Command as DaemonCommand, Daemon};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use cli::PomodoroCommand;
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Cli::parse();
|
let args = Cli::parse();
|
||||||
let daemon_command = match args.command {
|
let daemon_command = match args.command {
|
||||||
CliCommand::Daemon => return Daemon::new(args.socket)?.run(),
|
CliCommand::Daemon { notify } => return Daemon::new(args.socket, notify)?.run(),
|
||||||
CliCommand::Add {
|
CliCommand::Add { name, duration } => {
|
||||||
name,
|
DaemonCommand::Add(name.into_boxed_str(), duration.into())
|
||||||
duration_seconds,
|
}
|
||||||
} => DaemonCommand::Add(name, Duration::from_secs(duration_seconds)),
|
|
||||||
CliCommand::List => DaemonCommand::List,
|
CliCommand::List => DaemonCommand::List,
|
||||||
CliCommand::Remove { name } => DaemonCommand::Remove(name),
|
CliCommand::Remove { name } => DaemonCommand::Remove(name.into_boxed_str()),
|
||||||
|
CliCommand::Pomodoro(pomodoro) => match pomodoro {
|
||||||
|
PomodoroCommand::Start {
|
||||||
|
work,
|
||||||
|
pause,
|
||||||
|
long_pause,
|
||||||
|
pauses_till_long,
|
||||||
|
} => DaemonCommand::PomodoroStart {
|
||||||
|
work: work.into(),
|
||||||
|
pause: pause.into(),
|
||||||
|
long_pause: long_pause.into(),
|
||||||
|
pauses_till_long,
|
||||||
|
},
|
||||||
|
PomodoroCommand::Stop => DaemonCommand::PomodoroStop,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
send_command(&args.socket, daemon_command)
|
send_command(&args.socket, daemon_command)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
use std::{fmt::Display, time::Duration};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::daemon::Timer;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Pomodoro {
|
||||||
|
work: Duration,
|
||||||
|
pause: Duration,
|
||||||
|
long_pause: Duration,
|
||||||
|
pauses_till_long: u64,
|
||||||
|
pauses: u64,
|
||||||
|
status: Status,
|
||||||
|
pub timer: Timer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Pomodoro {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"Pomodoro ({}, {}, {}) currently {} with {} remaining.",
|
||||||
|
humantime::format_duration(self.work),
|
||||||
|
humantime::format_duration(self.pause),
|
||||||
|
humantime::format_duration(self.long_pause),
|
||||||
|
self.status,
|
||||||
|
humantime::format_duration(self.timer.remaining())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
enum Status {
|
||||||
|
Working,
|
||||||
|
Pausing,
|
||||||
|
LongPause,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Status {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Status::Working => write!(f, "pomodoro work"),
|
||||||
|
Status::Pausing => write!(f, "pomodoro pause"),
|
||||||
|
Status::LongPause => write!(f, "pomodoro long pause"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pomodoro {
|
||||||
|
pub fn new(
|
||||||
|
work: Duration,
|
||||||
|
pause: Duration,
|
||||||
|
long_pause: Duration,
|
||||||
|
pauses_till_long: u64,
|
||||||
|
) -> Self {
|
||||||
|
Pomodoro {
|
||||||
|
work,
|
||||||
|
pause,
|
||||||
|
long_pause,
|
||||||
|
pauses_till_long,
|
||||||
|
pauses: 0,
|
||||||
|
status: Status::Working,
|
||||||
|
timer: Timer::new(Status::Working.to_string().into_boxed_str(), work),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_expiration(&mut self, notify: bool) {
|
||||||
|
self.timer.handle_expiration(notify);
|
||||||
|
let duration = match self.status {
|
||||||
|
Status::Working => {
|
||||||
|
if self.pauses == self.pauses_till_long {
|
||||||
|
self.long_pause
|
||||||
|
} else {
|
||||||
|
self.pause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => self.work,
|
||||||
|
};
|
||||||
|
self.status = match self.status {
|
||||||
|
Status::Working => {
|
||||||
|
self.pauses += 1;
|
||||||
|
Status::Pausing
|
||||||
|
}
|
||||||
|
_ => Status::Working,
|
||||||
|
};
|
||||||
|
self.timer = Timer::new(self.status.to_string().into_boxed_str(), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
self.timer.is_expired()
|
||||||
|
}
|
||||||
|
}
|
24
src/timer.rs
24
src/timer.rs
|
@ -1,3 +1,4 @@
|
||||||
|
use notify_rust::Notification;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
fmt::{Display, Formatter},
|
fmt::{Display, Formatter},
|
||||||
|
@ -33,7 +34,7 @@ mod approx_instant {
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
|
||||||
pub struct Timer {
|
pub struct Timer {
|
||||||
pub name: String,
|
pub name: Box<str>,
|
||||||
#[serde(with = "approx_instant")]
|
#[serde(with = "approx_instant")]
|
||||||
start: Instant,
|
start: Instant,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
|
@ -43,15 +44,15 @@ impl Display for Timer {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
|
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"{} has {}s remaining.",
|
"{} has {} remaining.",
|
||||||
self.name,
|
self.name,
|
||||||
self.remaining().as_secs()
|
humantime::format_duration(self.remaining())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Timer {
|
impl Timer {
|
||||||
pub fn new(name: String, duration: Duration) -> Timer {
|
pub fn new(name: Box<str>, duration: Duration) -> Timer {
|
||||||
Timer {
|
Timer {
|
||||||
name,
|
name,
|
||||||
start: Instant::now(),
|
start: Instant::now(),
|
||||||
|
@ -63,7 +64,20 @@ impl Timer {
|
||||||
Instant::now() - self.start > self.duration
|
Instant::now() - self.start > self.duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining duration rounded to seconds of this [`Timer`].
|
||||||
pub fn remaining(&self) -> Duration {
|
pub fn remaining(&self) -> Duration {
|
||||||
self.duration - (Instant::now() - self.start)
|
let exact = self.duration - (Instant::now() - self.start);
|
||||||
|
Duration::from_secs(exact.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_expiration(&self, notify: bool) {
|
||||||
|
let msg = format!("Timer {} has expired!", self.name);
|
||||||
|
println!("{}", &msg);
|
||||||
|
if notify {
|
||||||
|
match Notification::new().summary(" Timers").body(&msg).show() {
|
||||||
|
Ok(_) => println!("Sent notification sucessfully."),
|
||||||
|
Err(_) => println!("Failed to send notification."),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue