From c0bd5582334cae45877b77f1cc74af16e20c0abd Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Mon, 1 Jun 2026 15:36:53 +0300 Subject: [PATCH] Server upload works --- .gitignore | 1 + Cargo.lock | 53 ++++++++ Cargo.toml | 6 +- src/data_provider/mod.rs | 1 + src/data_provider/settings.rs | 69 ++++++++++ src/data_provider/words.rs | 20 ++- src/main.rs | 28 ++++- src/repetitions.rs | 2 +- src/selector.rs | 8 +- src/sync.rs | 230 ++++++++++++++++++++++++++++++++++ 10 files changed, 405 insertions(+), 13 deletions(-) create mode 100644 src/data_provider/settings.rs create mode 100644 src/sync.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..2a0038a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.idea \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e900b7d..85bba23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1581,6 +1581,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -2092,6 +2098,7 @@ dependencies = [ "criterion", "dirs", "hex", + "hex-literal", "iced", "rand", "rayon", @@ -2103,6 +2110,7 @@ dependencies = [ "serde_json", "sha2", "tokio", + "zstd", ] [[package]] @@ -2352,6 +2360,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.1.1" @@ -3447,6 +3465,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -4591,6 +4610,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5936,6 +5961,34 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/Cargo.toml b/Cargo.toml index 3dda2bf..e7a44b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,14 @@ serde_json = "1.0.149" rhai = "1.24.0" chrono = "0.4.44" rusqlite = { version = "0.39.0", features = ["chrono"] } -reqwest = { version = "0", features = ["json", "stream"] } +reqwest = { version = "0", features = ["json", "stream", "multipart"] } rodio = "0.22.2" sha2 = "0.11.0" -hex = "0.4.3" tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "macros"] } rayon = "1.12.0" +hex = "0.4.3" +hex-literal = "1.1.0" +zstd = "0.13.3" [dev-dependencies] criterion = "0.5" diff --git a/src/data_provider/mod.rs b/src/data_provider/mod.rs index a5b57ca..54466a0 100644 --- a/src/data_provider/mod.rs +++ b/src/data_provider/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod words; pub(crate) mod card_sets; pub(crate) mod card_stats; pub(crate) mod voice; +pub(crate) mod settings; diff --git a/src/data_provider/settings.rs b/src/data_provider/settings.rs new file mode 100644 index 0000000..3e7aea0 --- /dev/null +++ b/src/data_provider/settings.rs @@ -0,0 +1,69 @@ +use rusqlite::fallible_iterator::FallibleIterator; +use rusqlite::Connection; + +pub fn get_setting(key: String, connection: &Connection) -> Option { + let mut stmt = connection.prepare("SELECT value FROM settings WHERE id = ?1").unwrap(); + let iter = stmt.query_map((key,), |row| { + row.get(0) + }).unwrap(); + + for row in iter { + if let Ok(value) = row { + return Some(value); + } + return None; + } + None +} + +pub fn set_setting(key: String, value: String, connection: &Connection) { + let current = get_settings_list(connection); + if current.contains(&key) { + update_settings(key, value, connection); + }else { + create_settings(key, value, connection); + } +} + +pub fn delete_settings(key: String, connection: &Connection) { + connection + .execute("DELETE FROM settings WHERE id = ?1", (&key,)) + .unwrap_or_else(|e| { + println!("{}", e); + 0 + }); +} + +fn create_settings(key: String, value: String, connection: &Connection) { + connection + .execute( + "INSERT into settings (id, value) VALUES (?1, ?2)", + (key, value), + ) + .unwrap_or_else(|e| { + println!("{}", e); + 0 + }); +} + +fn update_settings(key: String, value: String, connection: &Connection) { + connection + .execute( + "update settings +set value = ?2 +where id = ?1", + (key, value), + ) + .unwrap_or_else(|e| { + println!("{}", e); + 0 + });} + +fn get_settings_list(connection: &Connection) -> Vec { + let mut stmt = connection.prepare("SELECT id FROM settings").unwrap(); + let iter = stmt.query_map((), |row| { + row.get(0) + }).unwrap(); + iter.map(|row| { row.unwrap() }).collect() +} + diff --git a/src/data_provider/words.rs b/src/data_provider/words.rs index 5063965..9e9a45d 100644 --- a/src/data_provider/words.rs +++ b/src/data_provider/words.rs @@ -16,6 +16,16 @@ pub fn create_db() { } fn create_tables(conn: &Connection) { + conn.execute( + "create table settings +( + id text primary key, + value text +);", + (), + ) + .unwrap_or_else(|e| 0); + conn.execute( "create table card_set ( @@ -28,7 +38,7 @@ fn create_tables(conn: &Connection) { );", (), ) - .unwrap(); + .unwrap_or_else(|e| 0); conn.execute( "create table word_group ( @@ -38,7 +48,7 @@ fn create_tables(conn: &Connection) { );", (), ) - .unwrap(); + .unwrap_or_else(|e| 0); conn.execute( "create table words ( @@ -55,7 +65,7 @@ fn create_tables(conn: &Connection) { );", (), ) - .unwrap(); + .unwrap_or_else(|e| 0); conn.execute( "create table card_stats ( @@ -72,13 +82,13 @@ fn create_tables(conn: &Connection) { );", (), ) - .unwrap(); + .unwrap_or_else(|e| 0); conn.execute( "insert into word_group (name) values (\"Слова\");", (), ) - .unwrap(); + .unwrap_or_else(|e| 0); } pub fn add_word(word: &mut WordData, connection: &Connection) { diff --git a/src/main.rs b/src/main.rs index 1880723..c0099d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,12 @@ mod randomizer; mod repetition; mod repetitions; mod selector; +mod sync; mod word; mod writing; use crate::data_provider::card_sets::load_sets; +use crate::data_provider::settings::get_setting; use crate::data_provider::words::{create_db, load_word_groups, load_words}; use crate::dictionary::{app_data_dir, DictionaryMessage, DictionaryState}; use crate::dictionary_test::{DictionaryQuizMessage, DictionaryQuizState}; @@ -21,10 +23,12 @@ use crate::randomizer::randomizer::{RandomizerMessage, RandomizerState}; use crate::repetition::{RepetitionMessage, RepetitionState}; use crate::repetitions::{CardSetSettings, RepetitionsMessage, RepetitionsState}; use crate::selector::*; +use crate::sync::{SyncMessage, SyncState}; use crate::word::{WordMessage, WordState}; use crate::writing::{WritingMessage, WritingState}; use crate::Page::{ - Dictionary, DictionaryQuiz, Quiz, Randomizer, Repetition, Repetitions, Selector, Word, Writing, + Dictionary, DictionaryQuiz, Quiz, Randomizer, Repetition, Repetitions, Selector, Sync, Word, + Writing, }; use crate::RootMessage::Keyboard; use iced::keyboard::Event; @@ -60,6 +64,7 @@ pub enum RootMessage { Repetitions(RepetitionsMessage), Repetition(RepetitionMessage), Word(WordMessage), + Sync(SyncMessage), Keyboard(Event), } @@ -73,6 +78,7 @@ pub enum Page { Repetitions(RepetitionsState), Repetition(RepetitionState), Word(WordState), + Sync(SyncState), PreviousPage, } @@ -85,6 +91,7 @@ pub struct AppState { pub card_sets: Vec, pub word_groups: Vec, pub connection: Connection, + pub sync_data: AppSettings, } impl Default for ScreenState { @@ -96,12 +103,14 @@ impl Default for ScreenState { let list = load_words(&connection); let sets = load_sets(&connection); let groups = load_word_groups(&connection); + let setting = load_settings(&connection); let state = Arc::new(Mutex::new(AppState { dictionary: list, card_sets: sets, connection, word_groups: groups, + sync_data: setting, })); ScreenState { stack: vec![Selector(SelectorState::new(state.clone()))], @@ -109,6 +118,11 @@ impl Default for ScreenState { } } +fn load_settings(connection: &Connection) -> AppSettings { + let key = get_setting("SYNC_KEY".to_string(), connection); + AppSettings { key } +} + impl ScreenState { pub fn boot() -> (ScreenState, Task) { create_db(); @@ -130,7 +144,8 @@ impl ScreenState { Randomizer, Repetitions, Repetition, - Word + Word, + Sync ); Task::none() } @@ -145,7 +160,8 @@ impl ScreenState { Randomizer, Repetitions, Repetition, - Word + Word, + Sync ) } @@ -158,6 +174,10 @@ impl ScreenState { } } +pub struct AppSettings { + key: Option, +} + #[macro_export] macro_rules! view_navigation { ($stack:expr, $($e:ident), *) => { @@ -207,4 +227,4 @@ trait NavigatedPage { pub trait KeyPressedPage { fn press(&mut self, message: &Event) -> Task; -} \ No newline at end of file +} diff --git a/src/repetitions.rs b/src/repetitions.rs index ce39855..53176e2 100644 --- a/src/repetitions.rs +++ b/src/repetitions.rs @@ -193,7 +193,7 @@ impl RepetitionsState { column![ text!("Худшие слова"), container(scrollable(self.worst_words_list(&set)).height(200)).style(bordered_box), - radio("Начать с плохих слов", SetOrderMode::TrainWorstFirst, Some(set.open_mode), RepetitionsMessage::SetOpenMode), + // radio("Начать с плохих слов", SetOrderMode::TrainWorstFirst, Some(set.open_mode), RepetitionsMessage::SetOpenMode), radio("Полностью случайно", SetOrderMode::FullRandom, Some(set.open_mode), RepetitionsMessage::SetOpenMode) ] .spacing(DEFAULT_SPACING) diff --git a/src/selector.rs b/src/selector.rs index 9ffe98c..f0168c6 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -9,6 +9,7 @@ use crate::Page::{Quiz, Writing}; use crate::{AppState, NavigatedPage, Page, QuizState, RootMessage, DEFAULT_SPACING}; use iced::widget::*; use iced::{alignment, Element, Task}; +use crate::sync::SyncState; pub struct SelectorState { pub set: KanaSet, @@ -25,6 +26,7 @@ pub enum SelectorMessage { ToDictionary, ToRandomize, ToRepetitions, + ToSync, } impl NavigatedPage for SelectorState { @@ -48,6 +50,9 @@ impl NavigatedPage for SelectorState { if let SelectorMessage::ToRepetitions = message { return Some(Page::Repetitions(RepetitionsState::new(self.state.clone()))); } + if let SelectorMessage::ToSync = message { + return Some(Page::Sync(SyncState::new(self.state.clone()))); + } None } } @@ -80,7 +85,8 @@ impl SelectorState { button("Переключить азбуки").on_press(SelectorMessage::Change), button("Словарь").on_press(SelectorMessage::ToDictionary), button("Рандомайзер").on_press(SelectorMessage::ToRandomize), - button("Повторение").on_press(SelectorMessage::ToRepetitions) + button("Повторение").on_press(SelectorMessage::ToRepetitions), + button("Синхронизация").on_press(SelectorMessage::ToSync) ] .spacing(DEFAULT_SPACING), self.rows_selector(), diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..ca5feb3 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,230 @@ +use std::io; +use crate::data_provider::settings::set_setting; +use crate::sync::SyncMessage::NextAnimation; +use crate::Page::PreviousPage; +use crate::{AppState, NavigatedPage, Page, RootMessage, DEFAULT_SPACING}; +use iced::widget::container::rounded_box; +use iced::widget::{button, column, container, progress_bar, row, space, text}; +use iced::{Element, Fill, Font, Left, Task}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use zstd::{Encoder, DEFAULT_COMPRESSION_LEVEL}; +use crate::dictionary::app_data_dir; + +const API_URL: &str = "https://learning.micialware.ru/"; +/*const API_URL: &str = "http://localhost:8089/";*/ + +#[derive(Clone)] +pub enum SyncMessage { + Back, + CopyKey, + KeyCopied, + IdReceived(String), + InitSync, + GetKey, + Send, + GetLast, + NextAnimation, + NetworkFinished, +} +#[derive(Clone)] +pub struct SyncState { + state: Arc>, + frozen: bool, + progress: f32, +} + +impl NavigatedPage for SyncState { + fn navigate(&self, message: &SyncMessage) -> Option { + if let SyncMessage::Back = message + && !self.frozen + { + Some(PreviousPage) + } else { + None + } + } +} + +impl SyncState { + pub fn new(state: Arc>) -> SyncState { + Self { + state, + frozen: false, + progress: 0.0, + } + } + + pub fn update(&mut self, message: SyncMessage) -> Task { + match message { + SyncMessage::Back => {} + SyncMessage::CopyKey => { + let state = self.state.lock().unwrap(); + let key = state.sync_data.key.clone().unwrap(); + return iced::clipboard::write(key) + .map(|_val: String| RootMessage::Sync(SyncMessage::KeyCopied)); + } + SyncMessage::InitSync => { + return Task::perform(first_sync(), |id| { + RootMessage::Sync(SyncMessage::IdReceived(id)) + }); + } + SyncMessage::GetKey => { + return iced::clipboard::read().map(|key| { + RootMessage::Sync(SyncMessage::IdReceived(key.unwrap_or_else(String::new))) + }); + } + SyncMessage::KeyCopied => {} + SyncMessage::IdReceived(new_id) => { + if validate_id(&new_id) == false { + return Task::none(); + } + let mut state = self.state.lock().unwrap(); + state.sync_data.key = Some(new_id.clone()); + set_setting("SYNC_KEY".to_string(), new_id, &state.connection); + } + SyncMessage::Send => { + self.prepare_to_db_interaction(); + + let id = self.state.lock().unwrap().sync_data.key.clone().unwrap(); + let tasks = Task::batch([ + Task::perform( + async { tokio::time::sleep(Duration::from_millis(200)).await }, + |_| RootMessage::Sync(NextAnimation), + ), + Task::perform(send_data(id), |_| { + RootMessage::Sync(SyncMessage::NetworkFinished) + }), + ]); + + return tasks; + } + SyncMessage::GetLast => self.prepare_to_db_interaction(), + NextAnimation => { + self.progress += 1.0; + if self.progress > 5.0 { + self.progress = 0.0; + } + if self.frozen { + return Task::perform( + async { tokio::time::sleep(Duration::from_millis(200)).await }, + |_| RootMessage::Sync(NextAnimation), + ); + } + } + SyncMessage::NetworkFinished => { + self.frozen = false; + } + } + Task::none() + } + pub fn view(&self) -> Element<'_, SyncMessage> { + container( + column![ + button("Назад").on_press(SyncMessage::Back), + row![self.sync_column(), self.app_updater()].spacing(DEFAULT_SPACING) + ] + .align_x(Left) + .width(Fill) + .height(Fill) + .spacing(DEFAULT_SPACING), + ) + .center_x(Fill) + .padding(10) + .into() + } + + fn sync_column(&self) -> Element<'_, SyncMessage> { + let network_view: Element<'_, SyncMessage> = if self.frozen { + progress_bar(0.0..=5.0, self.progress).into() + } else { + space().into() + }; + if let Some(key) = self.state.lock().unwrap().sync_data.key.clone() { + column![ + text!("Ваш ключ синхронизации"), + container(container(text!("{}", key).size(20).font(Font::MONOSPACE)).padding(3)) + .style(rounded_box), + button("Скопировать в буффер обмена").on_press(SyncMessage::CopyKey), + row![ + button("↑ Отправить").on_press(SyncMessage::Send), + button("↓ Скачать").on_press(SyncMessage::GetLast) + ] + .spacing(DEFAULT_SPACING), + container(network_view).width(Fill), + ] + .spacing(DEFAULT_SPACING) + .width(Fill) + .into() + } else { + column![ + button("Создать сохранение").on_press(SyncMessage::InitSync), + button("Вставить ключ из буффера").on_press(SyncMessage::GetKey), + ] + .spacing(DEFAULT_SPACING) + .width(Fill) + .into() + } + } + + fn app_updater(&self) -> Element<'_, SyncMessage> { + column![].width(Fill).into() + } + + fn prepare_to_db_interaction(&mut self) { + if self.frozen { + return; + } + self.frozen = true; + self.progress = 0.0; + let state = self.state.lock().unwrap(); + state + .connection + .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) + .unwrap_or_else(|e| { + eprintln!("Ошибка при checkpoint WAL: {e}"); + }); + } +} + +async fn send_data(id: String) { + let path = app_data_dir(); + let db_file = path.join("data.db"); + let data = tokio::fs::read(db_file).await.unwrap(); + let data = compress(data); + + let api_url = API_URL.to_string(); + let id_url = format!("{api_url}upload/{id}"); + + let form = reqwest::multipart::Form::new().part("db", reqwest::multipart::Part::bytes(data)); + let client = reqwest::Client::new(); + client.post(&id_url).multipart(form).send().await.unwrap(); +} + +fn compress(data: Vec) -> Vec { + let mut encoder = Encoder::new(Vec::new(), DEFAULT_COMPRESSION_LEVEL).unwrap(); + io::copy(&mut &data[..], &mut encoder).unwrap(); + let compressed = encoder.finish().unwrap(); + compressed +} + +async fn first_sync() -> String { + let id_url = API_URL.to_string() + "generate"; + + let client = reqwest::Client::new(); + let id = client + .get(&id_url) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + println!("id: {}", id); + id +} + +#[inline] +fn validate_id(id: &str) -> bool { + id.len() == 24 && id.chars().all(|c| c.is_ascii_alphanumeric()) +}