From a21fd5e69bcf501c53052029fe973dd4027e290a Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Thu, 13 Aug 2026 09:06:22 +0300 Subject: [PATCH] Autosync first edition --- src/data_provider/mod.rs | 1 + src/data_provider/sqlite.rs | 8 +++ src/data_provider/web_api.rs | 108 +++++++++++++++++++++++++++++++++++ src/main.rs | 22 ++++--- src/navigation.rs | 97 +++++++++++++++++++++++-------- src/sync.rs | 93 ++++++------------------------ 6 files changed, 218 insertions(+), 111 deletions(-) create mode 100644 src/data_provider/web_api.rs diff --git a/src/data_provider/mod.rs b/src/data_provider/mod.rs index 274a229..4a507ae 100644 --- a/src/data_provider/mod.rs +++ b/src/data_provider/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod settings; pub(crate) mod sqlite; pub(crate) mod voice; pub(crate) mod words; +pub(crate) mod web_api; diff --git a/src/data_provider/sqlite.rs b/src/data_provider/sqlite.rs index ef20c1e..c91dc5e 100644 --- a/src/data_provider/sqlite.rs +++ b/src/data_provider/sqlite.rs @@ -55,3 +55,11 @@ fn make_card_stats(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute(query, ())?; Ok(()) } + +pub fn default_connection() -> Connection { + let path = app_data_dir(); + let db_file = path.join("data.db"); + let connection = Connection::open(db_file).unwrap(); + connection.execute("PRAGMA foreign_keys = ON;", []).unwrap(); + connection +} \ No newline at end of file diff --git a/src/data_provider/web_api.rs b/src/data_provider/web_api.rs new file mode 100644 index 0000000..0b2f4c2 --- /dev/null +++ b/src/data_provider/web_api.rs @@ -0,0 +1,108 @@ +use std::io; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; +use zstd::{Decoder, Encoder, DEFAULT_COMPRESSION_LEVEL}; +use crate::dictionary::app_data_dir; + +const API_URL: &str = "http://127.0.0.1:8089/"; +// const API_URL: &str = "https://learning.micialware.ru/"; + +pub 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/stream/{id}"); + + let client = reqwest::Client::new(); + let new_version = client.post(&id_url).body(data).send().await.unwrap().text().await.unwrap(); + set_local_version(new_version.parse::().unwrap()).await; +} + +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 +} + +pub async fn load_data(id: String, temp: bool) { + let api_url = API_URL.to_string(); + let id_url = format!("{api_url}download/{id}"); + let client = reqwest::Client::new(); + let data = client + .get(&id_url) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() + .to_vec(); + let data = decompress(data); + + let path = app_data_dir(); + let file_name = if temp { "data.db.tmp" } else { "data.db" }; + let db_file = path.join(file_name); + + tokio::fs::write(db_file, data).await.unwrap(); + + let version_url = format!("{API_URL}{id}/version"); + let version = client.get(&version_url).send().await.unwrap().text().await.unwrap(); + set_local_version(version.parse::().unwrap()).await; +} + +pub fn decompress(data: Vec) -> Vec { + let mut decoder = Decoder::new(&data[..]).unwrap(); + let mut decompressed = Vec::new(); + let res = io::copy(&mut decoder, &mut decompressed); + if let Err(e) = res { + println!("{}", e); + } + decompressed +} + +pub 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 +} + +pub async fn get_web_version(key: &str) -> Result { + let id_url = format!("{API_URL}{key}/version"); + let client = reqwest::Client::new(); + let version = client.get(&id_url).send().await?.text().await?; + return Ok(version.parse::().unwrap()); +} + +pub async fn get_local_version() -> u32 { + let file = app_data_dir().join("data.meta"); + if file.exists() { + let mut data = String::new(); + File::open(file.clone()).await.unwrap().read_to_string(&mut data).await.unwrap(); + return data.parse::().unwrap(); + } + + let mut file = OpenOptions::new().write(true).create(true).open(file).await.unwrap(); + file.write_all("0".as_bytes()).await.unwrap(); + 0 +} + +pub async fn set_local_version(version: u32) { + let file_path = app_data_dir().join("data.meta"); + let mut file = OpenOptions::new().write(true).create(true).truncate(true).open(file_path).await.unwrap(); + file.write_all(version.to_string().as_bytes()).await.unwrap(); +} + diff --git a/src/main.rs b/src/main.rs index c592996..c138550 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,4 @@ -//#![windows_subsystem = "windows"] -use mimalloc::MiMalloc; - -#[global_allocator] -static GLOBAL: MiMalloc = MiMalloc; +#![windows_subsystem = "windows"] mod data_provider; mod dictionary; mod dictionary_test; @@ -36,7 +32,11 @@ use iced::{keyboard, Subscription, Theme}; use iced_core::Size; use iced_core::window::Position; use rusqlite::Connection; +use mimalloc::MiMalloc; +use crate::data_provider::sqlite::default_connection; +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; const USER_FONT: Font = Font::with_name("Noto Sans JP"); fn main() -> iced::Result { @@ -72,17 +72,14 @@ pub struct AppState { impl AppState { pub fn new() -> Self { - let path = app_data_dir(); - let db_file = path.join("data.db"); - let connection = Connection::open(db_file).unwrap(); - connection.execute("PRAGMA foreign_keys = ON;", []).unwrap(); + Self { dictionary: vec![], card_sets: vec![], word_groups: vec![], - connection, - sync_data: AppSettings { key: None }, + connection: default_connection(), + sync_data: AppSettings { key: None, auto_web_fetch: false }, activity: Default::default(), } } @@ -102,5 +99,6 @@ fn fill_state(state: &mut AppState) { fn load_settings(connection: &Connection) -> AppSettings { let key = get_setting("SYNC_KEY".to_string(), connection); - AppSettings { key } + let fetch = get_setting("AUTO_WEB_FETCH".to_string(), connection).unwrap_or("false".to_string()).parse::().unwrap(); + AppSettings { key, auto_web_fetch: fetch } } diff --git a/src/navigation.rs b/src/navigation.rs index 3651ead..eb861d2 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -1,27 +1,29 @@ -use crate::state_navigate_handle; use crate::data_provider::history::{get_history_of_set, history_dir}; -use crate::data_provider::sqlite::create_db; -use crate::dictionary::{DictionaryMessage, DictionaryState}; +use crate::data_provider::sqlite::{create_db, default_connection}; +use crate::data_provider::web_api::{get_local_version, get_web_version, load_data, set_local_version}; +use crate::dictionary::{app_data_dir, DictionaryMessage, DictionaryState}; use crate::dictionary_test::{DictionaryQuizMessage, DictionaryQuizState}; use crate::history::{HistoryMessage, HistoryState}; use crate::message_navigation; use crate::navigation::Page::*; -use crate::navigation::RootMessage::{DataLoaded, Keyboard}; +use crate::navigation::RootMessage::{DataLoaded, Keyboard, UpdateData}; use crate::quiz::{QuizMessage, QuizState}; use crate::randomizer::{RandomizerMessage, RandomizerState}; use crate::repetition::{RepetitionMessage, RepetitionState}; use crate::repetition_settings::{RepetitionSettingsMessage, RepetitionSettingsState}; use crate::repetitions::{RepetitionsMessage, RepetitionsState}; use crate::selector::{SelectorMessage, SelectorState}; +use crate::state_navigate_handle; use crate::state_update; use crate::sync::{SyncMessage, SyncState}; use crate::view_navigation; use crate::word::{WordMessage, WordState}; use crate::writing::{WritingMessage, WritingState}; -use crate::{fill_state, AppState}; +use crate::{AppState, fill_state}; use chrono::NaiveDate; use iced::keyboard::Event; use iced::{Element, Task}; +use reqwest::Error; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -54,6 +56,8 @@ pub enum RootMessage { RepetitionSettings(RepetitionSettingsMessage), Keyboard(Event), DataLoaded(HashMap>), + None, + UpdateData, } pub enum Page { @@ -81,10 +85,29 @@ pub struct ScreenState { impl ScreenState { pub fn boot() -> (ScreenState, Task) { create_db(); - ( - ScreenState::default(), - Task::perform(Self::load_additional_data(), |data| data), - ) + let state = ScreenState::default(); + let reading_additional_task = Task::perform(Self::load_additional_data(), |data| data); + + let final_task; + let state_guard = state.app_state.lock().unwrap(); + if state_guard.sync_data.auto_web_fetch { + let key = state_guard.sync_data.key.clone().unwrap(); + final_task = Task::batch([ + reading_additional_task, + Task::perform(Self::load_web_backup(key), |result| { + if let Ok(update) = result && update { + UpdateData + } else { + RootMessage::None + } + }), + ]); + } else { + final_task = reading_additional_task; + } + + drop(state_guard); + (state, final_task) } async fn load_additional_data() -> RootMessage { @@ -111,6 +134,19 @@ impl ScreenState { DataLoaded(map) } + async fn load_web_backup(string: String) -> Result { + let local = get_local_version().await; + let web = get_web_version(string.as_str()).await?; + if web > local { + println!("Web version is newer than local version"); + load_data(string, true).await; + set_local_version(web).await; + }else { + return Ok(false); + } + Ok(true) + } + pub fn update(&mut self, message: RootMessage) -> Task { let time = Instant::now(); if let Keyboard(e) = message { @@ -122,6 +158,18 @@ impl ScreenState { return Task::none(); } + if let UpdateData = message{ + println!("Loading data"); + let mut state = self.app_state.lock().unwrap(); + let path = app_data_dir(); + let db_file = path.join("data.db"); + let temp_db_file = path.join("data.db.tmp"); + std::fs::rename(temp_db_file, &db_file).unwrap(); + + state.connection = default_connection(); + fill_state(&mut state); + return Task::none(); + } let task: (Task, bool) = self.navigate_or_update(message); if task.1 { @@ -148,21 +196,21 @@ impl ScreenState { fn navigate_or_update(&mut self, message: RootMessage) -> (Task, bool) { state_update!( - message, - self.stack, - Selector, - Quiz, - Writing, - Dictionary, - DictionaryQuiz, - Randomizer, - Repetitions, - Repetition, - Word, - Sync, - History, - RepetitionSettings - ) + message, + self.stack, + Selector, + Quiz, + Writing, + Dictionary, + DictionaryQuiz, + Randomizer, + Repetitions, + Repetition, + Word, + Sync, + History, + RepetitionSettings + ) } pub fn view(&self) -> Element<'_, RootMessage> { @@ -198,6 +246,7 @@ impl ScreenState { pub struct AppSettings { pub(crate) key: Option, + pub(crate) auto_web_fetch: bool, } #[macro_export] diff --git a/src/sync.rs b/src/sync.rs index 084d2e9..bdaa1c9 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,5 +1,5 @@ use crate::data_provider::settings::{delete_settings, set_setting}; -use crate::dictionary::app_data_dir; +use crate::data_provider::web_api::*; use crate::navigation::Page::PreviousPage; use crate::navigation::{NavigatedPage, Page}; use crate::styling::*; @@ -7,14 +7,11 @@ use crate::sync::SyncMessage::*; use crate::{fill_state, AppState, RootMessage}; use iced::widget::button::danger; use iced::widget::container::rounded_box; -use iced::widget::{button, column, container, progress_bar, row, space, text}; +use iced::widget::{button, column, container, progress_bar, row, space, text, toggler}; use iced::{Center, Element, Fill, Font, Length, Task}; -use std::io; use std::sync::{Arc, Mutex}; use std::time::Duration; -use zstd::{Decoder, Encoder, DEFAULT_COMPRESSION_LEVEL}; -const API_URL: &str = "https://learning.micialware.ru/"; #[derive(Clone)] pub enum SyncMessage { @@ -30,10 +27,12 @@ pub enum SyncMessage { NetworkFinished, Disable, DisableSync, + SwitchAutoSync, } #[derive(Clone)] pub struct SyncState { state: Arc>, + auto_fetch: bool, frozen: bool, progress: f32, } @@ -49,13 +48,17 @@ impl NavigatedPage for SyncState { } } - fn navigated(&mut self) { - } + fn navigated(&mut self) {} } impl SyncState { pub fn new(state: Arc>) -> SyncState { + let auto_fetch; + { + auto_fetch = state.lock().unwrap().sync_data.auto_web_fetch; + } Self { + auto_fetch, state, frozen: false, progress: 0.0, @@ -108,7 +111,7 @@ impl SyncState { async { tokio::time::sleep(Duration::from_millis(200)).await }, |_| RootMessage::Sync(NextAnimation), ), - Task::perform(load_data(id), |_| RootMessage::Sync(NetworkFinished)), + Task::perform(load_data(id, false), |_| RootMessage::Sync(NetworkFinished)), ]); return tasks; @@ -144,6 +147,12 @@ impl SyncState { delete_settings("SYNC_KEY".to_string(), &state.connection); } + SwitchAutoSync => { + self.auto_fetch = !self.auto_fetch; + let mut state = self.state.lock().unwrap(); + state.sync_data.auto_web_fetch = self.auto_fetch; + set_setting("AUTO_WEB_FETCH".to_string(), self.auto_fetch.to_string(), &state.connection); + } } Task::none() } @@ -187,6 +196,7 @@ impl SyncState { button("↓ Скачать").style(jl_button).on_press(GetLast) ] .spacing(DEFAULT_SPACING), + toggler(self.auto_fetch).label("Автоматическая синхронизация").on_toggle(|_| SwitchAutoSync), container(network_view).width(Fill), button("Отключить синхронизацию") .style(danger) @@ -229,73 +239,6 @@ impl SyncState { } } -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 load_data(id: String) { - let api_url = API_URL.to_string(); - let id_url = format!("{api_url}download/{id}"); - let client = reqwest::Client::new(); - let data = client - .get(&id_url) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap() - .to_vec(); - let data = decompress(data); - - let path = app_data_dir(); - let db_file = path.join("data.db"); - tokio::fs::write(db_file, data).await.unwrap(); -} - -fn decompress(data: Vec) -> Vec { - let mut decoder = Decoder::new(&data[..]).unwrap(); - let mut decompressed = Vec::new(); - let res = io::copy(&mut decoder, &mut decompressed); - if let Err(e) = res { - println!("{}", e); - } - decompressed -} - -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())