38 changed files with 426 additions and 262 deletions
@ -1,6 +0,0 @@ |
|||||||
mod app_config; |
|
||||||
pub mod cli; // Make cli public so we can access subcommands
|
|
||||||
mod defaults; |
|
||||||
|
|
||||||
pub use self::app_config::AppConfig; |
|
||||||
pub use self::cli::Args as CliArgs; |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
use crate::bootstrap::AppConfig; |
|
||||||
use crate::bootstrap::cli::ExportDictArgs; |
|
||||||
use crate::container::Container; |
|
||||||
use tracing::info; |
|
||||||
|
|
||||||
pub async fn run(args: ExportDictArgs, _config: AppConfig, _container: Container) { |
|
||||||
info!("Exporting dictionary '{}' to {:?}", args.name, args.output); |
|
||||||
|
|
||||||
// Logic for export would go here
|
|
||||||
// e.g. let dict = container.dict_service.get(args.name);
|
|
||||||
// encoder.write(dict, args.output);
|
|
||||||
} |
|
||||||
@ -1,13 +0,0 @@ |
|||||||
use crate::bootstrap::AppConfig; |
|
||||||
use crate::container::Container; |
|
||||||
use tracing::info; |
|
||||||
|
|
||||||
pub async fn run(config: AppConfig, container: Container) { |
|
||||||
loop { |
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; |
|
||||||
info!("Health check... DB URL: {}", config.database.url); |
|
||||||
|
|
||||||
// Example usage of the container
|
|
||||||
// let _ = container.user_service.do_something();
|
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,24 +1,10 @@ |
|||||||
use crate::bootstrap::AppConfig; |
use crate::config::AppConfig; |
||||||
// use std::sync::Arc;
|
|
||||||
|
|
||||||
#[derive(Clone)] |
#[derive(Clone)] |
||||||
pub struct Container { |
pub struct Container {} |
||||||
// pub user_service: Arc<UserService>,
|
|
||||||
// Add other services here (e.g., EmailService, MetricsService)
|
|
||||||
} |
|
||||||
|
|
||||||
impl Container { |
impl Container { |
||||||
pub async fn new(config: &AppConfig) -> anyhow::Result<Self> { |
pub async fn new(_: &AppConfig) -> anyhow::Result<Self> { |
||||||
// 1. Infrastructure / Adapters
|
|
||||||
// Using the config to initialize connections
|
|
||||||
// let user_repo = PostgresUserRepository::new(&AppConfig.database.url).await?;
|
|
||||||
// let user_repo = Arc::new(user_repo);
|
|
||||||
|
|
||||||
// // 2. Application / Domain Services
|
|
||||||
// // Injecting the repository into the service
|
|
||||||
// let user_service = UserService::new(user_repo);
|
|
||||||
// let user_service = Arc::new(user_service);
|
|
||||||
|
|
||||||
Ok(Self {}) |
Ok(Self {}) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,6 @@ |
|||||||
|
# Logging configuration |
||||||
|
log_level = "info" |
||||||
|
|
||||||
|
# Server configuration |
||||||
|
[server] |
||||||
|
port = 8080 |
||||||
@ -1,7 +1,18 @@ |
|||||||
[package] |
[package] |
||||||
name = "libmnemor" |
name = "applib" |
||||||
version = "0.1.0" |
version = "0.1.0" |
||||||
edition = "2024" |
edition = "2024" |
||||||
|
|
||||||
[dependencies] |
[dependencies] |
||||||
once_cell = "1.21.3" |
once_cell = "1.21.3" |
||||||
|
clap = { version = "4.5", features = ["derive", "env"] } |
||||||
|
const_format = "0.2.35" |
||||||
|
config = "0.15.19" |
||||||
|
tracing = "0.1" |
||||||
|
tokio = { version = "1.48", features = ["full"] } |
||||||
|
anyhow = "1.0" |
||||||
|
serde = { version = "1.0", features = ["derive"] } |
||||||
|
chrono = { version = "0.4", features = ["serde"] } |
||||||
|
thiserror = "1.0" |
||||||
|
async-trait = "0.1" |
||||||
|
parking_lot = "0.12" |
||||||
|
|||||||
@ -0,0 +1,4 @@ |
|||||||
|
pub mod config; |
||||||
|
pub mod errors; |
||||||
|
pub mod services; |
||||||
|
pub mod traits; |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
use crate::core::system::System; |
||||||
|
use serde::Deserialize; |
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)] |
||||||
|
pub struct ServerConfig { |
||||||
|
pub port: u16, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)] |
||||||
|
pub struct EncoderConfig { |
||||||
|
pub system: System, |
||||||
|
pub input: String, |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
#[derive(Debug)] |
||||||
|
pub enum RepositoryError { |
||||||
|
NotFound, |
||||||
|
ConnectionFailed, |
||||||
|
InvalidData(String), |
||||||
|
Unexpected(String), |
||||||
|
} |
||||||
|
|
||||||
|
impl std::fmt::Display for RepositoryError { |
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
||||||
|
write!(f, "{:?}", self) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
use crate::application::traits::{DictRepository, DictSource}; |
||||||
|
use crate::core::entities::{Dict, DictEntry}; |
||||||
|
|
||||||
|
pub struct DictImporter<'a, R> { |
||||||
|
repo: &'a R, |
||||||
|
batch_size: usize, |
||||||
|
} |
||||||
|
|
||||||
|
impl<'a, R: DictRepository> DictImporter<'a, R> { |
||||||
|
pub fn new(repo: &'a R) -> Self { |
||||||
|
Self { |
||||||
|
repo, |
||||||
|
batch_size: 1000, // reasonable default
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub fn import(&self, name: &str, mut source: impl DictSource) -> Result<(), anyhow::Error> { |
||||||
|
// 1. Ensure Dict exists (Logic: Create if new, or maybe clear existing?)
|
||||||
|
self.repo.create(name)?; |
||||||
|
|
||||||
|
let mut batch = Vec::with_capacity(self.batch_size); |
||||||
|
|
||||||
|
// 2. Stream data
|
||||||
|
while let Some(result) = source.next_entry() { |
||||||
|
match result { |
||||||
|
Ok(entry) => { |
||||||
|
// Optional: Domain Validation logic could go here
|
||||||
|
// if entry.text.is_empty() { continue; }
|
||||||
|
|
||||||
|
batch.push(entry); |
||||||
|
|
||||||
|
// 3. Batch Write
|
||||||
|
if batch.len() >= self.batch_size { |
||||||
|
self.repo.save_entries(name, &batch)?; |
||||||
|
batch.clear(); |
||||||
|
} |
||||||
|
} |
||||||
|
Err(e) => { |
||||||
|
// Logic: Do we abort on malformed JSON or log and continue?
|
||||||
|
// Here we abort for safety.
|
||||||
|
return Err(e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 4. Flush remaining
|
||||||
|
if !batch.is_empty() { |
||||||
|
self.repo.save_entries(name, &batch)?; |
||||||
|
} |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
use crate::{ |
||||||
|
application::errors::RepositoryError, |
||||||
|
core::entities::{Dict, DictEntry}, |
||||||
|
}; |
||||||
|
|
||||||
|
pub trait DictRepository { |
||||||
|
fn create(&self, name: &str) -> Result<(), RepositoryError>; |
||||||
|
// Batch saving is usually much faster than 1-by-1 for SQL
|
||||||
|
fn save_entries(&self, dict_name: &str, entries: &[DictEntry]) -> Result<(), RepositoryError>; |
||||||
|
|
||||||
|
fn fetch_many( |
||||||
|
&self, |
||||||
|
name: &str, |
||||||
|
limit: Option<u32>, |
||||||
|
offset: Option<u32>, |
||||||
|
) -> Result<Dict, RepositoryError>; |
||||||
|
} |
||||||
|
|
||||||
|
pub trait DictSource { |
||||||
|
fn next_entry(&mut self) -> Option<Result<DictEntry, anyhow::Error>>; |
||||||
|
} |
||||||
@ -1,11 +1,11 @@ |
|||||||
pub mod entities; |
pub mod entities; |
||||||
pub mod errors; |
pub mod errors; |
||||||
pub mod major; |
pub mod sys_major; |
||||||
pub mod system; |
pub mod system; |
||||||
pub mod traits; |
pub mod traits; |
||||||
|
|
||||||
// pub use self::major::*;
|
// pub use self::major::*;
|
||||||
pub use self::entities::*; |
// pub use self::entities::*;
|
||||||
pub use self::errors::*; |
// pub use self::errors::*;
|
||||||
pub use self::system::*; |
// pub use self::system::*;
|
||||||
pub use self::traits::*; |
pub use self::traits::*; |
||||||
@ -1,45 +1,38 @@ |
|||||||
use std::collections::HashMap; |
use std::collections::HashMap; |
||||||
|
|
||||||
#[derive(Debug, Default, Clone)] |
pub type DictEntryId = u32; |
||||||
pub struct DictEntry { |
|
||||||
pub phoneme_in: String, |
|
||||||
pub phoneme_out: String, |
|
||||||
|
|
||||||
pub not_before: Vec<String>, |
|
||||||
pub not_after: Vec<String>, |
|
||||||
|
|
||||||
pub only_before: Vec<String>, |
#[derive(Debug, Clone, PartialEq)] |
||||||
pub only_after: Vec<String>, |
pub struct DictEntry { |
||||||
|
pub id: DictEntryId, |
||||||
|
pub text: String, |
||||||
|
pub metadata: HashMap<String, String>, |
||||||
} |
} |
||||||
|
|
||||||
impl DictEntry { |
impl DictEntry { |
||||||
pub fn into_lowercase(self) -> Self { |
pub fn new(id: DictEntryId, text: String) -> Self { |
||||||
DictEntry { |
DictEntry { |
||||||
phoneme_in: self.phoneme_in.to_lowercase(), |
id, |
||||||
phoneme_out: self.phoneme_out.to_lowercase(), |
text, |
||||||
not_before: Self::lower_vec(self.not_before), |
metadata: HashMap::new(), |
||||||
not_after: Self::lower_vec(self.not_after), |
|
||||||
only_before: Self::lower_vec(self.only_before), |
|
||||||
only_after: Self::lower_vec(self.only_after), |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
fn lower_vec(vec: Vec<String>) -> Vec<String> { |
|
||||||
vec.into_iter().map(|s| s.to_lowercase()).collect() |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
pub type DictEntries = Vec<DictEntry>; |
|
||||||
pub struct Dict { |
pub struct Dict { |
||||||
name: String, |
pub name: String, |
||||||
entries: DictEntries, |
pub entries: HashMap<DictEntryId, DictEntry>, |
||||||
} |
} |
||||||
|
|
||||||
pub type WordEntryId = u32; |
impl Dict { |
||||||
|
pub fn new(name: String) -> Self { |
||||||
|
Dict { |
||||||
|
name, |
||||||
|
entries: HashMap::new(), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
#[derive(Debug, Default)] |
pub fn add_entry(&mut self, entry: DictEntry) { |
||||||
pub struct WordEntry { |
self.entries.insert(entry.id, entry); |
||||||
id: Option<WordEntryId>, |
} |
||||||
word: String, |
|
||||||
metadata: HashMap<String, String>, |
|
||||||
} |
} |
||||||
|
|||||||
@ -1,15 +1,15 @@ |
|||||||
use std::fmt; |
// use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug)] |
// #[derive(Debug)]
|
||||||
pub enum RepositoryError { |
// pub enum RepositoryError {
|
||||||
NotFound, |
// NotFound,
|
||||||
ConnectionFailed, |
// ConnectionFailed,
|
||||||
InvalidData(String), |
// InvalidData(String),
|
||||||
Unexpected(String), |
// Unexpected(String),
|
||||||
} |
// }
|
||||||
|
|
||||||
impl fmt::Display for RepositoryError { |
// impl fmt::Display for RepositoryError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{:?}", self) |
// write!(f, "{:?}", self)
|
||||||
} |
// }
|
||||||
} |
// }
|
||||||
|
|||||||
@ -1,6 +1,6 @@ |
|||||||
pub mod dict_en; |
|
||||||
pub mod dict_pl; |
|
||||||
mod encoder; |
mod encoder; |
||||||
|
pub mod rules_en; |
||||||
|
pub mod rules_pl; |
||||||
|
|
||||||
#[cfg(test)] |
#[cfg(test)] |
||||||
mod encoder_tests; |
mod encoder_tests; |
||||||
@ -1,8 +1,8 @@ |
|||||||
use crate::core::entities::{DictEntries, DictEntry}; |
use super::encoder::{Rule, Rules}; |
||||||
|
|
||||||
pub fn get_dict() -> DictEntries { |
pub fn get_rules() -> Rules { |
||||||
vec![ |
vec![ |
||||||
DictEntry { |
Rule { |
||||||
phoneme_in: "EN".to_string(), |
phoneme_in: "EN".to_string(), |
||||||
phoneme_out: "2".to_string(), |
phoneme_out: "2".to_string(), |
||||||
not_after: vec!["Y".to_string()], |
not_after: vec!["Y".to_string()], |
||||||
@ -1,8 +1,8 @@ |
|||||||
use crate::core::entities::{DictEntries, DictEntry}; |
use super::encoder::{Rule, Rules}; |
||||||
|
|
||||||
pub fn get_dict() -> DictEntries { |
pub fn get_rules() -> Rules { |
||||||
vec![ |
vec![ |
||||||
DictEntry { |
Rule { |
||||||
phoneme_in: "PL".to_string(), |
phoneme_in: "PL".to_string(), |
||||||
phoneme_out: "2".to_string(), |
phoneme_out: "2".to_string(), |
||||||
not_after: vec!["Y".to_string()], |
not_after: vec!["Y".to_string()], |
||||||
@ -1,15 +1,30 @@ |
|||||||
|
use serde::Deserialize; |
||||||
|
|
||||||
use crate::core::SystemEncoder; |
use crate::core::SystemEncoder; |
||||||
use crate::core::major; |
use crate::core::sys_major as major; |
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] |
||||||
pub enum System { |
pub enum System { |
||||||
|
#[serde(rename = "major_en")] |
||||||
MajorEn, |
MajorEn, |
||||||
|
#[serde(rename = "major_pl")] |
||||||
MajorPl, |
MajorPl, |
||||||
} |
} |
||||||
|
|
||||||
|
// from:
|
||||||
|
impl From<&str> for System { |
||||||
|
fn from(s: &str) -> Self { |
||||||
|
match s { |
||||||
|
"major_en" => System::MajorEn, |
||||||
|
"major_pl" => System::MajorPl, |
||||||
|
_ => panic!("Unknown system: {}", s), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
pub fn create_encoder(system: &System) -> Box<dyn SystemEncoder> { |
pub fn create_encoder(system: &System) -> Box<dyn SystemEncoder> { |
||||||
match system { |
match system { |
||||||
System::MajorPl => Box::new(major::Encoder::new(major::dict_pl::get_dict())), |
System::MajorPl => Box::new(major::Encoder::new(major::rules_pl::get_rules())), |
||||||
System::MajorEn => Box::new(major::Encoder::new(major::dict_en::get_dict())), |
System::MajorEn => Box::new(major::Encoder::new(major::rules_en::get_rules())), |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,18 +1,15 @@ |
|||||||
use crate::core::entities::{Dict, WordEntry, WordEntryId}; |
|
||||||
use crate::core::errors::RepositoryError; |
|
||||||
|
|
||||||
pub trait SystemEncoder { |
pub trait SystemEncoder { |
||||||
fn encode(&self, word: &str) -> String; |
fn encode(&self, word: &str) -> String; |
||||||
} |
} |
||||||
|
|
||||||
pub trait WordRepository { |
// pub trait WordRepository {
|
||||||
fn save(word: &WordEntry) -> Result<WordEntryId, RepositoryError>; |
// fn save(word: &WordEntry) -> Result<WordEntryId, RepositoryError>;
|
||||||
fn save_many(words: &Vec<WordEntry>) -> Result<(), RepositoryError>; |
// fn save_many(words: &Vec<WordEntry>) -> Result<(), RepositoryError>;
|
||||||
fn fetch(id: WordEntryId) -> Result<WordEntry, RepositoryError>; |
// fn fetch(id: WordEntryId) -> Result<WordEntry, RepositoryError>;
|
||||||
fn fetch_many(ids: &Vec<WordEntryId>) -> Result<Vec<WordEntry>, RepositoryError>; |
// fn fetch_many(ids: &Vec<WordEntryId>) -> Result<Vec<WordEntry>, RepositoryError>;
|
||||||
} |
// }
|
||||||
|
|
||||||
pub trait DictRepository { |
// pub trait DictRepository {
|
||||||
fn save(dict: &Dict) -> Result<(), RepositoryError>; |
// fn save(dict: &Dict) -> Result<(), RepositoryError>;
|
||||||
fn fetch(name: &str) -> Result<Dict, RepositoryError>; |
// fn fetch(name: &str) -> Result<Dict, RepositoryError>;
|
||||||
} |
// }
|
||||||
|
|||||||
@ -0,0 +1,45 @@ |
|||||||
|
use crate::application::ports::DictSource; |
||||||
|
use crate::core::entities::{DictEntry, DictEntryId}; |
||||||
|
use serde::Deserialize; |
||||||
|
use std::fs::File; |
||||||
|
use std::io::BufReader; |
||||||
|
use std::path::Path; |
||||||
|
|
||||||
|
// The "Wire Format".
|
||||||
|
// It exists ONLY here to map external JSON names to internal Entity names.
|
||||||
|
#[derive(Deserialize)] |
||||||
|
struct JsonEntry { |
||||||
|
id: u32, |
||||||
|
word: String, // "word" in JSON, "text" in Entity
|
||||||
|
// If JSON has extra fields we don't care about, Serde ignores them.
|
||||||
|
} |
||||||
|
|
||||||
|
pub struct JsonFileDictSource { |
||||||
|
// Helper iterator from Serde
|
||||||
|
iter: |
||||||
|
serde_json::StreamDeserializer<'static, serde_json::de::IoRead<BufReader<File>>, JsonEntry>, |
||||||
|
} |
||||||
|
|
||||||
|
impl JsonFileDictSource { |
||||||
|
pub fn new<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> { |
||||||
|
let file = File::open(path)?; |
||||||
|
let reader = BufReader::new(file); |
||||||
|
let iter = serde_json::Deserializer::from_reader(reader).into_iter::<JsonEntry>(); |
||||||
|
Ok(Self { iter }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl DictSource for JsonFileDictSource { |
||||||
|
fn next_entry(&mut self) -> Option<Result<DictEntry, anyhow::Error>> { |
||||||
|
self.iter.next().map(|res| { |
||||||
|
match res { |
||||||
|
Ok(json) => { |
||||||
|
// MAPPING HAPPENS HERE.
|
||||||
|
// This is type-safe. If DictEntry::new signature changes, this breaks.
|
||||||
|
Ok(DictEntry::new(json.id as DictEntryId, json.word)) |
||||||
|
} |
||||||
|
Err(e) => Err(anyhow::Error::new(e)), |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
@ -1,36 +1,9 @@ |
|||||||
// pub mod core;
|
mod application; |
||||||
|
|
||||||
mod core; |
mod core; |
||||||
|
pub mod infrastructure; |
||||||
|
mod presentation; |
||||||
|
|
||||||
pub use self::core::System; |
pub use self::application::config; |
||||||
pub use self::core::SystemEncoder; |
pub use self::core::system; |
||||||
pub use self::core::create_encoder; |
pub use self::core::traits::SystemEncoder; |
||||||
|
pub use self::presentation::cli; |
||||||
// pub struct TextProcessor {
|
|
||||||
// prefix: String,
|
|
||||||
// }
|
|
||||||
|
|
||||||
// impl TextProcessor {
|
|
||||||
// /// Create a new processor with custom prefix
|
|
||||||
// pub fn new(prefix: &str) -> Self {
|
|
||||||
// Self {
|
|
||||||
// prefix: prefix.to_string(),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// /// Process input text by adding prefix and converting to uppercase
|
|
||||||
// pub fn process(&self, input: &str) -> String {
|
|
||||||
// format!("{} {}", self.prefix, input.to_uppercase())
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[cfg(test)]
|
|
||||||
// mod tests {
|
|
||||||
// use super::*;
|
|
||||||
|
|
||||||
// #[test]
|
|
||||||
// fn test_processing() {
|
|
||||||
// let processor = TextProcessor::new(">> ");
|
|
||||||
// assert_eq!(processor.process("hello"), ">> HELLO");
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@ -0,0 +1,7 @@ |
|||||||
|
pub mod cli_args; |
||||||
|
pub mod commands; |
||||||
|
pub mod defaults; |
||||||
|
|
||||||
|
pub use self::cli_args::CliArgs; |
||||||
|
pub use self::cli_args::Command; |
||||||
|
pub use self::cli_args::GlobalArgs; |
||||||
@ -1,2 +1,2 @@ |
|||||||
pub mod export; |
pub mod encode; |
||||||
pub mod server; |
pub mod server; |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
use crate::application::config::EncoderConfig; |
||||||
|
use crate::core::system; |
||||||
|
use tracing::debug; |
||||||
|
|
||||||
|
pub async fn run(config: EncoderConfig) { |
||||||
|
debug!("Running greeter with config {:?}", config); |
||||||
|
let encoder = system::create_encoder(&config.system); |
||||||
|
let result = encoder.encode(&config.input); |
||||||
|
println!("{}", result); |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
use crate::application::config::ServerConfig; |
||||||
|
use tracing::info; |
||||||
|
|
||||||
|
pub async fn run(config: ServerConfig, blocker: impl std::future::Future<Output = ()>) { |
||||||
|
info!("Running server with config: {:#?}", config); |
||||||
|
|
||||||
|
tokio::select! { |
||||||
|
_ = server_loop() => {}, |
||||||
|
_ = blocker => { |
||||||
|
info!("Shutting down server..."); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async fn server_loop() { |
||||||
|
loop { |
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; |
||||||
|
info!("Health check... "); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue