More notifications and move start code to another file

This commit is contained in:
micialware committed 2026-08-23 21:49:01 +03:00
1 parent e1bfd9494d
commit 5a926b1419
17 files changed
+213 -171

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
use chrono::NaiveDate;
use hashbrown::HashMap;
use iced::{keyboard, window, Subscription};
use iced_core::{Font, Size, Theme};
use iced_core::window::Position;
use iced_core::window::settings::PlatformSpecific;
use rusqlite::Connection;
use crate::data_provider::card_sets::load_sets;
use crate::data_provider::settings::get_setting;
use crate::data_provider::sqlite::default_connection;
use crate::data_provider::words::{load_word_groups, load_words};
use crate::lang::{DeckSettings, Id, WordData, WordGroup};
use crate::navigation::{AppSettings, RootMessage, ScreenState};
use crate::navigation::RootMessage::Keyboard;
pub const USER_FONT: Font = Font::with_name("Noto Sans JP");
pub fn start() -> iced::Result {
println!("Welcome to JapLearn");
iced::application(ScreenState::boot, ScreenState::update, ScreenState::view)
.window(window_settings())
.subscription(subscription)
.title("JapLearn")
.settings(iced::Settings {
default_text_size: iced::Pixels(18.0),
..iced::Settings::default()
})
.font(include_bytes!("../noto.ttf"))
.default_font(USER_FONT)
.theme(Theme::GruvboxDark)
.run()
}
#[allow(unused_mut)]
fn window_settings() -> window::Settings {
let mut settings = window::Settings {
position: Position::Centered,
min_size: Some(Size::new(700.0_f32, 700.0_f32)),
..Default::default()
};
#[cfg(target_os = "linux")]
{
settings.platform_specific = PlatformSpecific {
application_id: "JapLearn".to_string(),
override_redirect: false,
};
}
settings
}
fn subscription(_state: &ScreenState) -> Subscription<RootMessage> {
keyboard::listen().map(Keyboard)
}
pub struct AppState {
pub dictionary: Vec<WordData>,
pub decks: Vec<DeckSettings>,
pub word_groups: Vec<WordGroup>,
pub connection: Connection,
pub sync_data: AppSettings,
pub activity: HashMap<Id, Vec<(NaiveDate, u32)>>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self {
dictionary: vec![],
decks: vec![],
word_groups: vec![],
connection: default_connection(),
sync_data: AppSettings {
key: None,
auto_web_fetch: false,
},
activity: Default::default(),
}
}
}
pub fn fill_state(state: &mut AppState) {
let list = load_words(&state.connection);
let sets = load_sets(&state.connection);
let groups = load_word_groups(&state.connection);
let setting = load_settings(&state.connection);
state.dictionary = list;
state.decks = sets;
state.word_groups = groups;
state.sync_data = setting
}
fn load_settings(connection: &Connection) -> AppSettings {
let key = get_setting("SYNC_KEY".to_string(), connection);
let fetch = get_setting("AUTO_WEB_FETCH".to_string(), connection)
.unwrap_or("false".to_string())
.parse::<bool>()
.unwrap();
AppSettings {
key,
auto_web_fetch: fetch,
}
}
+31 -15
View File
@@ -1,3 +1,4 @@
use crate::notification::NotificationData;
use hashbrown::HashMap;
use rusqlite::Connection;
use serde_json::Value;
@@ -21,17 +22,19 @@ pub struct ImportNote {
pub fields: Vec<String>,
}
pub fn load_groups(connection: &Connection) -> ImportData {
pub fn load_groups(connection: &Connection) -> Result<ImportData, NotificationData> {
let json_models: String = connection
.query_one("select models from col", (), |row| row.get(0))
.unwrap();
let raw: Value = serde_json::from_str(json_models.as_str()).unwrap();
let sets = raw.as_object().unwrap();
.map_err(|_| NotificationData::IncorrectAnkiFile)?;
let raw: Value = serde_json::from_str(json_models.as_str())
.map_err(|_| NotificationData::IncorrectAnkiFile)?;
let sets = raw.as_object();
if let Some(sets) = sets {
let mut total_data = ImportData(vec![]);
let mut count_stmt = connection
.prepare("select mid, count(id) from notes group by mid")
.unwrap();
.map_err(|_| NotificationData::IncorrectAnkiFile)?;
let counts = count_stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
@@ -46,11 +49,23 @@ pub fn load_groups(connection: &Connection) -> ImportData {
.as_array()
.unwrap()
.iter()
.map(|x| x["name"].as_str().unwrap().to_string())
.filter_map(|x| x.get("name"))
.map(|x| x.as_str().unwrap().to_string())
.collect();
let id = collection.get("id");
if id.is_none() {
return Err(NotificationData::IncorrectAnkiFile);
}
let id = id.unwrap().as_u64().unwrap();
let name = collection.get("name");
if name.is_none() {
return Err(NotificationData::IncorrectAnkiFile);
}
let name = name.unwrap().as_str().unwrap().to_string();
let mut group = ImportGroup {
id: collection["id"].as_u64().unwrap(),
name: collection["name"].as_str().unwrap().to_string(),
id,
name,
fields,
mapping: Default::default(),
length: 0,
@@ -62,17 +77,18 @@ pub fn load_groups(connection: &Connection) -> ImportData {
println!("{:?}", group);
total_data.0.push(group);
}
}
total_data
return Ok(total_data)
}
Err(NotificationData::IncorrectAnkiFile)
}
pub fn get_words_of_group(connection: &Connection, group_id: u64) -> Vec<ImportNote> {
pub fn get_words_of_group(connection: &Connection, group_id: u64) -> Result<Vec<ImportNote>, NotificationData> {
let mut count_stmt = connection
.prepare("select tags, flds from notes where mid == ?1;")
.unwrap();
.map_err(|_| NotificationData::IncorrectAnkiFile)?;
count_stmt
Ok(count_stmt
.query_map((group_id as i64,), |row| {
Ok(ImportNote {
tags: row.get(0)?,
@@ -83,7 +99,7 @@ pub fn get_words_of_group(connection: &Connection, group_id: u64) -> Vec<ImportN
.collect(),
})
})
.unwrap()
.map_err(|_| NotificationData::IncorrectAnkiFile)?
.map(|x| x.unwrap())
.collect::<Vec<ImportNote>>()
.collect::<Vec<ImportNote>>())
}
+9 -7
View File
@@ -3,12 +3,14 @@ use reqwest::Client;
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::BufReader;
use crate::notification::NotificationData;
use crate::notification::NotificationData::*;
pub async fn get_voice(text: &str) -> Result<BufReader<File>, ()> {
pub async fn get_voice(text: &str) -> Result<BufReader<File>, NotificationData> {
let mut path = app_data_dir();
path.push("voice");
if !path.exists() {
std::fs::create_dir(path.clone()).map_err(|_| ())?;
std::fs::create_dir(path.clone()).map_err(|_| PermissionDenied)?;
}
let hash = format!("{}.wav", hex::encode(Sha256::digest(text.as_bytes())));
path.push(hash);
@@ -20,7 +22,7 @@ pub async fn get_voice(text: &str) -> Result<BufReader<File>, ()> {
.send()
.await;
let query = query.map_err(|_| ())?.text().await.map_err(|_| ())?;
let query = query.map_err(|_| VoiceVoxUnavailable)?.text().await.map_err(|_| VoiceVoxUnavailable)?;
// Synthesis
let audio = client
.post(format!("{engine_url}/synthesis?speaker=11"))
@@ -28,13 +30,13 @@ pub async fn get_voice(text: &str) -> Result<BufReader<File>, ()> {
.body(query)
.send()
.await
.map_err(|_| ())?
.map_err(|_| VoiceVoxUnavailable)?
.bytes()
.await
.map_err(|_| ())?;
.map_err(|_| VoiceVoxUnavailable)?;
tokio::fs::write(&path, &audio).await.map_err(|_| ())?;
tokio::fs::write(&path, &audio).await.map_err(|_| PermissionDenied)?;
}
Ok(BufReader::new(File::open(path).map_err(|_| ())?))
Ok(BufReader::new(File::open(path).map_err(|_| PermissionDenied)?))
}
+2 -2
View File
@@ -4,10 +4,9 @@ use crate::dictionary_quiz::DictionaryQuizState;
use crate::import::ImportState;
use crate::lang::{WordData, WordGroup};
use crate::navigation::Page::{Import, Word};
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
use crate::word::WordState;
use crate::{AppState, RootMessage};
use chrono::{DateTime, TimeDelta, Utc};
use hashbrown::{HashMap, HashSet};
use iced::alignment::Vertical::Center;
@@ -25,6 +24,7 @@ use std::ops::Add;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::app::AppState;
#[derive(Clone)]
pub struct DictionaryState {
+2 -2
View File
@@ -1,8 +1,7 @@
use crate::data_provider::history::{get_history_of_set, HistoryItem};
use crate::lang::{WordData, INVALID_ID};
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::*;
use crate::styling::*;
use crate::{AppState, RootMessage};
use chrono::Local;
use iced::alignment::Horizontal::Center;
use iced::widget::space::horizontal;
@@ -11,6 +10,7 @@ use iced::{Element, Fill, FillPortion, Task};
use parking_lot::Mutex;
use std::sync::Arc;
use HistoryMessage::Back;
use crate::app::AppState;
#[derive(Clone)]
pub struct HistoryState {
+22 -23
View File
@@ -1,10 +1,11 @@
use crate::AppState;
use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups};
use crate::data_provider::words::{add_group, add_words};
use crate::dictionary::app_data_dir;
use crate::import::ImportMessage::*;
use crate::lang::{WordData, WordGroup};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::NotificationData::OpenFileError;
use crate::notification::{NotificationData, NotificationRequest};
use crate::styling::*;
use iced::widget::button::danger;
use iced::widget::container::success;
@@ -25,6 +26,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::task::spawn_blocking;
use zip::ZipArchive;
use crate::app::AppState;
const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"];
@@ -275,9 +277,6 @@ impl ImportState {
let mut row = Row::new();
for default in DEFAULT_FIELDS {
// if group.mapping.values().any(|name| name == default) {
// continue;
// }
row = row.push(
button(text!("{default}")).on_press(SetupDefaultProperty(default.to_string())),
);
@@ -365,41 +364,38 @@ impl ImportState {
.await
.unwrap()
},
|result: Result<ImportData, ()>| {
if let Ok(import) = result {
RootMessage::Import(UpdateImport(import))
} else {
RootMessage::None
}
|result: Result<ImportData, NotificationData>| match result {
Ok(import) => RootMessage::Import(UpdateImport(import)),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err)),
},
)
}
fn extract_import_file(path: PathBuf) -> Result<(), ()> {
let file = File::open(path).map_err(|_| ())?;
fn extract_import_file(path: PathBuf) -> Result<(), NotificationData> {
let file = File::open(path).map_err(|_| OpenFileError)?;
let reader = BufReader::new(file);
let mut archive = ZipArchive::new(reader).map_err(|_| ())?;
let mut archive = ZipArchive::new(reader).map_err(|_| OpenFileError)?;
let temp_file_path = app_data_dir().join("import");
let mut temp_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temp_file_path)
.map_err(|_| ())?;
.map_err(|_| OpenFileError)?;
if let Ok(mut file) = archive.by_name("collection.anki21") {
std::io::copy(&mut file, &mut temp_file).map_err(|_| ())?;
std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?;
Ok(())
} else if let Ok(mut file) = archive.by_name("collection.anki2") {
std::io::copy(&mut file, &mut temp_file).map_err(|_| ())?;
std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?;
Ok(())
} else {
Err(())
Err(OpenFileError)
}
}
fn read_import_file() -> Result<ImportData, ()> {
fn read_import_file() -> Result<ImportData, NotificationData> {
let temp_file_path = app_data_dir().join("import");
let connection = Connection::open(&temp_file_path).map_err(|_| ())?;
let data = load_groups(&connection);
let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?;
let data = load_groups(&connection)?;
Ok(data)
}
fn setup_mapping(&mut self, property_name: String) {
@@ -435,8 +431,8 @@ impl ImportState {
async move {
spawn_blocking(move || {
let temp_file_path = app_data_dir().join("import");
let connection = Connection::open(&temp_file_path).map_err(|_| ())?;
let import_list = get_words_of_group(&connection, group.id);
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);
@@ -500,7 +496,10 @@ impl ImportState {
.await
.unwrap()
},
|_: Result<(), ()>| RootMessage::Import(ImportFinished),
|r: Result<(), NotificationData>| match r {
Ok(_) => RootMessage::Import(ImportFinished),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err))
}
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::data_provider::card_stats::load_stats_of_deck;
use crate::data_provider::card_stats::update_stat_score;
use crate::data_provider::history::{push_note, HistoryItem};
use crate::AppState;
use crate::app::AppState;
use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use rand::distr::weighted::WeightedIndex;
+5 -107
View File
@@ -17,120 +17,18 @@ mod sync;
mod word;
mod writing;
pub mod notification;
pub mod app;
use crate::RootMessage::Keyboard;
use crate::data_provider::card_sets::load_sets;
use crate::data_provider::settings::get_setting;
use crate::data_provider::sqlite::default_connection;
use crate::data_provider::words::{load_word_groups, load_words};
use crate::lang::{DeckSettings, Id, WordData, WordGroup};
use crate::navigation::{AppSettings, RootMessage, ScreenState};
use crate::app::start;
use crate::navigation::RootMessage;
use crate::quiz::*;
use chrono::NaiveDate;
use hashbrown::HashMap;
use iced::{Font, window};
use iced::{Subscription, Theme, keyboard};
use iced_core::Size;
use iced_core::window::Position;
#[cfg(target_os = "linux")]
use iced_core::window::settings::PlatformSpecific;
use mimalloc::MiMalloc;
use rusqlite::Connection;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
const USER_FONT: Font = Font::with_name("Noto Sans JP");
fn main() -> iced::Result {
println!("Welcome to JapLearn");
iced::application(ScreenState::boot, ScreenState::update, ScreenState::view)
.window(window_settings())
.subscription(subscription)
.title("JapLearn")
.settings(iced::Settings {
default_text_size: iced::Pixels(18.0),
..iced::Settings::default()
})
.font(include_bytes!("../noto.ttf"))
.default_font(USER_FONT)
.theme(Theme::GruvboxDark)
.run()
start()
}
#[allow(unused_mut)]
fn window_settings() -> window::Settings {
let mut settings = window::Settings {
position: Position::Centered,
min_size: Some(Size::new(700.0_f32, 700.0_f32)),
..Default::default()
};
#[cfg(target_os = "linux")]
{
settings.platform_specific = PlatformSpecific {
application_id: "JapLearn".to_string(),
override_redirect: false,
};
}
settings
}
fn subscription(_state: &ScreenState) -> Subscription<RootMessage> {
keyboard::listen().map(Keyboard)
}
pub struct AppState {
pub dictionary: Vec<WordData>,
pub decks: Vec<DeckSettings>,
pub word_groups: Vec<WordGroup>,
pub connection: Connection,
pub sync_data: AppSettings,
pub activity: HashMap<Id, Vec<(NaiveDate, u32)>>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self {
dictionary: vec![],
decks: vec![],
word_groups: vec![],
connection: default_connection(),
sync_data: AppSettings {
key: None,
auto_web_fetch: false,
},
activity: Default::default(),
}
}
}
fn fill_state(state: &mut AppState) {
let list = load_words(&state.connection);
let sets = load_sets(&state.connection);
let groups = load_word_groups(&state.connection);
let setting = load_settings(&state.connection);
state.dictionary = list;
state.decks = sets;
state.word_groups = groups;
state.sync_data = setting
}
fn load_settings(connection: &Connection) -> AppSettings {
let key = get_setting("SYNC_KEY".to_string(), connection);
let fetch = get_setting("AUTO_WEB_FETCH".to_string(), connection)
.unwrap_or("false".to_string())
.parse::<bool>()
.unwrap();
AppSettings {
key,
auto_web_fetch: fetch,
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ 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::app::{fill_state, AppState};
use chrono::NaiveDate;
use hashbrown::HashMap;
use iced::keyboard::Event;
+16
View File
@@ -132,6 +132,18 @@ impl NotificationManager {
NotificationData::ImportError => {
text!("Ошибка при импорте данных из Anki")
}
NotificationData::IncorrectAnkiFile => {
text!("Неподдерживаемый формат файла .apkg")
}
NotificationData::OpenFileError => {
text!("Ошибка открытия файла пакета")
}
NotificationData::VoiceVoxUnavailable => {
text!("Не получается подключиться к VoiceVox")
}
NotificationData::PermissionDenied => {
text!("У приложение недостаточно прав")
}
}
.into()
}
@@ -146,6 +158,10 @@ pub enum NotificationRequest {
#[derive(Clone)]
pub enum NotificationData {
PermissionDenied,
DataLoaded,
ImportError,
IncorrectAnkiFile,
OpenFileError,
VoiceVoxUnavailable
}
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::lang::KanaSet;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::quiz::QuizMessage::*;
use crate::styling::*;
use crate::{RootMessage, USER_FONT};
use iced::widget::*;
use iced::{Element, Fill, Task, alignment};
use rand::seq::SliceRandom;
use crate::app::USER_FONT;
#[derive(Clone, Debug)]
pub struct QuizState {
+2 -2
View File
@@ -2,9 +2,8 @@ use crate::data_provider::voice::get_voice;
use crate::lang::Id;
use crate::lang::{CardStatistics, DeckData, DeckSettings, WordData, WordOpenMode};
use crate::navigation::Page::PreviousPage;
use crate::navigation::{KeyPressedPage, NavigatedPage, Page};
use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
use crate::styling::*;
use crate::{AppState, RootMessage};
use chrono::Local;
use iced::alignment::Horizontal::Center;
use iced::keyboard::key::Physical::Code;
@@ -16,6 +15,7 @@ use rodio::MixerDeviceSink;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::task::spawn_blocking;
use crate::app::AppState;
pub struct RepetitionState {
state: Arc<Mutex<AppState>>,
+2 -1
View File
@@ -1,4 +1,4 @@
use crate::AppState;
use crate::data_provider::card_sets::{delete_set, update_deck};
use crate::data_provider::card_stats::{delete_stats, load_stats_of_deck};
use crate::lang::DeckSettings;
@@ -15,6 +15,7 @@ use iced_core::{Padding, Theme, color};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use crate::app::AppState;
pub struct RepetitionSettingsState {
set: DeckSettings,
+2 -2
View File
@@ -3,12 +3,11 @@ use crate::data_provider::card_stats::{add_stat_list, load_stats_of_deck};
use crate::history::HistoryState;
use crate::lang::{AppendMode, CardStatistics, DeckSettings, Id, OrderMode};
use crate::navigation::Page::{History, PreviousPage, Repetition, RepetitionSettings};
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::repetition::RepetitionState;
use crate::repetition_settings::RepetitionSettingsState;
use crate::repetitions::RepetitionsMessage::*;
use crate::styling::*;
use crate::{AppState, RootMessage};
use chrono::{Days, Local, Utc};
use hashbrown::{HashMap, HashSet};
pub use iced::widget::button::{Catalog, Style};
@@ -25,6 +24,7 @@ use iced_core::svg::Handle;
use parking_lot::Mutex;
use std::ops::Deref;
use std::sync::Arc;
use crate::app::AppState;
#[derive(Clone)]
pub struct RepetitionsState {
+3 -2
View File
@@ -1,14 +1,13 @@
use crate::dictionary::DictionaryState;
use crate::lang::{KanaSet, KanaType};
use crate::navigation::Page::*;
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::randomizer::RandomizerState;
use crate::repetitions::RepetitionsState;
use crate::selector::SelectorMessage::ChangeMode;
use crate::styling::*;
use crate::sync::SyncState;
use crate::writing::WritingState;
use crate::{AppState, QuizState, RootMessage};
use iced::widget::button::{Status, Style};
use iced::widget::*;
use iced::{Element, Task, alignment};
@@ -17,6 +16,8 @@ use iced_core::Background;
use iced_core::Color;
use parking_lot::Mutex;
use std::sync::Arc;
use crate::app::AppState;
use crate::quiz::QuizState;
pub struct SelectorState {
pub set: KanaSet,
+2 -2
View File
@@ -1,10 +1,9 @@
use crate::data_provider::settings::{delete_settings, set_setting};
use crate::data_provider::web_api::*;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
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, toggler};
@@ -12,6 +11,7 @@ use iced::{Center, Element, Fill, Font, Length, Task};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use crate::app::{fill_state, AppState};
use crate::notification::{NotificationRequest, NotificationData};
#[derive(Clone)]
+2 -2
View File
@@ -1,15 +1,15 @@
use crate::data_provider::words::{delete_word, update_word};
use crate::lang::WordData;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
use crate::word::WordMessage::*;
use crate::{AppState, RootMessage};
use iced::widget::button::danger;
use iced::widget::{button, column, row, rule, scrollable, text, text_input};
use iced::{Element, Fill, Task};
use parking_lot::Mutex;
use std::sync::Arc;
use crate::app::AppState;
#[derive(Clone)]
pub struct WordState {