From 532640058ec481e2592b3feb39b1637e0c95424a Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 4 Sep 2026 11:18:23 +0300 Subject: [PATCH] Rewrite history page --- sql/history.sql | 2 +- src/app.rs | 8 +- src/core/deck.rs | 26 +++--- src/data/entities.rs | 17 +++- src/data/repositories.rs | 21 +++-- src/data_provider/history.rs | 167 +++++++++++++++++------------------ src/navigation.rs | 57 +----------- src/pages/dictionary.rs | 4 +- src/pages/history.rs | 64 +++++++------- src/pages/import.rs | 63 ++++++------- src/pages/repetition.rs | 2 +- 11 files changed, 201 insertions(+), 230 deletions(-) diff --git a/sql/history.sql b/sql/history.sql index 9355ab2..e84074d 100644 --- a/sql/history.sql +++ b/sql/history.sql @@ -3,7 +3,7 @@ create table history id INTEGER primary key autoincrement, set_id INTEGER not null references decks on delete cascade, - stat_id INTEGER not null references states on delete cascade, + word_id INTEGER not null references words on delete cascade, timestamp INTEGER not null, answer INTEGER not null, before INTEGER not null, diff --git a/src/app.rs b/src/app.rs index 3534593..8d61352 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,10 @@ use std::sync::Arc; -use crate::data::Id; use crate::data_provider::decks::{DeckRepository}; use crate::data_provider::settings::get_setting; use crate::data_provider::sqlite::{SqliteConnection}; use crate::data_provider::words::{WordRepository}; use crate::navigation::RootMessage::Keyboard; use crate::navigation::{AppSettings, RootMessage, ScreenState}; -use chrono::NaiveDate; -use hashbrown::HashMap; use iced::{Subscription, keyboard, window}; use iced_core::window::Position; #[cfg(target_os = "linux")] @@ -18,6 +15,7 @@ use parking_lot::RwLock; use rusqlite::Connection; use crate::data::repositories::Repository; use crate::data_provider::card_states::CardStateRepository; +use crate::data_provider::history::HistoryRepository; use crate::data_provider::word_groups::WordGroupRepository; pub const USER_FONT: Font = Font::with_name("Noto Sans JP"); @@ -69,7 +67,7 @@ pub struct AppState { pub card_states: CardStateRepository, pub connection: Arc>, pub sync_data: Arc>, - pub activity: HashMap>, + pub history: HistoryRepository, } impl Default for AppState { @@ -86,11 +84,11 @@ impl AppState { decks: DeckRepository::new(connection.clone()), word_groups: WordGroupRepository::new(connection.clone()), card_states: CardStateRepository::new(connection.clone()), + history: HistoryRepository::new(connection.clone()), sync_data: Arc::new(RwLock::new(AppSettings { key: None, auto_web_fetch: false, })), - activity: Default::default(), connection, } } diff --git a/src/core/deck.rs b/src/core/deck.rs index 93184eb..e87a3c1 100644 --- a/src/core/deck.rs +++ b/src/core/deck.rs @@ -1,12 +1,11 @@ use crate::app::AppState; use crate::core::srs::OrderModule::{Fsrs, RandomSRS, SemiRandomSRS, WorstWordsSRS}; use crate::core::srs::{ - FsrsModule, OrderMode, OrderModule, RandomSRSModule, SRSModule, SemiRandomSRSModule, + OrderMode, OrderModule, RandomSRSModule, SRSModule, SemiRandomSRSModule, WordOpenMode, WorstWordsSRSModule, }; use crate::data::entities::*; -use crate::data_provider::card_states::{load_stats_of_deck, update_stat_score}; -use crate::data_provider::history::push_note; +use crate::data_provider::card_states::update_stat_score; use crate::notification::{MaybeError, NotificationData}; use chrono::Utc; @@ -116,16 +115,17 @@ impl DeckData { } } update_stat_score(word, &self.state.connection.read())?; - let _ = push_note( - self.settings.id, - HistoryItem { - timestamp: Utc::now(), - word_id: word.word_id, - mode: status, - before: old_score, - after: new_score, - }, - ); + todo!("Implement history pushing") + // let _ = push_note( + // self.settings.id, + // HistoryNote { + // timestamp: Utc::now(), + // word_id: word.word_id, + // answer: status, + // before: old_score, + // after: new_score, + // }, + // ); } Ok(()) diff --git a/src/data/entities.rs b/src/data/entities.rs index 8aab411..1d9b49a 100644 --- a/src/data/entities.rs +++ b/src/data/entities.rs @@ -62,12 +62,25 @@ impl WordData { } #[derive(Clone)] -pub struct HistoryItem { +pub struct HistoryNote { + pub id: Id, pub timestamp: DateTime, + pub set_id: Id, pub word_id: Id, - pub mode: WordOpenMode, + pub answer: WordOpenMode, pub before: u8, pub after: u8, + pub fsrs: bool +} + +impl EntityData for HistoryNote { + fn id(&self) -> Id { + self.id + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } } #[derive(Clone)] diff --git a/src/data/repositories.rs b/src/data/repositories.rs index 73b1fe5..0370ec5 100644 --- a/src/data/repositories.rs +++ b/src/data/repositories.rs @@ -188,7 +188,7 @@ impl, D: DbLayer> Qu ); } - pub fn handles(&self, data_repository: &DataRepositoryInner) -> impl Iterator> { + pub fn handles(&self) -> impl Iterator> { self.cache.iter().cloned() } @@ -216,6 +216,7 @@ where D: BatchedDbLayer, T: EntityData, { + fn create_now_many(&mut self, entities: &mut [T]) -> Result>, NotificationData>; fn save_many(&mut self, handles: &mut [EntityHandle]) -> MaybeError; fn delete_many(&mut self, handles: &mut [EntityHandle]) -> MaybeError; } @@ -236,11 +237,11 @@ where } #[derive(Clone)] -pub struct DataRepository> { +pub struct DataRepository> { inner: Arc>>, } -pub struct DataRepositoryInner> { +pub struct DataRepositoryInner> { cache: Vec, temporal: Vec, next_temporal: u32, @@ -251,7 +252,7 @@ pub struct DataRepositoryInner> shifts: usize, } -impl + std::default::Default> DataRepository { +impl + Default> DataRepository { pub fn new(connection: Arc>) -> DataRepository { Self{ inner: Arc::new(RwLock::new(DataRepositoryInner{ @@ -269,7 +270,7 @@ impl + std::default::Default> } -impl> Deref for DataRepository { +impl> Deref for DataRepository { type Target = Arc>>; fn deref(&self) -> &Self::Target { @@ -313,7 +314,7 @@ impl> DataRepositoryInner { pub fn query( &self, - rule: Arc bool + Send + Sync>, + rule: Arc bool + Send + Sync>, ) -> Query, D> where T: Send + Sync, @@ -536,6 +537,14 @@ impl> Repository for DataRepositoryIn impl + DbLayer> BatchedRepository for DataRepositoryInner { + fn create_now_many(&mut self, entities: &mut [T]) -> Result>, NotificationData> { + self.data_layer.add_many_entities(entities, &mut self.connection.write())?; + let len = self.cache.len(); + let handles = entities.iter().enumerate().map(|(index, entity)| {EntityHandle::new(entity.id(), index + len, true)}).collect(); + self.cache.extend(entities.to_vec()); + Ok(handles) + } + fn save_many(&mut self, handles: &mut [EntityHandle]) -> MaybeError { let mut temp = Vec::with_capacity(handles.len() / 2); let mut cached = Vec::with_capacity(handles.len() / 2); diff --git a/src/data_provider/history.rs b/src/data_provider/history.rs index c3cd324..f01c2e1 100644 --- a/src/data_provider/history.rs +++ b/src/data_provider/history.rs @@ -1,97 +1,92 @@ use crate::core::srs::WordOpenMode; -use crate::data::Id; -use crate::data::entities::HistoryItem; -use crate::fs::*; -use crate::notification::NotificationData::PermissionDenied; -use crate::notification::{MaybeError, NotificationData}; -use chrono::{DateTime, NaiveDate}; -use rusqlite::Connection; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; +use crate::data::entities::HistoryNote; +use crate::data::repositories::{DataRepository, DataRepositoryInner}; +use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet}; +use rusqlite::types::{FromSql, FromSqlResult, ToSqlOutput, ValueRef}; +use rusqlite::{Error, Params, Row, ToSql}; -pub fn get_open_count_of_set( - id: Id, - connection: &Connection, -) -> Result, NotificationData> { - todo!() -} +pub type HistoryRepository = DataRepository; +pub type HistoryRepositoryInner = DataRepositoryInner; +pub type HistoryDataLayer = SqliteDbLayer; -pub fn get_history_of_set(id: Id) -> Result, NotificationData> { - let app_dir = history_dir()?; - let head = app_dir.clone().join(format!("set_{}_history.csv", id)); - let mut lines = Vec::new(); - - append_lines_if_exists(head, &mut lines)?; - lines.reverse(); - Ok(parse_history_items(lines)) -} - -fn append_lines_if_exists(path: PathBuf, lines: &mut Vec) -> MaybeError { - if path.exists() { - let reader = BufReader::new(File::open(path).map_err(|_| PermissionDenied)?); - reader - .lines() - .map_while(Result::ok) - .for_each(|i| lines.push(i)); +#[derive(Clone)] +pub struct HistoryQuerySet; +impl TableAccessQuerySet for HistoryQuerySet { + fn load() -> &'static str { + "SELECT rowid, set_id, state_id, timestamp, answer, before, after, fsrs FROM history" + } + + fn save() -> &'static str { + todo!("History is readonly") + } + + fn add() -> &'static str { + "INSERT INTO history (set_id, state_id, timestamp, answer, before, after, fsrs) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + } + + fn name() -> &'static str { + "history" + } + + fn from_sql(row: &Row) -> Result { + Ok(HistoryNote { + id: row.get(0)?, + timestamp: row.get(3)?, + set_id: row.get(1)?, + word_id: row.get(2)?, + answer: row.get(4)?, + before: row.get(5)?, + after: row.get(6)?, + fsrs: row.get(7)?, + }) + } + + fn insert_params(entity: &HistoryNote) -> impl Params { + ( + entity.set_id, + entity.word_id, + entity.timestamp, + entity.answer, + entity.before, + entity.after, + entity.fsrs, + ) + } + + fn update_params(entity: &HistoryNote) -> impl Params { + ( + entity.set_id, + entity.word_id, + entity.timestamp, + entity.answer, + entity.before, + entity.after, + entity.fsrs, + ) } - Ok(()) } -fn parse_history_items(strings: Vec) -> Vec { - let mut items = Vec::with_capacity(strings.len()); - for string in strings { - if let [time, word, mode, before, after] = string.split(';').collect::>()[..] { - let id = word.parse::(); - if id.is_err() { - continue; +impl FromSql for WordOpenMode { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + if let ValueRef::Integer(s) = value { + return match s { + 1 => Ok(WordOpenMode::Hard), + 2 => Ok(WordOpenMode::Ok), + 3 => Ok(WordOpenMode::Easy), + _ => Ok(WordOpenMode::None), } - let id = id.unwrap_or_default(); - if !id.is_valid() { - continue; - } - items.push(HistoryItem { - timestamp: DateTime::from_timestamp(time.parse().unwrap_or_default(), 0) - .unwrap_or_default(), - word_id: id, - mode: match mode.parse::().unwrap_or_default() { - 2 => WordOpenMode::Hard, - 3 => WordOpenMode::Ok, - 4 => WordOpenMode::Easy, - _ => WordOpenMode::None, - }, - before: before.parse().unwrap_or_default(), - after: after.parse().unwrap_or_default(), - }); } + Ok(WordOpenMode::None) } - - items } -pub fn push_note(set_id: Id, item: HistoryItem) -> MaybeError { - let app_dir = history_dir()?; - let path = app_dir.clone().join(format!("set_{}_history.csv", set_id)); - - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|_| PermissionDenied)?; - let line_str = format!( - "{};{};{};{};{}", - item.timestamp.timestamp(), - item.word_id, - match item.mode { - WordOpenMode::Easy => 4, - WordOpenMode::Ok => 3, - WordOpenMode::Hard => 2, - WordOpenMode::None => 1, - }, - item.before, - item.after - ); - writeln!(&mut file, "{}", line_str).map_err(|_| PermissionDenied)?; - Ok(()) -} +impl ToSql for WordOpenMode { + fn to_sql(&self) -> rusqlite::Result> { + Ok(ToSqlOutput::from(match self { + WordOpenMode::Easy => 3, + WordOpenMode::Ok => 2, + WordOpenMode::Hard => 1, + WordOpenMode::None => 0 + })) + } +} \ No newline at end of file diff --git a/src/navigation.rs b/src/navigation.rs index acd7636..7b41f7e 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -1,15 +1,12 @@ -use crate::app::{AppState, fill_state}; +use crate::app::{fill_state, AppState}; use crate::data::Id; -use crate::data_provider::history::get_history_of_set; -use crate::data_provider::sqlite::{create_db, default_connection, SqliteConnection}; +use crate::data_provider::sqlite::{create_db, SqliteConnection}; use crate::data_provider::web_api::{ get_local_version, get_web_version, load_data, set_local_version, }; use crate::fs::*; use crate::navigation::Page::*; -use crate::navigation::RootMessage::{ - DataLoaded, Keyboard, Notification, PushNotification, UpdateData, -}; +use crate::navigation::RootMessage::{Keyboard, Notification, PushNotification, UpdateData}; use crate::notification::NotificationData::PermissionDenied; use crate::notification::{ NotificationData, NotificationManager, NotificationMessage, NotificationRequest, @@ -31,16 +28,11 @@ use crate::state_navigate_handle; use crate::state_update; use crate::view_navigation; use crate::{message_navigation, push_error, push_error_task}; -use chrono::NaiveDate; -use hashbrown::HashMap; use iced::keyboard::Event; use iced::widget::stack; use iced::{Element, Task}; use iced_core::Length::Fill; use log::{debug, info}; -use parking_lot::Mutex; -use rusqlite::Connection; -use std::sync::Arc; use std::time::Instant; impl Default for ScreenState { @@ -71,7 +63,6 @@ pub enum RootMessage { RepetitionSettings(RepetitionSettingsMessage), Import(ImportMessage), Keyboard(Event), - DataLoaded(HashMap>), PushNotification(NotificationRequest), Notification(NotificationMessage), None, @@ -108,11 +99,8 @@ impl ScreenState { create_db(); info!("Db created"); let state = ScreenState::default(); - let reading_additional_task = Task::perform(Self::load_additional_data(), |data| { - data.unwrap_or_else(|err| push_error!(err)) - }); - let mut final_task = reading_additional_task; + let mut final_task = Task::none(); let state_guard = &state.app_state; if state_guard.sync_data.read().auto_web_fetch && let Some(key) = state_guard.sync_data.read().key.clone() @@ -136,38 +124,6 @@ impl ScreenState { (state, final_task) } - async fn load_additional_data() -> Result { - let directory = history_dir()?; - - let mut map = HashMap::new(); - for file in directory - .read_dir() - .map_err(|_| PermissionDenied)? - .filter_map(Result::ok) - { - let mut vec = vec![]; - let history_file_name = match file.file_name().into_string() { - Err(_) => continue, - Ok(v) => v, - }; - - if let Ok(id) = history_file_name.split('_').collect::>()[1].parse::() - && let Ok(history) = get_history_of_set(id) - { - let by_date = history.chunk_by(|x, x1| { - x.timestamp.naive_local().date() == x1.timestamp.naive_local().date() - }); - for group in by_date { - vec.push((group[0].timestamp.naive_local().date(), group.len() as u32)); - } - map.insert(id, vec); - } else { - continue; - } - } - Ok(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?; @@ -189,11 +145,6 @@ impl ScreenState { return self.handle_keyboard(e); } - if let DataLoaded(data) = message { - self.app_state.activity = data; - return Task::none(); - } - if let UpdateData = message { info!("Loading data"); let state = &mut self.app_state; diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index 6569884..a172d84 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -81,7 +81,7 @@ impl NavigatedPage for DictionaryState { let dict = &self.state.dictionary.read(); let mut query = dict.query(Arc::new(|_| true)); - let handles = query.handles(dict); + let handles = query.handles(); words = handles.filter(|h| self.included.contains(h)).collect(); return Some(Page::DictionaryQuiz(DictionaryQuizState::new( @@ -552,7 +552,7 @@ impl DictionaryState { let groups = &self.state.word_groups.read(); - for mut group in self.groups_query.handles(groups) { + for mut group in self.groups_query.handles() { let group_name = groups.get(&mut group).unwrap().name.clone(); row = row.push( button(text!("{}", group_name)) diff --git a/src/pages/history.rs b/src/pages/history.rs index 3770c21..dbb2382 100644 --- a/src/pages/history.rs +++ b/src/pages/history.rs @@ -1,22 +1,20 @@ use crate::app::AppState; -use crate::data::INVALID_ID; -use crate::data::entities::{HistoryItem, WordData}; -use crate::data_provider::history::get_history_of_set; +use crate::data::entities::{HistoryNote, WordData}; +use crate::data::repositories::Repository; use crate::navigation::*; use crate::styling::*; -use HistoryMessage::Back; use chrono::Local; use iced::alignment::Horizontal::Center; use iced::widget::space::horizontal; use iced::widget::*; use iced::{Element, Fill, FillPortion, Task}; -use parking_lot::Mutex; use std::sync::Arc; +use HistoryMessage::Back; #[derive(Clone)] pub struct HistoryState { - list: Vec, - words: Vec, + list: Vec<(WordData, HistoryNote)>, + pub state: AppState, } impl NavigatedPage for HistoryState { @@ -39,9 +37,9 @@ impl NavigatedPage for HistoryState { .width(FillPortion(5)), horizontal().width(FillPortion(1)) ] - .height(Fill) - .width(Fill) - .into(), + .height(Fill) + .width(Fill) + .into(), Back, ) } @@ -49,56 +47,60 @@ impl NavigatedPage for HistoryState { impl HistoryState { pub fn new(id: crate::data::Id, state: AppState) -> Self { - let state = state.lock(); - let history = get_history_of_set(id).unwrap_or_default(); + let dict = &state.dictionary.read(); + let history_repo = &mut state.history.write(); + history_repo.init().expect("TODO: panic message"); + let id = id.clone(); + let mut history = history_repo.query(Arc::new(move |h| h.set_id == id)); let words = history - .iter() + .iter(history_repo) .map( - |item| match state.dictionary.iter().find(|w| w.id == item.word_id) { - None => WordData { - id, + |item| {let handle = dict.handle_by_id(item.word_id); + let word = dict.get_static(handle).unwrap_or(WordData { + id: Default::default(), + group_id: Default::default(), key: "Удаленное слово".to_string(), - value: "Удаленное слово".to_string(), - tags: "Удаленное слово".to_string(), + value: "".to_string(), + tags: "".to_string(), additional: Default::default(), - group_id: INVALID_ID, - }, - Some(word) => word.clone(), - }, + }); + (word, item.clone()) + } ) .collect(); - Self { - list: history, - words, + list: words, + state: state.clone(), } } fn history_lines(&self) -> Column<'_, HistoryMessage> { let mut column = Column::new(); - for (index, item) in self.list.iter().enumerate() { + for (index, item) in &self.list { + + let activity = item; column = column.push( row![ iced::widget::column![ - text!("{}", self.words[index].key) + text!("{}", index.key) .width(Fill) .align_x(Center), - text!("{}", self.words[index].value) + text!("{}", index.value) .width(Fill) .align_x(Center), ] .align_x(Center), iced::widget::column![ - text!("{} ➞ {}", item.before, item.after), + text!("{} ➞ {}", activity.before, activity.after), text!( "{}", - item.timestamp.with_timezone(&Local).format("%d.%m %H:%M") + activity.timestamp.with_timezone(&Local).format("%d.%m %H:%M") ) ] .spacing(5) .align_x(Center) ] - .spacing(5), + .spacing(5), ); column = column.push(rule::horizontal(1)); } diff --git a/src/pages/import.rs b/src/pages/import.rs index 1c18ab5..b5900f9 100644 --- a/src/pages/import.rs +++ b/src/pages/import.rs @@ -1,5 +1,5 @@ use crate::app::AppState; -use crate::data::entities::{WordData, WordGroup}; +use crate::data::entities::{EntityData, WordData, WordGroup}; use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups}; use crate::data_provider::words::{add_group, add_words}; use crate::fs::*; @@ -29,6 +29,7 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::task::spawn_blocking; use zip::ZipArchive; +use crate::data::repositories::{BatchedRepository, Repository}; const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"]; @@ -431,29 +432,18 @@ impl ImportState { info!("{}", group.name); Task::perform( async move { - spawn_blocking(move || { + let words_list = spawn_blocking(move || { let temp_file_path = temp_import_path(); - let connection = - Connection::open(&temp_file_path).map_err(|_| OpenFileError)?; + let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?; let import_list = get_words_of_group(&connection, group.id)?; let mut words_list = Vec::with_capacity(import_list.len()); let map_indices = Self::get_mapping_indices(&group); - let mut group_entity = WordGroup { - id: 0.into(), - name: group.name.clone(), - }; - { - let state = state.lock(); - add_group(&mut group_entity, &state.connection)?; - } - let group_id = group_entity.id; for import in import_list { let mut word = WordData::new(); word.tags = import.tags.trim().replace(" ", ", "); - word.group_id = group_id; for (dest, indices) in &map_indices { let collected_string = Self::collect_strings( &import.fields, @@ -480,24 +470,37 @@ impl ImportState { words_list.push(word); } - let mut state = state.lock(); - let connection = &mut state.connection; - let mut index = 0; - let total_len = words_list.len() as f32 / 1024.0; - for word in &mut words_list.chunks_mut(1024) { - add_words(word, connection)?; - index += 1; - let mut progress = progress.lock(); - *progress = (index as f32 / total_len).min(1.0); - } - - state.dictionary.append(&mut words_list); - state.word_groups.push(group_entity); - - Ok(()) + Ok::<_, NotificationData>(words_list) }) .await - .unwrap() + .unwrap()?; + + let group_handle = { + let group_entity = WordGroup { + id: 0.into(), + name: group.name.clone(), + }; + let mut groups_repo = state.word_groups.write(); + groups_repo.create_now(group_entity)? + }; + + let group_id = group_handle.id; + let mut words_list = words_list; + for word in &mut words_list { + word.group_id = group_id; + } + + let mut words_repo = state.dictionary.write(); + let mut index = 0; + let total_len = words_list.len() as f32 / 1024.0; + for words in &mut words_list.chunks_mut(1024) { + words_repo.create_now_many(words)?; + index += 1; + let mut progress = progress.lock(); + *progress = (index as f32 / total_len).min(1.0); + } + + Ok(()) }, |r: Result<(), NotificationData>| match r { Ok(_) => RootMessage::Import(ImportFinished), diff --git a/src/pages/repetition.rs b/src/pages/repetition.rs index e40e5f9..560699b 100644 --- a/src/pages/repetition.rs +++ b/src/pages/repetition.rs @@ -167,7 +167,7 @@ impl RepetitionState { } fn increment_counter(&mut self) { - let mut state = self.state.lock(); + let mut state = self.state; let id = self.settings.id; let activity = state.activity.get_mut(&id);