Author SHA1 Message Date
micialware 142c2d3d10 correct history order on history page 2026-10-10 19:20:24 +03:00
micialware 27bdcf41b0 update compression level and logging compression time 2026-10-05 22:17:04 +03:00
micialware 5817bfcd46 Reformat code 2026-10-04 15:19:56 +03:00
micialware a36613b238 tokio update 2026-10-04 13:01:53 +03:00
micialware 496f246125 More data on exams page 2026-10-04 12:22:33 +03:00
micialware 7ad44110c3 Exam page works 2026-10-03 22:47:33 +03:00
micialware 0b23da9eda Fix display and sync bugs 2026-10-03 21:27:13 +03:00
micialware 5b2b48c88f updated card display 2026-10-03 20:32:11 +03:00
micialware 632f0c341b Basic exam page 2026-10-03 15:18:00 +03:00
micialware 65991f742e Basic exams page ready 2026-10-03 15:12:09 +03:00
micialware 79c42d0feb Exams page more code 2026-10-03 14:49:19 +03:00
micialware 4b9e257cdf Exam base 2026-10-03 01:13:11 +03:00
micialware 6628d79307 Update message text 2026-10-02 21:19:30 +03:00
micialware 936efd0e99 Fsrs pools change 2026-09-23 18:05:58 +03:00
micialware 8603e9b981 Fix editor bug 2026-09-23 14:37:42 +03:00
micialware 824a868d73 Small database sync update 2026-09-22 21:57:29 +03:00
micialware 8b673e52da Change selection type on extended editing 2026-09-22 15:48:18 +03:00
micialware b210b2013d Fix saving bug 2026-09-22 15:27:39 +03:00
micialware 48cd68d1c7 Fix incorrect states clearing 2026-09-21 11:53:07 +03:00
micialware a5526dc5e9 Migrate to String::new() 2026-09-20 22:24:42 +03:00
micialware 1d4b37e381 Update version, remove unused features 2026-09-20 22:18:59 +03:00
micialware 96a58c61bc Rollback default control radius 2026-09-15 22:11:38 +03:00
micialware 1847d4f44f apply new style 2026-09-15 22:09:06 +03:00
micialware b1902b35cb style and macros for text input 2026-09-15 21:54:24 +03:00
micialware 5d12f3b02a Fixing incorrect history reading bug 2026-09-14 19:41:02 +03:00
micialware 0e867a33ce Merge pull request 'Updated word display' (#19) from updated-word-display into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/19
2026-09-13 17:42:58 +03:00
micialware ccfb302770 Prepare for new anki format 2026-09-13 12:44:51 +03:00
micialware 0de809fcce Compressing word fields 2026-09-11 22:08:50 +03:00
micialware e5622c70de Fix deck filter 2026-09-11 14:02:57 +03:00
micialware cbf49412ec additional fields encoding and decoding 2026-09-11 13:56:26 +03:00
micialware f61f93717c Custom fields repo into AppState 2026-09-11 13:28:23 +03:00
micialware be64b1da2d Fix group delete 2026-09-11 13:12:45 +03:00
micialware 2568b9f292 More fixes for dictionary panel 2026-09-11 13:09:20 +03:00
micialware 05c82bee02 Fix tag edit bug 2026-09-11 12:48:04 +03:00
micialware 5d305affd3 Fixing some bugs 2026-09-11 12:38:21 +03:00
micialware d59776d713 More keyboard handling for dictionary 2026-09-11 12:03:13 +03:00
micialware adb6388ee2 Prepare for extended word edit 2026-09-11 11:47:04 +03:00
micialware 8fe4f3b8f4 New selected field movement 2026-09-11 00:41:36 +03:00
micialware 47bc83d4ae New edit process 2026-09-11 00:01:19 +03:00
micialware 097c3c033c Merge pull request 'reformat custom fields table. will return to this topic later' (#18) from compressed-fields into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/18
2026-09-09 15:16:01 +03:00
micialware c30be42822 reformat custom fields table. will return to this topic later 2026-09-09 15:15:47 +03:00
micialware ace54b10e1 remove clone from states 2026-09-09 15:03:54 +03:00
micialware f66bf69ef5 Code cleanup 2026-09-09 15:00:52 +03:00
micialware 1ad991667b Migrate to tiny macros of button 2026-09-09 14:26:55 +03:00
micialware 444352c007 Merge pull request 'Repo integration' (#15) from repo-integration into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/15
2026-09-09 12:47:42 +03:00
micialware 2069bdf5b3 Fix already updated cache change 2026-09-09 12:47:22 +03:00
micialware 28730c0fda Delete saved card states 2026-09-09 12:35:00 +03:00
micialware 7715aaff3b Fixing tag selection 2026-09-09 12:12:44 +03:00
micialware a20c72eb9d Optimize groups query 2026-09-09 11:23:51 +03:00
micialware 58bdaac7fe Remove word display widget base 2026-09-09 11:13:50 +03:00
micialware 4a71f31e6f Fix import 2026-09-09 10:53:03 +03:00
micialware 6e3f58d21c Version update 2026-09-09 10:43:26 +03:00
micialware 3663765284 Db saving cases 2026-09-09 10:33:21 +03:00
micialware 294f0e8b98 Query time and database optimization 2026-09-09 10:19:44 +03:00
micialware 2e04450c76 Working repetitions 2026-09-09 10:08:52 +03:00
micialware 242b56fc21 Fixes for sqlite data layer 2026-09-08 00:09:13 +03:00
micialware 7f9aa11d6e More fixes for dictionary 2026-09-07 18:19:54 +03:00
micialware 92b019b072 Temporal dictionary display fix 2026-09-07 18:00:50 +03:00
micialware 9c93ca4408 Merge remote-tracking branch 'origin/word-display' into repo-integration 2026-09-07 17:17:18 +03:00
micialware 6387afba93 Incorrect data display fix 2026-09-07 16:21:40 +03:00
micialware 42c38d55e1 Fix more bugs 2026-09-07 16:10:10 +03:00
micialware 60dae212ec Fix incorrect filter light in repetition page 2026-09-07 15:49:39 +03:00
micialware b3b5785fd7 Fix some bugs in repo and repetitions page 2026-09-07 15:48:10 +03:00
micialware bdf71fa0b7 Small history table change 2026-09-07 10:59:55 +03:00
micialware f9c4bf9c03 App is building 2026-09-07 10:53:09 +03:00
micialware 7e1bb4bc22 temporal solving word 2026-09-06 23:58:41 +03:00
micialware 1afb7493f6 Sync solved 2026-09-06 23:55:50 +03:00
micialware e2f367f3aa WordDisplay base 2026-09-06 10:13:33 +03:00
micialware 22c05250aa Repetition settings remake 2026-09-04 22:34:36 +03:00
micialware a68e56e861 Solve repetition 2026-09-04 17:26:59 +03:00
micialware 08ab55f283 Fix deck data 2026-09-04 13:00:41 +03:00
micialware 9a7578727b Fix data access method and solve import page 2026-09-04 11:37:19 +03:00
micialware 532640058e Rewrite history page 2026-09-04 11:18:23 +03:00
micialware f19a6f64c3 Dictionary partial rewritten and dictionary quiz rewritten 2026-09-03 00:36:15 +03:00
micialware f2035ca749 Partial dictionary rewrite 2026-09-02 00:19:58 +03:00
micialware 1509947cb0 Temporal todo 2026-08-31 14:50:21 +03:00
micialware 55bdc5a7ad New repository methods 2026-08-31 14:35:11 +03:00
micialware 11d5144db5 Added repositories into AppState 2026-08-31 12:12:14 +03:00
micialware 6c20561ede Repository types defined 2026-08-31 11:52:54 +03:00
micialware 0c4aec3dc0 Partial AppState rewrite 2026-08-31 11:46:35 +03:00
micialware 29a90d48d9 Reformat code 2026-08-31 11:26:05 +03:00
micialware 2fc58fcc2d Custom fields table 2026-08-31 11:25:41 +03:00
micialware 6d363c8148 Custom fields table 2026-08-31 11:23:41 +03:00
micialware ba834ac9b1 Impl Id convert methods 2026-08-31 11:21:01 +03:00
micialware 97e301b0d6 Merge pull request 'Db layers' (#14) from db_layers into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/14
2026-08-30 23:38:33 +03:00
micialware aa58bb2918 Repository start method 2026-08-30 23:35:32 +03:00
micialware 6f010428d5 Add db operation time 2026-08-30 22:36:43 +03:00
micialware d9bbf9e705 Write TableAccessQuerySet for other tables. Create Id macro 2026-08-30 22:15:03 +03:00
micialware 9a5b64a4fe Decks rewritten 2026-08-30 20:50:30 +03:00
micialware c9ccacc2d1 Fix CardStates 2026-08-30 20:46:52 +03:00
micialware 068b9c7197 CardStates rewritten to TableAccessQuerySet 2026-08-30 20:43:31 +03:00
micialware 45830a7985 Write SqlDbLayer 2026-08-30 20:32:16 +03:00
micialware 8ec7c8f05d New repo method 2026-08-30 19:30:43 +03:00
micialware 18aa748451 Merge remote-tracking branch 'origin/repository'
# Conflicts:
#	src/data/entities.rs
2026-08-30 19:25:38 +03:00
micialware 3ac7cd068b Rename methods 2026-08-30 19:22:28 +03:00
micialware b006ece0c1 Clear code and add shifts into Query 2026-08-29 23:15:22 +03:00
micialware ea214d8ac0 Change connection type from Mutex to RwLock 2026-08-29 23:10:57 +03:00
micialware 4b17a5a4a7 Fix incorrect handle_by_id 2026-08-29 22:50:18 +03:00
micialware 2c1163fc3b Compress EntityHandle 2026-08-29 22:41:11 +03:00
micialware d0eabbe1f8 Merge pull request 'Fsrs' (#12) from fsrs into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/12
2026-08-29 22:05:16 +03:00
micialware d5a2a575fe Cleanup and format 2026-08-29 22:02:01 +03:00
micialware af6cf10765 rename data provider file names 2026-08-29 21:32:54 +03:00
micialware a642a8fda4 rename tables 2026-08-29 21:31:54 +03:00
micialware 188a26e77d Change date type 2026-08-28 21:47:47 +03:00
micialware e7019903b0 Create fsrs upadte method 2026-08-28 18:52:38 +03:00
micialware 663fa377b1 Add history table and modify card_stats 2026-08-28 17:44:14 +03:00
micialware d8e3a5b6db Load code fixed 2026-08-28 17:15:16 +03:00
micialware a01a29c492 Query manager code 2026-08-28 16:52:48 +03:00
micialware 70aff6ed18 Better query code 2026-08-27 14:51:35 +03:00
micialware 89aa1abc13 Query partial code 2026-08-27 14:05:05 +03:00
micialware 0f2090af77 Test update, repo update 2026-08-27 09:21:16 +03:00
micialware 0326b56ded Update save_many 2026-08-26 23:55:24 +03:00
micialware 3eba0c93d4 save_many code 2026-08-26 19:48:22 +03:00
micialware d99fd337da More repo code 2026-08-26 17:53:09 +03:00
micialware 807f398d81 First repo code 2026-08-25 15:46:39 +03:00
micialware 1f3ad22e05 Merge pull request 'Data provider refactor' (#11) from data-provider-refactor into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/11
2026-08-25 12:21:54 +03:00
micialware 10cf7f0d2e Formatting 2026-08-25 12:04:07 +03:00
micialware dddac01bdf Fix compression bug 2026-08-25 01:05:15 +03:00
micialware 1d24fe2631 Finishing deunprap 2026-08-24 23:48:00 +03:00
micialware a60ed37cd4 Introduce MaybeError type 2026-08-24 23:42:58 +03:00
micialware d836b57d2a Remove more unwrap 2026-08-24 23:27:24 +03:00
micialware b5b1eb768c remove more unwrap 2026-08-24 23:10:23 +03:00
micialware 5a6747030b Rewrite words and groups into result 2026-08-24 18:46:54 +03:00
micialware c56d135ce8 More web-api notifications 2026-08-24 18:27:32 +03:00
micialware 6f8adde06b Rewrite settings to result 2026-08-24 18:23:29 +03:00
micialware 8841a51897 Rewrite fully second db table into result 2026-08-24 18:16:41 +03:00
micialware 06bf9138c0 First database deunwrap 2026-08-24 17:33:21 +03:00
micialware 3cbdf0803b Remove Option for vecs in repetitions 2026-08-24 13:15:49 +03:00
micialware d261a08932 Remove unused import 2026-08-24 11:41:20 +03:00
micialware 92ca42869c Extract all entities to entities.rs 2026-08-24 11:40:31 +03:00
micialware c67663141f Full split of lang.rs 2026-08-24 11:35:37 +03:00
micialware 22b455a640 Splitting to deck and srs 2026-08-24 01:37:00 +03:00
micialware c74a0eb311 Splitting kana set 2026-08-24 01:22:00 +03:00
micialware 36e5693564 Simple srs refactoring 2026-08-24 00:29:41 +03:00
micialware 42c9a5095a Remove unused import 2026-08-23 22:48:46 +03:00
micialware 0baf6cadf7 Migrate to structured logging 2026-08-23 22:48:00 +03:00
micialware 0367d5001b Remove ensure db 2026-08-23 22:22:26 +03:00
micialware 3fcfe95720 More notifications 2026-08-23 22:16:02 +03:00
micialware f113d2b3d7 Change notification layout 2026-08-23 22:05:08 +03:00
micialware 5a926b1419 More notifications and move start code to another file 2026-08-23 21:49:01 +03:00
micialware e1bfd9494d Notification base 2026-08-23 21:00:48 +03:00
micialware dfaff30efa Return par_iter 2026-08-23 00:32:43 +03:00
micialware 163ebc44a5 Fix id eval filter bug 2026-08-23 00:01:57 +03:00
micialware d42d618066 Merge pull request 'Repetitions remake' (#1) from repetitions-remake into master
Reviewed-on: https://gitea.micialware.ru/micialware/jap_learn/pulls/1
2026-08-20 19:48:19 +03:00
67 changed files with 7067 additions and 3909 deletions

No files matched your search

Generated
+354 -643
View File
File diff suppressed because it is too large. Load diff
+14 -15
View File
@@ -1,43 +1,42 @@
[package] [package]
name = "jap_learn" name = "jap_learn"
version = "0.1.3" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
iced = { version = "0.14.0", features = ["tokio", "svg", "lazy"]} iced = { version = "0.14.0", features = ["tokio", "svg", "lazy", "crisp", "tiny-skia", "wayland", "wgpu"], default-features = false}
iced_core = "0.14.0" iced_core = "0.14.0"
rand = "0.10.2" rand = "0.10.3"
dirs = "6.0.0" dirs = "7.0.0"
serde_json = "1.0.151" serde_json = "1.0.151"
rhai = { version = "1.25.1", features = ["sync"] } rhai = { version = "1.26.1", features = ["sync"] }
chrono = "0.4.45" chrono = "0.4.45"
rusqlite = { version = "0.40.2", features = ["chrono", "bundled"] } rusqlite = { version = "0.40.2", features = ["chrono", "bundled"] }
reqwest = { version = "0.13.4", features = ["json", "stream", "multipart"] } reqwest = { version = "0.13.5", features = ["json", "stream", "multipart"] }
rodio = "0.22.2" rodio = "0.22.2"
sha2 = "0.11.0" sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "1.53.2", features = ["rt", "rt-multi-thread", "macros"] }
hex = "0.4.3" hex = "0.4.3"
zstd = "0.13.3" zstd = "0.14.1"
zip = "8.6.0" zip = "8.6.0"
rfd = "0.17.2" rfd = "0.17.2"
mimalloc = "0.1.52" mimalloc = "0.1.52"
hashbrown = "0.17.1" hashbrown = "0.17.1"
rayon = "1.12.0" rayon = "1.12.0"
parking_lot = "0.12.5" parking_lot = "0.12.5"
env_logger = "0.11.11"
log = "0.4.34"
fsrs = "6.6.2"
litemap = { version = "0.8.3" }
[profile.super-release] [profile.super-release]
inherits = "release" inherits = "release"
codegen-units = 1 codegen-units = 1
lto = true lto = true
opt-level = 3 opt-level = 3
strip = true #strip = true
panic = "abort" panic = "abort"
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8.2", features = ["html_reports"] } criterion = { version = "0.8.2", features = ["html_reports"] }
rustc-hash = "2.1.3"
[[bench]]
name = "map_bench"
harness = false
-126
View File
@@ -1,126 +0,0 @@
use criterion::{Criterion, criterion_group, criterion_main};
use std::collections::HashMap as StdHashMap;
use std::hint::black_box;
use hashbrown::HashMap as BrownHashMap;
const SIZES: [usize; 3] = [5, 10, 50];
const QUERY_COUNT: usize = 4096;
/// Детерминированный ключ ~17 символов, например "key_9e3779b9_0042".
fn make_key(i: u64) -> String {
let h = i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
format!("key_{:08x}_{:04}", h & 0xFFFF_FFFF, i)
}
// Для длинных ключей (пути/URL) замени на:
// fn make_key(i: u64) -> String {
// format!(
// "/api/v2/users/{i}/settings/visibility_{:08x}",
// i.wrapping_mul(0x9E37_79B9)
// )
// }
fn make_vec(n: usize) -> Vec<(String, u64)> {
(0..n as u64).map(|i| (make_key(i), i)).collect()
}
fn make_std_map(data: &[(String, u64)]) -> StdHashMap<String, u64> {
data.iter().map(|(k, v)| (k.clone(), *v)).collect()
}
fn make_brown_map(data: &[(String, u64)]) -> BrownHashMap<String, u64> {
data.iter().map(|(k, v)| (k.clone(), *v)).collect()
}
fn make_queries(n: usize, with_misses: bool) -> Vec<String> {
let keys: Vec<String> = (0..n as u64).map(make_key).collect();
let missing = "__missing_key__".to_string();
(0..QUERY_COUNT)
.map(|i| {
if with_misses && i % 16 == 15 {
missing.clone()
} else {
let idx = i.wrapping_mul(2_654_435_761) % n;
keys[idx].clone()
}
})
.collect()
}
fn lookup_std(map: &StdHashMap<String, u64>, queries: &[String]) -> u64 {
let mut sum = 0u64;
for q in queries {
match map.get(black_box(q.as_str())) {
Some(v) => sum = sum.wrapping_add(*v),
None => sum = sum.wrapping_add(1),
}
}
black_box(sum)
}
fn lookup_brown(map: &BrownHashMap<String, u64>, queries: &[String]) -> u64 {
let mut sum = 0u64;
for q in queries {
match map.get(black_box(q.as_str())) {
Some(v) => sum = sum.wrapping_add(*v),
None => sum = sum.wrapping_add(1),
}
}
black_box(sum)
}
fn lookup_vec(data: &[(String, u64)], queries: &[String]) -> u64 {
let mut sum = 0u64;
for q in queries {
let q = black_box(q);
match data.iter().find(|(k, _)| k == q) {
Some((_, v)) => sum = sum.wrapping_add(*v),
None => sum = sum.wrapping_add(1),
}
}
black_box(sum)
}
fn bench(c: &mut Criterion) {
let mut group = c.benchmark_group("map_vs_vec_str");
for n in SIZES {
let vec_data = make_vec(n);
let std_map = make_std_map(&vec_data);
let brown_map = make_brown_map(&vec_data);
let queries_hit = make_queries(n, false);
let queries_mixed = make_queries(n, true);
// std::collections::HashMap (SipHash)
group.bench_function(format!("std_hashmap_hit/{n}"), |b| {
b.iter(|| lookup_std(black_box(&std_map), black_box(&queries_hit)))
});
group.bench_function(format!("std_hashmap_mixed/{n}"), |b| {
b.iter(|| lookup_std(black_box(&std_map), black_box(&queries_mixed)))
});
// hashbrown (SwissTable + foldhash)
group.bench_function(format!("hashbrown_hit/{n}"), |b| {
b.iter(|| lookup_brown(black_box(&brown_map), black_box(&queries_hit)))
});
group.bench_function(format!("hashbrown_mixed/{n}"), |b| {
b.iter(|| lookup_brown(black_box(&brown_map), black_box(&queries_mixed)))
});
// Vec<(String, u64)>, линейный поиск
group.bench_function(format!("vec_linear_hit/{n}"), |b| {
b.iter(|| lookup_vec(black_box(&vec_data), black_box(&queries_hit)))
});
group.bench_function(format!("vec_linear_mixed/{n}"), |b| {
b.iter(|| lookup_vec(black_box(&vec_data), black_box(&queries_mixed)))
});
}
group.finish();
}
criterion_group!(benches, bench);
criterion_main!(benches);
-9
View File
@@ -1,9 +0,0 @@
create table card_set
(
id INTEGER
primary key autoincrement,
name TEXT not null,
forward TEXT not null,
backward TEXT not null,
filter TEXT not null
);
-13
View File
@@ -1,13 +0,0 @@
create table card_stats
(
id INTEGER
primary key autoincrement,
word_id INTEGER not null
references words
on delete cascade,
set_id INTEGER not null
references card_set
on delete cascade,
score INTEGER default 1 not null,
last_opened INTEGER not null
);
+6
View File
@@ -0,0 +1,6 @@
create table custom_fields
(
id integer primary key,
name text not null,
identifier text not null
);
+9
View File
@@ -0,0 +1,9 @@
create table decks
(
id integer
primary key autoincrement,
name text not null,
forward text not null,
backward text not null,
filter text not null
);
+5
View File
@@ -0,0 +1,5 @@
create table exams(
id integer primary key,
timestamp integer,
deck_id integer not null references decks on delete cascade
);
+7
View File
@@ -0,0 +1,7 @@
create table exam_entries
(
id integer primary key,
exam_id integer not null references exams on delete cascade,
word_id integer not null references words on delete cascade,
status integer default null
);
+12
View File
@@ -0,0 +1,12 @@
create table history
(
id integer
primary key autoincrement,
deck_id integer not null references decks 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,
after integer not null,
fsrs integer not null
);
+16
View File
@@ -0,0 +1,16 @@
create table states
(
id integer
primary key autoincrement,
word_id integer not null
references words
on delete cascade,
deck_id integer not null
references decks
on delete cascade,
score integer default 1 not null,
last_opened integer not null,
fsrs_opened integer not null,
fsrs_memory integer not null,
fsrs_open_day integer not null
);
-6
View File
@@ -1,6 +0,0 @@
create table word_group
(
id INTEGER
primary key autoincrement,
name TEXT not null
);
+6
View File
@@ -0,0 +1,6 @@
create table word_groups
(
id integer
primary key autoincrement,
name text not null
);
+7 -7
View File
@@ -1,13 +1,13 @@
create table words create table words
( (
id INTEGER id integer
primary key autoincrement, primary key autoincrement,
key TEXT not null, key text not null,
value TEXT not null, value text not null,
tags TEXT not null, tags text not null,
more TEXT, more text,
group_id integer default 1 not null group_id integer default 1 not null
constraint words_word_group_id_fk constraint words_word_groups_id_fk
references word_group references word_groups
on update cascade on delete cascade on update cascade on delete cascade
); );
+131
View File
@@ -0,0 +1,131 @@
use crate::data::repositories::Repository;
use crate::data_provider::card_states::CardStateRepository;
use crate::data_provider::custom_fields::CustomFieldRepository;
use crate::data_provider::decks::DeckRepository;
use crate::data_provider::history::HistoryRepository;
use crate::data_provider::settings::get_setting;
use crate::data_provider::sqlite::SqliteConnection;
use crate::data_provider::word_groups::WordGroupRepository;
use crate::data_provider::words::WordRepository;
use crate::navigation::RootMessage::Keyboard;
use crate::navigation::{AppSettings, RootMessage, ScreenState};
use iced::{Subscription, keyboard, window};
use iced_core::window::Position;
#[cfg(target_os = "linux")]
use iced_core::window::settings::PlatformSpecific;
use iced_core::{Font, Size, Theme};
use log::info;
use parking_lot::{Mutex, RwLock};
use rusqlite::Connection;
use std::sync::Arc;
use crate::data_provider::exam::{ExamEntryRepository, ExamRepository};
pub const USER_FONT: Font = Font::with_name("Noto Sans JP");
pub fn start() -> iced::Result {
info!("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)
}
#[derive(Clone)]
pub struct AppState {
pub dictionary: WordRepository,
pub decks: DeckRepository,
pub word_groups: WordGroupRepository,
pub card_states: CardStateRepository,
pub custom_fields: CustomFieldRepository,
pub connection: Arc<Mutex<SqliteConnection>>,
pub sync_data: Arc<RwLock<AppSettings>>,
pub history: HistoryRepository,
pub exam: ExamRepository,
pub exam_entries: ExamEntryRepository
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
let connection = Arc::new(Mutex::new(SqliteConnection::default()));
Self {
dictionary: WordRepository::new(connection.clone()),
decks: DeckRepository::new(connection.clone()),
word_groups: WordGroupRepository::new(connection.clone()),
card_states: CardStateRepository::new(connection.clone()),
history: HistoryRepository::new(connection.clone()),
exam: ExamRepository::new(connection.clone()),
custom_fields: CustomFieldRepository::new(connection.clone()),
exam_entries: ExamEntryRepository::new(connection.clone()),
sync_data: Arc::new(RwLock::new(AppSettings {
key: None,
auto_web_fetch: false,
})),
connection,
}
}
}
pub fn fill_state(state: &mut AppState) {
let connection = state.connection.lock();
let setting = load_settings(&connection);
drop(connection);
state.word_groups.write().start().unwrap();
state.dictionary.write().start().unwrap();
state.decks.write().start().unwrap();
state.history.write().start().unwrap();
state.card_states.write().start().unwrap();
state.custom_fields.write().start().unwrap();
state.exam.write().start().unwrap();
state.exam_entries.write().start().unwrap();
state.sync_data = Arc::new(RwLock::new(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,
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod deck;
pub mod kana_set;
pub mod srs;
+206
View File
@@ -0,0 +1,206 @@
use crate::app::AppState;
use crate::core::srs::OrderModule::{Fsrs, RandomSRS, SemiRandomSRS, WorstWordsSRS};
use crate::core::srs::{
FsrsModule, OrderMode, OrderModule, RandomSRSModule, SRSModule, SemiRandomSRSModule,
WordOpenMode, WorstWordsSRSModule,
};
use crate::data::Id;
use crate::data::entities::*;
use crate::data::repositories::Repository;
use crate::data_provider::card_states::CardStateRepositoryInner;
use crate::notification::MaybeError;
use chrono::Utc;
use litemap::LiteMap;
use log::info;
use rhai::{Engine, Scope};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
#[derive(Clone)]
pub struct DeckData {
pub(crate) words: Vec<EntityHandle<WordData>>,
pub(crate) states: Vec<EntityHandle<CardState>>,
current_word_index: Option<usize>,
state: AppState,
order_module: OrderModule,
open_mode: OrderMode,
settings: DeckSettings,
}
struct DisplayStyle {
properties: LiteMap<String, String>,
}
impl DeckData {
pub fn new(settings: &DeckSettings, open_mode: OrderMode, state: AppState) -> Self {
let states_repo = state.card_states.read();
let mut query = states_repo.deck_states(settings.id);
let words = query
.iter(&states_repo)
.map(|card_state| EntityHandle::from_id(card_state.word_id))
.collect();
let states = query.handles().collect::<Vec<EntityHandle<CardState>>>();
let fsrs = Fsrs(FsrsModule::new(
Self::set_links(states.as_slice(), &states_repo).as_slice(),
));
drop(states_repo);
Self {
states,
words,
current_word_index: None,
state,
order_module: fsrs,
open_mode,
settings: settings.clone(),
}
}
pub fn next(&mut self) -> (EntityHandle<WordData>, EntityHandle<CardState>) {
if self.fsrs_finished() {
self.switch_order_engine()
}
let repo = self.state.card_states.read();
let links = Self::set_links(self.states.as_slice(), &repo);
let index = match self.order_module {
SemiRandomSRS(ref mut module) => module.next(links.as_slice()),
RandomSRS(ref mut module) => module.next(links.as_slice()),
WorstWordsSRS(ref mut module) => module.next(links.as_slice()),
Fsrs(ref mut module) => module.next(links.as_slice()),
};
self.current_word_index = Some(index);
(self.words[index], self.states[index])
}
fn set_links<'a>(
set: &[EntityHandle<CardState>],
repo: &'a CardStateRepositoryInner,
) -> Vec<&'a CardState> {
set.iter()
.map(|h| repo.get_ref_static(*h).unwrap())
.collect::<Vec<_>>()
}
pub fn open(&mut self, status: WordOpenMode) -> MaybeError {
if let Some(index) = self.current_word_index {
let mut repo = self.state.card_states.write();
let card_state_handle = &mut self.states[index];
let card_state = repo.get_mut(card_state_handle).unwrap();
let old_score = card_state.score;
card_state.update(status);
let new_score = card_state.score;
let mut fsrs = false;
match self.order_module {
SemiRandomSRS(ref mut module) => {
module.open(status, index, card_state);
}
RandomSRS(ref mut module) => {
module.open(status, index, card_state);
}
WorstWordsSRS(ref mut module) => {
module.open(status, index, card_state);
}
Fsrs(ref mut module) => {
module.open(status, index, card_state);
fsrs = true;
}
}
let word_id = card_state.word_id;
repo.save(card_state_handle)?;
let mut history = self.state.history.write();
history.create_now(HistoryNote {
id: Default::default(),
timestamp: Utc::now(),
deck_id: self.settings.id,
word_id,
answer: status,
before: old_score,
after: new_score,
fsrs,
})?;
}
Ok(())
}
fn switch_order_engine(&mut self) {
let repo = self.state.card_states.read();
let links = Self::set_links(self.states.as_slice(), &repo);
self.order_module = match self.open_mode {
OrderMode::Default => SemiRandomSRS(SemiRandomSRSModule::new(links.as_slice())),
OrderMode::TrainWorstFirst => WorstWordsSRS(WorstWordsSRSModule::new(links.as_slice())),
OrderMode::FullRandom => RandomSRS(RandomSRSModule::new(links.as_slice())),
}
}
fn fsrs_finished(&self) -> bool {
if let Fsrs(ref fsrs) = self.order_module
&& fsrs.finished()
{
info!("Fsrs finished. Infinite mode enabled");
return true;
}
false
}
pub fn len(&self) -> usize {
self.states.len()
}
pub fn list(deck: &DeckSettings, state: &AppState) -> Vec<EntityHandle<WordData>> {
let time = Instant::now();
let list = vec![];
let engine = Engine::new();
let ast = engine.compile(&deck.filter);
if let Ok(ast) = ast {
let groups = state.word_groups.read().iter().cloned().collect::<Vec<_>>();
let dictionary = &state.dictionary.read();
let fields: HashMap<Id, String> = HashMap::from_iter(
state
.custom_fields
.read()
.iter()
.map(|f| (f.id, f.identifier.clone())),
);
let query = dictionary.query_par(Arc::new(move |word| {
let mut more = rhai::Map::new();
for kv in &word.additional {
more.insert(fields[kv.0].clone().into(), kv.1.clone().into());
}
let mut scope = Scope::new();
let group_name = groups[groups
.binary_search_by_key(&word.group_id, |g| g.id)
.unwrap()]
.name
.clone();
scope
.push_constant("id", word.id.0 as i64)
.push_constant("key", word.key.clone())
.push_constant("value", word.value.clone())
.push_constant("tags", word.tags.clone())
.push_constant("more", more)
.push_constant("group", group_name);
let result = engine.eval_ast_with_scope::<bool>(&mut scope, &ast);
if let Ok(result) = result {
return result;
}
false
}));
info!("Collecting available words is {:?}", time.elapsed());
let handles = query.handles().collect();
drop(query);
handles
} else {
list
}
}
}
+201
View File
@@ -0,0 +1,201 @@
#[derive(Clone, Debug)]
pub struct KanaSet {
name: String,
pub(crate) chars_type: KanaType,
pub(crate) dictionary: Vec<Vec<(String, String)>>,
pub(crate) include_map: [bool; 10],
}
#[derive(Clone, Debug)]
pub enum KanaType {
Hiragana,
Katakana,
}
impl KanaSet {
pub fn hiragana() -> Self {
Self {
name: "Хирагана".to_string(),
chars_type: KanaType::Hiragana,
dictionary: vec![
vec![
(String::from("ぁ"), String::from("a")),
(String::from("ぃ"), String::from("i")),
(String::from("ぅ"), String::from("u")),
(String::from("ぇ"), String::from("e")),
(String::from("ぉ"), String::from("o")),
],
vec![
(String::from("さ"), String::from("sa")),
(String::from("し"), String::from("shi")),
(String::from("す"), String::from("su")),
(String::from("せ"), String::from("se")),
(String::from("そ"), String::from("so")),
],
vec![
(String::from("か"), String::from("ka")),
(String::from("き"), String::from("ki")),
(String::from("く"), String::from("ku")),
(String::from("け"), String::from("ke")),
(String::from("こ"), String::from("ko")),
],
// Ряд «та» (たちつてと)
vec![
(String::from("た"), String::from("ta")),
(String::from("ち"), String::from("chi")),
(String::from("つ"), String::from("tsu")),
(String::from("て"), String::from("te")),
(String::from("と"), String::from("to")),
],
// Ряд «на» (なにぬねの)
vec![
(String::from("な"), String::from("na")),
(String::from("に"), String::from("ni")),
(String::from("ぬ"), String::from("nu")),
(String::from("ね"), String::from("ne")),
(String::from("の"), String::from("no")),
],
// Ряд «ха» (はひふへほ)
vec![
(String::from("は"), String::from("ha")),
(String::from("ひ"), String::from("hi")),
(String::from("ふ"), String::from("fu")),
(String::from("へ"), String::from("he")),
(String::from("ほ"), String::from("ho")),
],
// Ряд «ма» (まみむめも)
vec![
(String::from("ま"), String::from("ma")),
(String::from("み"), String::from("mi")),
(String::from("む"), String::from("mu")),
(String::from("め"), String::from("me")),
(String::from("も"), String::from("mo")),
],
// Ряд «я» (やゆよ) — только 3 символа
vec![
(String::from("や"), String::from("ya")),
(String::from("ゆ"), String::from("yu")),
(String::from("よ"), String::from("yo")),
],
// Ряд «ра» (らりるれろ)
vec![
(String::from("ら"), String::from("ra")),
(String::from("り"), String::from("ri")),
(String::from("る"), String::from("ru")),
(String::from("れ"), String::from("re")),
(String::from("ろ"), String::from("ro")),
],
vec![
(String::from("わ"), String::from("wa")),
(String::from("を"), String::from("wo")),
(String::from("ん"), String::from("n")),
],
],
include_map: [true; 10],
}
}
pub fn katakana() -> Self {
Self {
name: "Катакана".to_string(),
chars_type: KanaType::Katakana,
dictionary: vec![
vec![
(String::from("ァ"), String::from("a")),
(String::from("ィ"), String::from("i")),
(String::from("ゥ"), String::from("u")),
(String::from("ェ"), String::from("e")),
(String::from("ォ"), String::from("o")),
],
vec![
(String::from("サ"), String::from("sa")),
(String::from("シ"), String::from("shi")),
(String::from("ス"), String::from("su")),
(String::from("セ"), String::from("se")),
(String::from("ソ"), String::from("so")),
],
vec![
(String::from("カ"), String::from("ka")),
(String::from("キ"), String::from("ki")),
(String::from("ク"), String::from("ku")),
(String::from("ケ"), String::from("ke")),
(String::from("コ"), String::from("ko")),
],
// Ряд «та» (タチツテト)
vec![
(String::from("タ"), String::from("ta")),
(String::from("チ"), String::from("chi")),
(String::from("ツ"), String::from("tsu")),
(String::from("テ"), String::from("te")),
(String::from("ト"), String::from("to")),
],
// Ряд «на» (ナニヌネノ)
vec![
(String::from("ナ"), String::from("na")),
(String::from("ニ"), String::from("ni")),
(String::from("ヌ"), String::from("nu")),
(String::from("ネ"), String::from("ne")),
(String::from("ノ"), String::from("no")),
],
// Ряд «ха» (ハヒフヘホ)
vec![
(String::from("ハ"), String::from("ha")),
(String::from("ヒ"), String::from("hi")),
(String::from("フ"), String::from("fu")),
(String::from("ヘ"), String::from("he")),
(String::from("ホ"), String::from("ho")),
],
// Ряд «ма» (マミムメモ)
vec![
(String::from("マ"), String::from("ma")),
(String::from("ミ"), String::from("mi")),
(String::from("ム"), String::from("mu")),
(String::from("メ"), String::from("me")),
(String::from("モ"), String::from("mo")),
],
// Ряд «я» (ヤユヨ) — только 3 символа
vec![
(String::from("ヤ"), String::from("ya")),
(String::from("ユ"), String::from("yu")),
(String::from("ヨ"), String::from("yo")),
],
// Ряд «ра» (ラリルレロ)
vec![
(String::from("ラ"), String::from("ra")),
(String::from("リ"), String::from("ri")),
(String::from("ル"), String::from("ru")),
(String::from("レ"), String::from("re")),
(String::from("ロ"), String::from("ro")),
],
vec![
(String::from("ワ"), String::from("wa")),
(String::from("ヲ"), String::from("wo")),
(String::from("ン"), String::from("n")),
],
],
include_map: [true; 10],
}
}
pub fn list(&self) -> Vec<(String, String)> {
let mut current_set: Vec<(String, String)> = Vec::new();
for i in 0..10 {
if self.include_map[i] {
self.dictionary[i].iter().for_each(|v| {
current_set.push(v.clone());
});
}
}
current_set
}
}
impl Default for KanaSet {
fn default() -> Self {
KanaSet::hiragana()
}
}
impl PartialEq<Self> for KanaSet {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
+247
View File
@@ -0,0 +1,247 @@
use crate::data::entities::CardState;
use chrono::{Local, TimeDelta};
use fsrs::FSRS;
use rand::distr::weighted::WeightedIndex;
use rand::prelude::ThreadRng;
use rand::prelude::{Distribution, SliceRandom};
use rand::{RngExt, rng};
const MAX_HISTORY_LEN: usize = 20;
const MAX_HISTORY_LEN_PART: f32 = 0.33;
pub const MAX_SCORE: u8 = 25;
pub const FADE_PER_DAY: f32 = 0.95;
const PARTIAL_POOL_SIZE: usize = 10;
#[derive(Clone, PartialEq, Copy, Eq)]
pub enum OrderMode {
Default,
TrainWorstFirst,
FullRandom,
}
#[derive(Clone, Copy, Eq, PartialEq, Default)]
pub enum AppendMode {
Full,
#[default]
Manual,
}
#[derive(Clone)]
pub enum OrderModule {
SemiRandomSRS(SemiRandomSRSModule),
RandomSRS(RandomSRSModule),
WorstWordsSRS(WorstWordsSRSModule),
Fsrs(FsrsModule),
}
pub trait SRSModule {
fn new(set: &[&CardState]) -> Self;
fn next(&mut self, set: &[&CardState]) -> usize;
fn open(&mut self, status: WordOpenMode, index: usize, updated_word: &mut CardState);
}
#[derive(Clone)]
pub struct RandomSRSModule {
basket: Vec<usize>,
}
impl SRSModule for RandomSRSModule {
fn new(set: &[&CardState]) -> Self {
let mut basket = (0..set.len()).collect::<Vec<usize>>();
basket.shuffle(&mut rand::rng());
Self { basket }
}
fn next(&mut self, set: &[&CardState]) -> usize {
match self.basket.pop() {
Some(index) => index,
None => {
self.basket = (0..set.len()).collect::<Vec<usize>>();
self.basket.shuffle(&mut rand::rng());
self.basket.pop().expect("empty basket")
}
}
}
fn open(&mut self, _: WordOpenMode, _: usize, _: &mut CardState) {}
}
#[derive(Clone)]
pub struct SemiRandomSRSModule {
history: Vec<usize>,
last_weights: WeightedIndex<f32>,
generator: ThreadRng,
}
impl SRSModule for SemiRandomSRSModule {
fn new(set: &[&CardState]) -> Self {
let weights = set
.iter()
.map(|s| (100.0 / s.calculated_score()).powf(2.0) * 2.0)
.collect::<Vec<f32>>();
Self {
generator: rng(),
last_weights: WeightedIndex::new(weights).expect("Incorrect weights update"),
history: vec![],
}
}
fn next(&mut self, set: &[&CardState]) -> usize {
let index = self.last_weights.sample(&mut self.generator);
if self.history.contains(&index) && self.history.len() != 1 {
return self.next(set);
}
if self.history.len() == self.history_len(set) {
self.history.remove(0);
}
self.history.push(index);
index
}
fn open(&mut self, _: WordOpenMode, index: usize, word: &mut CardState) {
let new_weight = (100.0 / word.calculated_score()).powf(2.0);
self.last_weights
.update_weights(&[(index, &new_weight)])
.expect("update weights error");
}
}
impl SemiRandomSRSModule {
fn history_len(&self, set: &[&CardState]) -> usize {
((set.len() as f32 * MAX_HISTORY_LEN_PART) as usize).clamp(1, MAX_HISTORY_LEN)
}
}
#[derive(Clone)]
pub struct WorstWordsSRSModule {
pool_size: usize,
rounds_remaining: u8,
pool: Vec<usize>,
queue: Vec<usize>,
rounds_count: u8,
}
impl SRSModule for WorstWordsSRSModule {
fn new(_: &[&CardState]) -> Self {
WorstWordsSRSModule {
pool_size: PARTIAL_POOL_SIZE,
rounds_count: 2,
rounds_remaining: 0,
pool: vec![],
queue: vec![],
}
}
fn next(&mut self, set: &[&CardState]) -> usize {
if self.rounds_remaining == 0 {
self.fill_pool(set);
self.rounds_remaining = self.rounds_count;
}
match self.queue.pop() {
Some(index) => index,
None => {
self.queue.append(&mut self.pool.clone());
self.rounds_remaining -= 1;
self.queue.pop().expect("empty queue")
}
}
}
fn open(&mut self, _: WordOpenMode, _: usize, _: &mut CardState) {}
}
impl WorstWordsSRSModule {
fn fill_pool(&mut self, set: &[&CardState]) {
let mut sorted = set.iter().enumerate().collect::<Vec<_>>();
sorted.sort_by_key(|c| c.1.score);
let mut worst = sorted[0..self.pool_size]
.iter()
.map(|(index, _)| *index)
.collect::<Vec<usize>>();
worst.shuffle(&mut rand::rng());
self.pool = worst;
}
}
#[derive(Clone)]
pub struct FsrsModule {
next_round: Vec<usize>,
queue: Vec<usize>,
rng: ThreadRng,
fsrs: FSRS,
}
impl SRSModule for FsrsModule {
fn new(set: &[&CardState]) -> Self {
let today = Local::now().date_naive();
let mut new = Self {
queue: set
.iter()
.enumerate()
.filter(|(_, c)| c.fsrs_open_day <= today)
.map(|(index, _)| index)
.collect(),
next_round: vec![],
rng: rng(),
fsrs: FSRS::default(),
};
new.queue.shuffle(&mut new.rng);
new
}
fn next(&mut self, _: &[&CardState]) -> usize {
if self.queue.is_empty() {
self.fill_queue();
self.queue.shuffle(&mut self.rng);
}
self.queue.pop().unwrap_or(0)
}
fn open(&mut self, status: WordOpenMode, index: usize, updated_word: &mut CardState) {
let today = Local::now().date_naive();
let delta = today - updated_word.fsrs_opened;
let delta_days = delta.num_days();
let memory = self
.fsrs
.next_states(updated_word.fsrs_memory, 0.9, delta_days as u32)
.expect("Wrong next stage calculation");
let next = match status {
WordOpenMode::Easy => memory.easy,
WordOpenMode::Ok => memory.good,
WordOpenMode::Hard => memory.hard,
WordOpenMode::None => memory.again,
};
let mut next_day = today + TimeDelta::days(next.interval.round() as i64);
if (next_day - today).num_days() >= 5 {
let offset = ((rng().random::<f32>() - 0.5) * 3.0).round() as i32;
next_day += TimeDelta::days(offset as i64);
}
let first_time = updated_word.fsrs_memory.is_none();
updated_word.fsrs_memory = Some(next.memory);
updated_word.fsrs_open_day = next_day;
updated_word.fsrs_opened = updated_word.last_open.date_naive();
if (updated_word.fsrs_open_day == today && !first_time)
|| (first_time && status != WordOpenMode::Easy)
{
self.next_round.push(index);
}
}
}
impl FsrsModule {
fn fill_queue(&mut self) {
self.queue.append(&mut self.next_round);
}
pub fn finished(&self) -> bool {
self.queue.is_empty() && self.next_round.is_empty()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum WordOpenMode {
Easy,
Ok,
Hard,
None,
}
+82
View File
@@ -0,0 +1,82 @@
pub mod entities;
pub mod repositories;
use rusqlite::ToSql;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef};
use std::fmt::{Display, Formatter};
use std::num::ParseIntError;
use std::str::FromStr;
macro_rules! id_impl {
($id_type:ty, $name:ident, $invalid:ident) => {
#[derive(Clone, PartialEq, Eq, Debug, Hash, Copy, Ord, PartialOrd, Default)]
pub struct $name(pub(crate) $id_type);
pub const $invalid: $name = $name(0);
impl FromStr for $name {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok($name(s.parse::<$id_type>()?))
}
}
impl From<$id_type> for $name {
fn from(value: $id_type) -> Self {
$name(value)
}
}
impl From<$name> for $id_type {
fn from(value: $name) -> Self {
value.0
}
}
impl Display for $name {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromSql for $name {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
match value {
ValueRef::Integer(i) => Ok($name(i as $id_type)),
_ => Err(FromSqlError::InvalidType),
}
}
}
impl ToSql for $name {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0 as i64))
}
}
impl From<$name> for String {
fn from(value: $name) -> Self {
value.0.to_string()
}
}
impl $name {
pub(crate) fn is_valid(self) -> bool {
self.0 != 0
}
}
};
}
id_impl!(u32, Id, INVALID_ID);
id_impl!(u16, SmallId, INVALID_SMALL_ID);
impl From<SmallId> for Id {
fn from(value: SmallId) -> Self {
Self(value.0 as u32)
}
}
impl From<Id> for SmallId {
fn from(value: Id) -> Self {
Self(value.0 as u16)
}
}
+332
View File
@@ -0,0 +1,332 @@
use crate::core::srs::{FADE_PER_DAY, MAX_SCORE, WordOpenMode};
use crate::data::{INVALID_ID, Id};
use crate::data_provider::history::HistoryRepositoryInner;
use chrono::{DateTime, Local, NaiveDate, Utc};
use fsrs::MemoryState;
use litemap::LiteMap;
use log::info;
use rhai::Engine;
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
use std::sync::Arc;
use std::time::Instant;
#[derive(Clone, Debug)]
pub struct WordData {
pub id: Id,
pub group_id: Id,
pub key: String,
pub value: String,
pub tags: String,
pub additional: LiteMap<Id, String>,
}
impl EntityData for WordData {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
#[derive(Clone)]
pub struct WordGroup {
pub id: Id,
pub name: String,
}
impl EntityData for WordGroup {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
impl WordData {
pub fn new() -> Self {
Self {
id: INVALID_ID,
key: String::new(),
value: String::new(),
tags: String::new(),
additional: Default::default(),
group_id: 1.into(),
}
}
}
#[derive(Clone)]
pub struct HistoryNote {
pub id: Id,
pub timestamp: DateTime<Utc>,
pub deck_id: Id,
pub word_id: Id,
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;
}
}
impl HistoryRepositoryInner {
pub fn activity_of_deck(&self, id: Id) -> Vec<(NaiveDate, u32)> {
let mut query = self.query(Arc::new(move |note| note.deck_id == id));
let values = query.iter(self).collect::<Vec<_>>();
let mut counts = vec![];
if !values.is_empty() {
let mut date = values[0].timestamp.with_timezone(&Local).date_naive();
let mut count = 0;
for note in values {
let current_date = note.timestamp.with_timezone(&Local).date_naive();
if date == current_date {
count += 1;
} else {
counts.push((date, count));
date = current_date;
count = 0;
}
}
if count != 0 {
counts.push((date, count));
}
}
counts
}
}
#[derive(Clone)]
pub struct DeckSettings {
pub id: Id,
pub name: String,
pub forward: String,
pub backward: String,
pub filter: String,
}
impl EntityData for DeckSettings {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
impl DeckSettings {
pub(crate) fn with_name(name: String) -> DeckSettings {
DeckSettings {
id: 0.into(),
name,
forward: String::new(),
backward: String::new(),
filter: "true".to_string(),
}
}
pub fn check_filter(&self) -> bool {
let time = Instant::now();
let engine = Engine::new();
let ast = engine.compile(&self.filter);
info!("Compile time is {:?}", time.elapsed());
ast.is_ok()
}
pub fn require_speech(&self) -> bool {
self.forward.contains("speech") || self.backward.contains("speech")
}
}
#[derive(Clone, PartialEq)]
pub struct CardState {
pub id: Id,
pub word_id: Id,
pub deck_id: Id,
pub last_open: DateTime<Utc>,
pub score: u8,
pub fsrs_opened: NaiveDate,
pub fsrs_memory: Option<MemoryState>,
pub fsrs_open_day: NaiveDate,
}
impl EntityData for CardState {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
impl CardState {
pub fn update(&mut self, status: WordOpenMode) {
match status {
WordOpenMode::Easy => {
self.score = (self.calculated_score() + 5.0).round() as u8;
}
WordOpenMode::Ok => self.score = (self.calculated_score() + 2.0).round() as u8,
WordOpenMode::Hard => {
self.score = (self.calculated_score() * 0.75).round() as u8;
}
WordOpenMode::None => {
self.score = (self.calculated_score() * 0.4) as u8;
}
}
self.score = self.score.clamp(1, MAX_SCORE);
self.last_open = Utc::now();
}
pub fn calculated_score(&self) -> f32 {
let time = Utc::now() - self.last_open;
let days = time.num_days();
let multiplier = FADE_PER_DAY.powi(days as i32);
(self.score as f32 * multiplier).max(1.0)
}
}
#[derive(Clone)]
pub struct CustomField {
pub id: Id,
pub name: String,
pub identifier: String,
}
impl EntityData for CustomField {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
#[derive(Clone)]
pub struct Exam {
pub id: Id,
pub timestamp: DateTime<Utc>,
pub deck_id: Id,
}
impl EntityData for Exam {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
#[derive(Clone)]
pub struct ExamEntry{
pub id: Id,
pub exam_id: Id,
pub word_id: Id,
pub status: Option<bool>
}
impl EntityData for ExamEntry {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
pub struct EntityHandle<E> {
pub(crate) id: Id,
index: u32,
pub(crate) _phantom: PhantomData<E>,
}
impl<T> Clone for EntityHandle<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for EntityHandle<T> {}
impl<T> EntityHandle<T> {
pub fn new(id: Id, index: usize, real: bool) -> Self {
Self {
id,
index: ((index as u32) << 1 >> 1) | (!real as u32) << 31,
_phantom: Default::default(),
}
}
pub fn from_id(id: Id) -> Self {
Self::new(id, id.0 as usize, true)
}
pub fn index(&self) -> usize {
(self.index << 1 >> 1) as usize
}
pub fn set_index(&mut self, index: usize) {
self.index = (self.index >> 31 << 31) | ((index as u32) << 1 >> 1);
}
pub fn real(&self) -> bool {
(self.index >> 31) == 0
}
pub fn set_real(&mut self, value: bool) {
self.index = self.index << 1 >> 1 | (!value as u32) << 31;
}
}
impl<T> Eq for EntityHandle<T> {}
impl<T> PartialEq<Self> for EntityHandle<T> {
fn eq(&self, other: &Self) -> bool {
self.real() == other.real() && self.id == other.id
}
}
impl<T> PartialOrd<Self> for EntityHandle<T> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl<T> Ord for EntityHandle<T> {
fn cmp(&self, other: &Self) -> Ordering {
if self.real() && !other.real() {
return Ordering::Less;
}
if !self.real() && other.real() {
return Ordering::Greater;
}
self.id.cmp(&other.id)
}
}
impl<T> Hash for EntityHandle<T> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
self.real().hash(state);
}
}
pub trait EntityData {
fn id(&self) -> Id;
fn set_id(&mut self, id: Id);
}
+732
View File
@@ -0,0 +1,732 @@
use crate::data::Id;
use crate::data::entities::{EntityData, EntityHandle};
use crate::data_provider::sqlite::SqliteConnection;
use crate::helpers::into_result;
use crate::notification::{MaybeError, NotificationData};
use log::debug;
use parking_lot::{Mutex, RwLock};
use rayon::iter::IndexedParallelIterator;
use rayon::iter::IntoParallelRefIterator;
use rayon::iter::ParallelIterator;
use rusqlite::Connection;
use std::marker::PhantomData;
use std::ops::{Deref, Index, IndexMut};
use std::sync::Arc;
use std::time::Instant;
pub trait Repository<T, D>
where
D: DbLayer<T>,
{
fn get(&self, handle: &mut EntityHandle<T>) -> Option<T>;
fn get_ref(&self, handle: &mut EntityHandle<T>) -> Option<&T>;
fn get_mut(&mut self, handle: &mut EntityHandle<T>) -> Option<&mut T>;
fn get_static(&self, handle: EntityHandle<T>) -> Option<T>;
fn get_ref_static(&self, handle: EntityHandle<T>) -> Option<&T>;
fn get_mut_static(&mut self, handle: EntityHandle<T>) -> Option<&mut T>;
fn set(&mut self, handle: &mut EntityHandle<T>, value: T);
fn set_static(&mut self, handle: EntityHandle<T>, value: T);
fn handle_by_id(&self, id: Id) -> EntityHandle<T>;
fn create(&mut self, new: T) -> EntityHandle<T>;
fn create_now(&mut self, new: T) -> Result<EntityHandle<T>, NotificationData>;
fn save(&mut self, entity: &mut EntityHandle<T>) -> MaybeError;
fn delete(&mut self, handle: EntityHandle<T>) -> MaybeError;
fn find<F>(&self, predicate: F) -> Option<EntityHandle<T>>
where
F: Fn(&T) -> bool;
fn init(&mut self) -> MaybeError;
fn start(&mut self) -> MaybeError;
fn reload(&mut self) -> MaybeError;
fn shifts(&self) -> usize;
fn iter<'a>(&'a self) -> impl Iterator<Item = &'a T>
where
T: 'a;
fn iter_mut<'a>(&'a mut self) -> impl Iterator<Item = &'a mut T>
where
T: 'a;
}
struct QueryManger<T, R, D>
where
R: Repository<T, D>,
D: DbLayer<T>,
{
queries: Vec<Arc<Mutex<Query<T, R, D>>>>,
}
impl<T, R, D> QueryManger<T, R, D>
where
R: Repository<T, D>,
D: DbLayer<T>,
T: EntityData,
{
pub(crate) fn handle_save(&self, old: EntityHandle<T>, new: &mut EntityHandle<T>, data: &T) {
return;
if old.real() && new.real() {
return;
}
let affected = self.get_affected_queries(data);
for q in affected {
let mut query = self.queries[q].lock();
let index = query.cache.binary_search(&old).unwrap();
query.cache.remove(index);
let real_end = query.cache.iter().rposition(|r| r.real()).unwrap() + 1;
query.cache.insert(real_end, new.clone());
}
}
pub(crate) fn handle_delete(&self, handle: &EntityHandle<T>, data: &T) {
return;
let affected = self.get_affected_queries(data);
for q in affected {
let mut query = self.queries[q].lock();
let slice = query.cache.as_slice();
if let Ok(index) = slice.binary_search(&handle) {
query.cache.remove(index);
}
}
}
fn get_affected_queries(&self, data: &T) -> Vec<usize> {
self.queries
.iter()
.enumerate()
.filter(|(_, q)| {
let query = q.lock();
let rule = &query.rule;
rule(data)
})
.map(|(i, _)| i)
.collect()
}
fn reload_queries(&mut self, data_repository: impl Repository<T, D>) {
for q in &self.queries {
let mut query = q.lock();
query.cache.clear();
}
}
}
#[derive(Clone)]
pub struct Query<T, R, D>
where
R: Repository<T, D>,
D: DbLayer<T>,
{
cache: Vec<EntityHandle<T>>,
rule: Arc<dyn Fn(&T) -> bool + Send + Sync>,
parallel: bool,
_marker: PhantomData<(R, D)>,
shift_stamp: usize,
}
impl<T: EntityData + Clone + Send + Sync, R: Repository<T, D>, D: DbLayer<T>> Query<T, R, D> {
pub fn iter<'a>(
&mut self,
data_repository_inner: &'a DataRepositoryInner<T, D>,
) -> impl Iterator<Item = &'a T>
where
T: 'a,
{
if self.shift_stamp != data_repository_inner.shifts() {
self.load(data_repository_inner);
}
self.cache
.iter_mut()
.filter_map(move |e| data_repository_inner.get_ref(e))
}
pub fn iter_with_handles<'a>(
&mut self,
data_repository_inner: &'a DataRepositoryInner<T, D>,
) -> impl Iterator<Item = (&'a T, EntityHandle<T>)>
where
T: 'a,
{
if self.shift_stamp != data_repository_inner.shifts() {
self.load(data_repository_inner);
}
self.cache
.iter_mut()
.filter_map(move |e| Some((data_repository_inner.get_ref(e)?, *e)))
}
pub fn for_each_mut<F>(&mut self, repo: &mut DataRepositoryInner<T, D>, mut f: F)
where
F: FnMut(&mut T),
{
if self.shift_stamp != repo.shifts() {
self.load(repo);
}
for handle in &mut self.cache {
if let Some(entity) = repo.get_mut(handle) {
f(entity);
}
}
}
pub fn load(&mut self, data_repository: &DataRepositoryInner<T, D>)
where
T: Send + Sync,
{
let time = Instant::now();
self.cache.clear();
if self.parallel {
self.cache = data_repository
.cache
.par_iter()
.enumerate()
.filter(|(_, entity)| self.rule.as_ref()(entity))
.map(|(i, entity)| EntityHandle::new(entity.id(), i, true))
.collect();
} else {
self.cache = data_repository
.cache
.iter()
.enumerate()
.filter(|(_, entity)| (self.rule)(entity))
.map(|(i, entity)| EntityHandle::new(entity.id(), i, true))
.collect();
}
self.cache.extend(
data_repository
.temporal
.iter()
.enumerate()
.filter(|(_, entity)| (self.rule)(entity))
.map(|(i, entity)| EntityHandle::new(entity.id(), i, false))
.collect::<Vec<_>>(),
);
debug!(
"Query for {} elements (filtered {}) takes {:?}",
data_repository.len(),
self.cache.len(),
time.elapsed()
);
}
pub fn handles(&self) -> impl Iterator<Item = EntityHandle<T>> {
self.cache.iter().cloned()
}
pub fn len(&self) -> usize {
self.cache.len()
}
pub fn get(&self, index: usize) -> Option<EntityHandle<T>> {
self.cache.get(index).cloned()
}
}
impl<T: EntityData + Clone + Send + Sync, R: Repository<T, D>, D: DbLayer<T>> Index<usize>
for Query<T, R, D>
{
type Output = EntityHandle<T>;
fn index(&self, index: usize) -> &Self::Output {
&self.cache[index]
}
}
impl<T: EntityData + Clone + Send + Sync, R: Repository<T, D>, D: DbLayer<T>> IndexMut<usize>
for Query<T, R, D>
{
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.cache[index]
}
}
pub trait BatchedRepository<T, D>
where
D: BatchedDbLayer<T>,
T: EntityData,
{
fn create_now_many(
&mut self,
entities: &mut [T],
) -> Result<Vec<EntityHandle<T>>, NotificationData>;
fn save_many(&mut self, handles: &mut [EntityHandle<T>]) -> MaybeError;
fn delete_many(&mut self, handles: &mut [EntityHandle<T>]) -> MaybeError;
}
pub trait DbLayer<T> {
fn write_entity(&self, entity: &T, connection: &Connection) -> MaybeError;
fn add_entity(&mut self, entity: &mut T, connection: &Connection) -> MaybeError;
fn delete_entity(&mut self, id: Id, connection: &Connection) -> MaybeError;
fn load_all(&self, storage: &mut Vec<T>, connection: &Connection) -> MaybeError;
}
pub trait BatchedDbLayer<T>
where
T: EntityData,
{
fn delete_many_entities(&mut self, ids: &[Id], connection: &mut Connection) -> MaybeError;
fn add_many_entities(&mut self, entity: &mut [T], connection: &mut Connection) -> MaybeError;
}
#[derive(Clone)]
pub struct DataRepository<T: Clone + EntityData, D: DbLayer<T>> {
inner: Arc<RwLock<DataRepositoryInner<T, D>>>,
}
pub struct DataRepositoryInner<T: Clone + EntityData, D: DbLayer<T>> {
cache: Vec<T>,
temporal: Vec<T>,
next_temporal: u32,
connection: Arc<Mutex<SqliteConnection>>,
data_layer: D,
query_manger: QueryManger<T, Self, D>,
version: u16,
shifts: usize,
}
impl<T: Clone + EntityData, D: DbLayer<T> + Default> DataRepository<T, D> {
pub fn new(connection: Arc<Mutex<SqliteConnection>>) -> DataRepository<T, D> {
Self {
inner: Arc::new(RwLock::new(DataRepositoryInner {
cache: vec![],
temporal: vec![],
next_temporal: 0,
connection,
data_layer: Default::default(),
query_manger: QueryManger { queries: vec![] },
version: 0,
shifts: 0,
})),
}
}
}
impl<T: Clone + EntityData, D: DbLayer<T>> Deref for DataRepository<T, D> {
type Target = Arc<RwLock<DataRepositoryInner<T, D>>>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T: EntityData + Clone + std::marker::Send + std::marker::Sync, D: DbLayer<T>>
DataRepositoryInner<T, D>
{
pub fn query_par(
&self,
rule: Arc<dyn Fn(&T) -> bool + Send + Sync>,
) -> Query<T, DataRepositoryInner<T, D>, D>
where
T: Send + Sync,
{
let mut query = Query {
cache: vec![],
rule,
parallel: true,
_marker: PhantomData,
shift_stamp: self.shifts,
};
query.load(self);
query
}
pub fn query(
&self,
rule: Arc<dyn Fn(&T) -> bool + Send + Sync>,
) -> Query<T, DataRepositoryInner<T, D>, D>
where
T: Send + Sync,
{
let mut query = Query {
cache: vec![],
rule,
parallel: false,
_marker: PhantomData,
shift_stamp: self.shifts,
};
query.load(self);
query
}
pub fn empty_query(&self) -> Query<T, DataRepositoryInner<T, D>, D> {
Query {
cache: vec![],
rule: Arc::new(|_| false),
parallel: false,
_marker: Default::default(),
shift_stamp: 0,
}
}
pub fn empty_query_par(&self) -> Query<T, DataRepositoryInner<T, D>, D> {
Query {
cache: vec![],
rule: Arc::new(|_| false),
parallel: true,
_marker: Default::default(),
shift_stamp: 0,
}
}
pub fn full_query(&self) -> Query<T, DataRepositoryInner<T, D>, D> {
self.query(Arc::new(|_| true))
}
pub fn len(&self) -> usize {
self.cache.len() + self.temporal.len()
}
}
impl<T: EntityData + Clone, D: DbLayer<T>> Repository<T, D> for DataRepositoryInner<T, D> {
fn get(&self, handle: &mut EntityHandle<T>) -> Option<T> {
let slice = if handle.real() {
self.cache.as_slice()
} else {
self.temporal.as_slice()
};
let index = fast_position_of(slice, handle)?;
Some(slice[index].clone())
}
fn get_ref(&self, handle: &mut EntityHandle<T>) -> Option<&T> {
let slice = if handle.real() {
self.cache.as_slice()
} else {
self.temporal.as_slice()
};
let index = fast_position_of(slice, handle)?;
Some(&slice[index])
}
fn get_mut(&mut self, handle: &mut EntityHandle<T>) -> Option<&mut T> {
let slice = if handle.real() {
self.cache.as_mut_slice()
} else {
self.temporal.as_mut_slice()
};
let index = fast_position_of(slice, handle)?;
Some(&mut slice[index])
}
fn get_static(&self, mut handle: EntityHandle<T>) -> Option<T> {
self.get(&mut handle)
}
fn get_ref_static(&self, mut handle: EntityHandle<T>) -> Option<&T> {
self.get_ref(&mut handle)
}
fn get_mut_static(&mut self, mut handle: EntityHandle<T>) -> Option<&mut T> {
self.get_mut(&mut handle)
}
fn set(&mut self, handle: &mut EntityHandle<T>, value: T) {
let slice = if handle.real() {
self.cache.as_mut_slice()
} else {
self.temporal.as_mut_slice()
};
let index = fast_position_of(slice, handle).unwrap();
slice[index] = value;
}
fn set_static(&mut self, mut handle: EntityHandle<T>, value: T) {
self.set(&mut handle, value)
}
fn handle_by_id(&self, id: Id) -> EntityHandle<T> {
EntityHandle::new(id, id.0 as usize, true)
}
fn create(&mut self, mut new: T) -> EntityHandle<T> {
let next_temporal = self.next_temporal;
self.next_temporal += 1;
let entity = EntityHandle::new(next_temporal.into(), self.temporal.len(), false);
new.set_id(entity.id);
self.temporal.push(new);
entity
}
fn create_now(&mut self, mut new: T) -> Result<EntityHandle<T>, NotificationData> {
self.data_layer
.add_entity(&mut new, &self.connection.lock())?;
let index = self.cache.len();
let handle = EntityHandle::new(new.id(), index, true);
self.cache.push(new);
Ok(handle)
}
fn save(&mut self, entity: &mut EntityHandle<T>) -> MaybeError {
let original_state = *entity;
if entity.real() {
let data = self.get_ref(entity).unwrap();
self.data_layer
.write_entity(data, &self.connection.lock())?;
} else {
let index = fast_position_of(self.temporal.as_slice(), entity).unwrap();
let data = &mut self.temporal[index];
self.data_layer.add_entity(data, &self.connection.lock())?;
entity.set_real(true);
entity.id = data.id();
entity.set_index(self.cache.len());
self.query_manger.handle_save(original_state, entity, data);
let data = self.temporal.remove(index);
self.cache.push(data);
self.shifts += 1;
}
Ok(())
}
fn delete(&mut self, mut handle: EntityHandle<T>) -> MaybeError {
let vec = if handle.real() {
self.data_layer
.delete_entity(handle.id, &self.connection.lock())?;
&mut self.cache
} else {
&mut self.temporal
};
let index = into_result(
fast_position_of(vec.as_slice(), &mut handle),
NotificationData::DbSaveError,
)?;
let value = vec.remove(index);
self.query_manger.handle_delete(&handle, &value);
self.shifts += 1;
Ok(())
}
fn find<F>(&self, predicate: F) -> Option<EntityHandle<T>>
where
F: Fn(&T) -> bool,
{
if let Some(index) = self.cache.iter().position(&predicate) {
let value = &self.cache[index];
return Some(EntityHandle::new(value.id(), index, true));
}
if let Some(index) = self.temporal.iter().position(predicate) {
let value = &self.temporal[index];
return Some(EntityHandle::new(value.id(), index, false));
}
None
}
fn init(&mut self) -> MaybeError {
self.data_layer
.load_all(&mut self.cache, &self.connection.lock())?;
self.version += 1;
Ok(())
}
fn start(&mut self) -> MaybeError {
if self.version != 0 {
return Ok(());
}
self.init()?;
Ok(())
}
fn reload(&mut self) -> MaybeError {
self.cache.clear();
self.temporal.clear();
self.init()?;
Ok(())
}
fn shifts(&self) -> usize {
self.shifts
}
fn iter<'a>(&'a self) -> impl Iterator<Item = &'a T>
where
T: 'a,
{
self.cache.iter().chain(self.temporal.iter())
}
fn iter_mut<'a>(&'a mut self) -> impl Iterator<Item = &'a mut T>
where
T: 'a,
{
self.cache.iter_mut().chain(self.temporal.iter_mut())
}
}
impl<T: EntityData + Clone, D: BatchedDbLayer<T> + DbLayer<T>> BatchedRepository<T, D>
for DataRepositoryInner<T, D>
{
fn create_now_many(
&mut self,
entities: &mut [T],
) -> Result<Vec<EntityHandle<T>>, NotificationData> {
self.data_layer
.add_many_entities(entities, &mut self.connection.lock())?;
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<T>]) -> MaybeError {
let mut temp = Vec::with_capacity(handles.len() / 2);
let mut cached = Vec::with_capacity(handles.len() / 2);
handles.sort_by_key(|a| a.id);
handles.iter_mut().for_each(|handle| {
if handle.real() {
cached.push(handle)
} else {
temp.push(handle)
}
});
let mut temp_values = Vec::with_capacity(temp.len());
let mut cell = 0;
let len = temp.len();
self.temporal.retain(|x| {
if cell == len {
return true;
}
let find = temp[cell].id;
let remove = x.id() == find;
if remove {
cell += 1;
temp_values.push(x.clone());
}
!remove
});
self.data_layer
.add_many_entities(&mut temp_values, &mut self.connection.lock())?;
let start = self.cache.len();
for (i, (handle, data)) in temp.into_iter().zip(&mut temp_values).enumerate() {
let old = *handle;
handle.id = data.id();
handle.set_index(start + i);
handle.set_real(true);
self.query_manger.handle_save(old, handle, data);
}
self.cache.extend(temp_values);
for handle in cached.into_iter() {
let h = handle;
let idx = fast_position_of(self.cache.as_slice(), h).unwrap();
self.data_layer
.write_entity(&self.cache[idx], &self.connection.lock())?;
}
self.shifts += 1;
Ok(())
}
fn delete_many(&mut self, handles: &mut [EntityHandle<T>]) -> MaybeError {
let (cached, temp) = split_temp_real(handles, |e| e.id);
let mut cell = 0;
let len = temp.len();
self.temporal.retain(|x| {
if cell == len {
return true;
}
let find = temp[cell];
let remove = x.id() == find;
if remove {
cell += 1;
}
!remove
});
self.data_layer
.delete_many_entities(cached.as_slice(), &mut self.connection.lock())?;
let len = cached.len();
cell = 0;
self.cache.retain(|x| {
if cell == len {
return true;
}
let find = cached[cell];
let remove = x.id() == find;
if remove {
cell += 1;
}
!remove
});
self.shifts += 1;
Ok(())
}
}
fn split_temp_real<T, F, V>(handles: &mut [EntityHandle<T>], getter: F) -> (Vec<V>, Vec<V>)
where
F: Fn(&EntityHandle<T>) -> V,
{
let mut temp = Vec::with_capacity(handles.len() / 2);
let mut cached = Vec::with_capacity(handles.len() / 2);
handles.sort_by_key(|a| a.id);
handles.iter().for_each(|handle| {
if handle.real() {
cached.push(getter(handle))
} else {
temp.push(getter(handle))
}
});
(cached, temp)
}
pub fn fast_position_of<E: EntityData>(
storage: &[E],
handle: &mut EntityHandle<E>,
) -> Option<usize> {
if storage.is_empty() {
return None;
}
let max_idx = storage.len() - 1;
let upper = (handle.index()).min(max_idx);
let peek = storage[upper].id();
if peek == handle.id {
handle.set_index(upper);
return Some(upper);
}
if peek.0 < handle.id.0 {
return None;
}
let target_id_val = handle.id.0 as i64;
let peek_val = peek.0 as i64;
let delta = peek_val - target_id_val;
let new_index = (upper as i64 - delta).max(0) as usize;
if storage[new_index].id() == handle.id {
handle.set_index(new_index);
return Some(new_index);
}
if new_index == upper {
return None;
}
let index = storage[new_index..upper]
.binary_search_by(|e| e.id().cmp(&handle.id))
.ok()?
+ new_index;
handle.set_index(index);
Some(index)
}
-75
View File
@@ -1,75 +0,0 @@
use crate::lang::{DeckSettings, OrderMode};
use rusqlite::Connection;
pub fn load_sets(connection: &Connection) -> Vec<DeckSettings> {
let mut stmt = connection
.prepare("SELECT id, name, forward, backward, filter FROM card_set")
.unwrap();
let iter = stmt
.query_map([], |row| {
Ok(DeckSettings {
id: row.get(0)?,
name: row.get(1)?,
forward: row.get(2)?,
backward: row.get(3)?,
filter: row.get(4)?,
count: None,
worst_words_list: None,
open_mode: OrderMode::Default,
})
})
.unwrap();
let mut buffer = vec![];
for word in iter {
buffer.push(word.unwrap());
}
buffer
}
pub fn add_set(set: &mut DeckSettings, connection: &Connection) {
let index : u32 = connection
.query_row(
"INSERT INTO card_set (name, forward, backward, filter) VALUES (?1, ?2, ?3, ?4) RETURNING id",
(
&set.name,
&set.forward,
&set.backward,
&set.filter,
),
|row| row.get(0)
)
.unwrap();
set.id = index.into();
}
pub fn update_deck(set: &mut DeckSettings, connection: &Connection) {
if !set.id.is_valid() {
add_set(set, connection);
} else {
connection
.execute(
"UPDATE card_set SET name = ?1, forward = ?2, backward = ?3, filter = ?4 WHERE id = ?5",
(
&set.name,
&set.forward,
&set.backward,
&set.filter,
&set.id
),
)
.unwrap();
}
}
pub fn delete_set(set: &DeckSettings, connection: &Connection) {
if !set.id.is_valid() {
return;
}
connection
.execute("DELETE FROM card_set WHERE id = ?1", (&set.id,))
.unwrap();
}
+103
View File
@@ -0,0 +1,103 @@
use crate::data::entities::CardState;
use crate::data::repositories::{DataRepository, DataRepositoryInner, Query};
use crate::data::Id;
use crate::data_access_methods;
use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use chrono::NaiveDate;
use fsrs::MemoryState;
use rusqlite::{Error, Params, Row};
use std::sync::Arc;
data_access_methods!(
CardStateDbLayer,
CardStateRepository,
CardStateRepositoryInner,
CardState,
CardStatesQuerySet,
CardStateQuery
);
#[derive(Clone)]
pub struct CardStatesQuerySet;
impl TableAccessQuerySet<CardState> for CardStatesQuerySet {
fn load() -> &'static str {
"SELECT id, word_id, deck_id, score, last_opened, fsrs_opened, fsrs_memory, fsrs_open_day FROM states"
}
fn save() -> &'static str {
"UPDATE states SET score = ?1, last_opened = ?2, fsrs_opened = ?3, fsrs_memory = ?4, fsrs_open_day = ?5 WHERE id = ?6"
}
fn add() -> &'static str {
"INSERT INTO states (word_id, deck_id, score, last_opened, fsrs_opened, fsrs_memory, fsrs_open_day) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
}
fn name() -> &'static str {
"states"
}
fn from_sql(row: &Row) -> Result<CardState, Error> {
Ok(CardState {
id: row.get(0)?,
word_id: row.get(1)?,
deck_id: row.get(2)?,
score: row.get(3)?,
fsrs_opened: NaiveDate::from_epoch_days(row.get::<usize, i32>(5)?)
.expect("Incorrect date encoding"),
fsrs_memory: get_memory(row.get(6)?),
last_open: row.get(4)?,
fsrs_open_day: NaiveDate::from_epoch_days(row.get::<usize, i32>(7)?)
.expect("Incorrect date encoding"),
})
}
fn insert_params(entity: &CardState) -> impl Params {
(
entity.word_id,
entity.deck_id,
entity.score,
entity.last_open.timestamp(),
entity.fsrs_opened.to_epoch_days(),
memory_to_i64(&entity.fsrs_memory),
entity.fsrs_open_day.to_epoch_days(),
)
}
fn update_params(entity: &CardState) -> impl Params {
(
entity.score,
entity.last_open.timestamp(),
entity.fsrs_opened.to_epoch_days(),
memory_to_i64(&entity.fsrs_memory),
entity.fsrs_open_day.to_epoch_days(),
entity.id,
)
}
}
fn get_memory(value: i64) -> Option<MemoryState> {
if value == 0 {
return None;
}
Some(MemoryState {
stability: f32::from_bits(value as u32),
difficulty: f32::from_bits((value >> 32) as u32),
})
}
fn memory_to_i64(value: &Option<MemoryState>) -> i64 {
let Some(value) = &value else {
return 0;
};
value.stability.to_bits() as i64 | ((value.difficulty.to_bits() as i64) << 32)
}
impl CardStateRepositoryInner {
pub fn deck_states(
&self,
deck_id: Id,
) -> Query<CardState, CardStateRepositoryInner, CardStateDbLayer> {
self.query(Arc::new(move |state| state.deck_id == deck_id))
}
}
-91
View File
@@ -1,91 +0,0 @@
use crate::lang::{CardStatistics, DeckSettings};
use rusqlite::{Connection, params};
use std::time::Instant;
pub fn load_stats_of_deck(set: &DeckSettings, connection: &Connection) -> Vec<CardStatistics> {
let mut stmt = connection
.prepare("SELECT id, word_id, score, last_opened FROM card_stats WHERE set_id = ?1")
.unwrap();
let iter = stmt
.query_map((set.id,), |row| {
Ok(CardStatistics {
id: row.get(0)?,
word_id: row.get(1)?,
set_id: set.id,
score: row.get(2)?,
last_open: row.get(3)?,
})
})
.unwrap();
let mut buffer = vec![];
for word in iter {
buffer.push(word.unwrap());
}
buffer
}
pub fn add_stat_list(stats: &mut [CardStatistics], connection: &mut Connection) {
let time = Instant::now();
let tx = connection.transaction().unwrap();
let count = stats.len();
{
let mut stmt = tx
.prepare(
"INSERT INTO card_stats (word_id, set_id, score, last_opened) VALUES (?1, ?2, ?3, ?4)",
)
.unwrap();
for stat in stats.iter() {
stmt.execute(params![
stat.word_id,
stat.set_id,
stat.score,
stat.last_open.timestamp()
])
.unwrap();
}
}
let last_index: u32 = tx.last_insert_rowid() as u32;
tx.commit().unwrap();
let start_index = last_index - (count as u32) + 1;
for (index, id) in (start_index..=last_index).enumerate() {
stats[index].id = id.into();
}
println!("Added {} cards for {:?}", stats.len(), time.elapsed());
}
pub fn update_stat_score(stat: &CardStatistics, connection: &Connection) {
let time = Instant::now();
connection
.execute(
"UPDATE card_stats SET score = ?1, last_opened = ?2 WHERE id = ?3",
(&stat.score, &stat.last_open.timestamp(), &stat.id),
)
.unwrap();
println!("Updated stat: {}", time.elapsed().as_millis());
}
pub fn delete_stats(stat: &[CardStatistics], connection: &Connection) {
if stat.is_empty() {
return;
}
let ids = stat
.iter()
.map(|s| s.id.into())
.collect::<Vec<String>>()
.join(", ");
connection
.execute(
format!("DELETE FROM card_stats WHERE id in ({ids})").as_str(),
(),
)
.unwrap();
}
+42
View File
@@ -0,0 +1,42 @@
use crate::data::entities::CustomField;
use crate::data::repositories::{DataRepository, DataRepositoryInner};
use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use rusqlite::{Error, Params, Row};
use crate::data_access_methods;
data_access_methods!(CustomFieldDbLayer, CustomFieldRepository, CustomFieldRepositoryInner, CustomField, CustomFieldsQuerySet, CustomFieldQuery);
#[derive(Clone)]
pub struct CustomFieldsQuerySet;
impl TableAccessQuerySet<CustomField> for CustomFieldsQuerySet {
fn load() -> &'static str {
"SELECT id, name, identifier FROM custom_fields"
}
fn save() -> &'static str {
"UPDATE custom_fields SET name = ?1, identifier = ?2 WHERE id = ?3"
}
fn add() -> &'static str {
"INSERT INTO custom_fields (name, identifier) VALUES (?1, ?2)"
}
fn name() -> &'static str {
"custom_fields"
}
fn from_sql(row: &Row) -> Result<CustomField, Error> {
Ok(CustomField {
id: row.get(0)?,
name: row.get(1)?,
identifier: row.get(2)?,
})
}
fn insert_params(entity: &CustomField) -> impl Params {
(&entity.name, &entity.identifier)
}
fn update_params(entity: &CustomField) -> impl Params {
(&entity.name, &entity.identifier, entity.id)
}
}
+55
View File
@@ -0,0 +1,55 @@
use crate::data::entities::DeckSettings;
use crate::data::repositories::DataRepository;
use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use rusqlite::{Error, Params, Row};
pub type DeckRepository = DataRepository<DeckSettings, SqliteDbLayer<DeckSettings, DeckQuerySet>>;
#[derive(Clone)]
pub struct DeckQuerySet;
impl TableAccessQuerySet<DeckSettings> for DeckQuerySet {
fn load() -> &'static str {
"SELECT id, name, forward, backward, filter FROM decks"
}
fn save() -> &'static str {
"UPDATE decks SET name = ?1, forward = ?2, backward = ?3, filter = ?4 WHERE id = ?5"
}
fn add() -> &'static str {
"INSERT INTO decks (name, forward, backward, filter) VALUES (?1, ?2, ?3, ?4)"
}
fn name() -> &'static str {
"decks"
}
fn from_sql(row: &Row) -> Result<DeckSettings, Error> {
Ok(DeckSettings {
id: row.get(0)?,
name: row.get(1)?,
forward: row.get(2)?,
backward: row.get(3)?,
filter: row.get(4)?,
})
}
fn insert_params(entity: &DeckSettings) -> impl Params {
(
&entity.name,
&entity.forward,
&entity.backward,
&entity.filter,
)
}
fn update_params(entity: &DeckSettings) -> impl Params {
(
&entity.name,
&entity.forward,
&entity.backward,
&entity.filter,
entity.id,
)
}
}
+98
View File
@@ -0,0 +1,98 @@
use crate::data::entities::{Exam, ExamEntry};
use crate::data::repositories::*;
use crate::data_access_methods;
use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use rusqlite::{Error, Params, Row};
data_access_methods!(
ExamDataLayer,
ExamRepository,
ExamRepositoryInner,
Exam,
ExamQuerySet,
ExamQuery
);
data_access_methods!(
ExamEntryLayer,
ExamEntryRepository,
ExamEntryRepositoryInner,
ExamEntry,
ExamEntryQuerySet,
ExamEntryQuery
);
#[derive(Clone)]
pub struct ExamQuerySet;
impl TableAccessQuerySet<Exam> for ExamQuerySet {
fn load() -> &'static str {
"SELECT id, timestamp, deck_id FROM exams"
}
fn save() -> &'static str {
"UPDATE exams set deck_id = $1, timestamp = $2 WHERE id = $3"
}
fn add() -> &'static str {
"INSERT INTO exams (timestamp, deck_id) values ($1, $2)"
}
fn name() -> &'static str {
"exams"
}
fn from_sql(row: &Row) -> Result<Exam, Error> {
Ok(Exam {
id: row.get(0)?,
timestamp: row.get(1)?,
deck_id: row.get(2)?,
})
}
fn insert_params(entity: &Exam) -> impl Params {
(entity.timestamp.timestamp(), entity.deck_id)
}
fn update_params(entity: &Exam) -> impl Params {
(entity.timestamp.timestamp(), entity.deck_id, entity.id)
}
}
#[derive(Clone)]
pub struct ExamEntryQuerySet;
impl TableAccessQuerySet<ExamEntry> for ExamEntryQuerySet {
fn load() -> &'static str {
"SELECT id, exam_id, word_id, status FROM exam_entries"
}
fn save() -> &'static str {
"UPDATE exam_entries set status = $1 WHERE id = $2"
}
fn add() -> &'static str {
"INSERT into exam_entries (exam_id, word_id, status) VALUES ($1, $2, $3)"
}
fn name() -> &'static str {
"exam_entries"
}
fn from_sql(row: &Row) -> Result<ExamEntry, Error> {
Ok(ExamEntry {
id: row.get(0)?,
exam_id: row.get(1)?,
word_id: row.get(2)?,
status: row.get(3)?,
})
}
fn insert_params(entity: &ExamEntry) -> impl Params {
(entity.exam_id, entity.word_id, entity.status)
}
fn update_params(entity: &ExamEntry) -> impl Params {
(entity.status, entity.id)
}
}
+90 -95
View File
@@ -1,99 +1,94 @@
use crate::dictionary::app_data_dir; use crate::core::srs::WordOpenMode;
use crate::lang::{Id, WordOpenMode}; use crate::data::entities::HistoryNote;
use chrono::{DateTime, Utc}; use crate::data::repositories::{DataRepository, DataRepositoryInner};
use std::fs; use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use std::fs::{File, OpenOptions}; use rusqlite::types::{FromSql, FromSqlResult, ToSqlOutput, ValueRef};
use std::io::Write; use rusqlite::{Error, Params, Row, ToSql};
use std::io::{BufRead, BufReader}; use crate::data_access_methods;
use std::path::PathBuf;
pub fn get_history_of_set(id: Id) -> Vec<HistoryItem> { data_access_methods!(HistoryDataLayer, HistoryRepository, HistoryRepositoryInner, HistoryNote, HistoryQuerySet, HistoryQuery);
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();
parse_history_items(lines)
}
fn append_lines_if_exists(path: PathBuf, lines: &mut Vec<String>) {
if path.exists() {
let reader = BufReader::new(File::open(path).unwrap());
for line in reader.lines() {
lines.push(line.unwrap());
}
}
}
fn parse_history_items(strings: Vec<String>) -> Vec<HistoryItem> {
let mut items = Vec::with_capacity(strings.len());
for string in strings {
if let [time, word, mode, before, after] = string.split(';').collect::<Vec<&str>>()[..] {
let id = word.parse::<Id>();
if id.is_err() {
continue;
}
let id = id.unwrap();
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::<u8>().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(),
});
}
}
items
}
pub fn push_note(set_id: Id, item: HistoryItem) {
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)
.unwrap();
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).unwrap();
}
pub fn history_dir() -> PathBuf {
let directory = app_data_dir().join("history");
if !directory.exists() {
fs::create_dir(directory.clone()).unwrap();
}
directory
}
#[derive(Clone)] #[derive(Clone)]
pub struct HistoryItem { pub struct HistoryQuerySet;
pub timestamp: DateTime<Utc>, impl TableAccessQuerySet<HistoryNote> for HistoryQuerySet {
pub word_id: Id, fn load() -> &'static str {
pub mode: WordOpenMode, "SELECT rowid, deck_id, word_id, timestamp, answer, before, after, fsrs FROM history"
pub before: u8, }
pub after: u8,
fn save() -> &'static str {
todo!("History is readonly")
}
fn add() -> &'static str {
"INSERT INTO history (deck_id, word_id, timestamp, answer, before, after, fsrs) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
}
fn name() -> &'static str {
"history"
}
fn id_field() -> &'static str {
"rowid"
}
fn from_sql(row: &Row) -> Result<HistoryNote, Error> {
Ok(HistoryNote {
id: row.get(0)?,
timestamp: row.get(3)?,
deck_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.deck_id,
entity.word_id,
entity.timestamp.timestamp(),
entity.answer,
entity.before,
entity.after,
entity.fsrs,
)
}
fn update_params(entity: &HistoryNote) -> impl Params {
(
entity.deck_id,
entity.word_id,
entity.timestamp.timestamp(),
entity.answer,
entity.before,
entity.after,
entity.fsrs,
)
}
}
impl FromSql for WordOpenMode {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
if let ValueRef::Integer(s) = value {
return match s {
1 => Ok(WordOpenMode::Hard),
2 => Ok(WordOpenMode::Ok),
3 => Ok(WordOpenMode::Easy),
_ => Ok(WordOpenMode::None),
};
}
Ok(WordOpenMode::None)
}
}
impl ToSql for WordOpenMode {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(match self {
WordOpenMode::Easy => 3,
WordOpenMode::Ok => 2,
WordOpenMode::Hard => 1,
WordOpenMode::None => 0,
}))
}
} }
+58 -26
View File
@@ -1,6 +1,11 @@
use hashbrown::HashMap; use crate::helpers::into_result;
use crate::notification::NotificationData;
use crate::notification::NotificationData::IncorrectAnkiFile;
use crate::pages::import::AnkiSchema;
use log::info;
use rusqlite::Connection; use rusqlite::Connection;
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Clone)] #[derive(Clone)]
pub struct ImportData(pub(crate) Vec<ImportGroup>); pub struct ImportData(pub(crate) Vec<ImportGroup>);
@@ -11,7 +16,7 @@ pub struct ImportGroup {
pub name: String, pub name: String,
pub fields: Vec<String>, pub fields: Vec<String>,
pub length: u64, pub length: u64,
pub mapping: HashMap<String, String>, pub mapping: BTreeMap<String, String>,
pub imported: bool, pub imported: bool,
} }
@@ -21,36 +26,57 @@ pub struct ImportNote {
pub fields: Vec<String>, pub fields: Vec<String>,
} }
pub fn load_groups(connection: &Connection) -> ImportData { pub fn load_groups(
connection: &Connection,
schema: AnkiSchema,
) -> Result<ImportData, NotificationData> {
match schema {
AnkiSchema::Old => load_old_schema(connection),
AnkiSchema::Beta => load_new_schema(connection),
}
}
fn load_old_schema(connection: &Connection) -> Result<ImportData, NotificationData> {
let json_models: String = connection let json_models: String = connection
.query_one("select models from col", (), |row| row.get(0)) .query_one("select models from col", (), |row| row.get(0))
.unwrap(); .map_err(|_| IncorrectAnkiFile)?;
let raw: Value = serde_json::from_str(json_models.as_str()).unwrap(); let raw: Value = serde_json::from_str(json_models.as_str()).map_err(|_| IncorrectAnkiFile)?;
let sets = raw.as_object().unwrap(); let sets = raw.as_object();
if let Some(sets) = sets {
let mut total_data = ImportData(vec![]); let mut total_data = ImportData(vec![]);
let mut count_stmt = connection let mut count_stmt = connection
.prepare("select mid, count(id) from notes group by mid") .prepare("select mid, count(id) from notes group by mid")
.unwrap(); .map_err(|_| IncorrectAnkiFile)?;
let counts = count_stmt let counts = count_stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap() .map_err(|_| IncorrectAnkiFile)?
.map(|x| x.unwrap()) .filter_map(|x| x.ok())
.collect::<Vec<(i64, i64)>>(); .collect::<Vec<(i64, i64)>>();
for (_, collection) in sets { for (_, collection) in sets {
let fields_property = collection.get("fields"); let fields_property = collection.get("flds");
if let Some(fields_property) = fields_property { if let Some(fields_property) = fields_property {
let fields = fields_property let fields = into_result(fields_property.as_array(), IncorrectAnkiFile)?
.as_array()
.unwrap()
.iter() .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(); .collect();
let id = collection.get("id");
if id.is_none() || id.unwrap().as_u64().is_none() {
return Err(IncorrectAnkiFile);
}
let id = id.unwrap().as_u64().unwrap();
let name = collection.get("name");
if name.is_none() || name.unwrap().as_str().is_none() {
return Err(IncorrectAnkiFile);
}
let name = name.unwrap().as_str().unwrap().to_string();
let mut group = ImportGroup { let mut group = ImportGroup {
id: collection["id"].as_u64().unwrap(), id,
name: collection["name"].as_str().unwrap().to_string(), name,
fields, fields,
mapping: Default::default(), mapping: Default::default(),
length: 0, length: 0,
@@ -59,20 +85,26 @@ pub fn load_groups(connection: &Connection) -> ImportData {
if let Some((_, count)) = counts.iter().find(|(id, _)| *id == group.id as i64) { if let Some((_, count)) = counts.iter().find(|(id, _)| *id == group.id as i64) {
group.length = *count as u64 group.length = *count as u64
} }
println!("{:?}", group); info!("{:?}", group);
total_data.0.push(group); total_data.0.push(group);
} }
} }
total_data return Ok(total_data);
} }
Err(IncorrectAnkiFile)
pub fn get_words_of_group(connection: &Connection, group_id: u64) -> Vec<ImportNote> { }
fn load_new_schema(_: &Connection) -> Result<ImportData, NotificationData> {
Err(IncorrectAnkiFile)
}
pub fn get_words_of_group(
connection: &Connection,
group_id: u64,
) -> Result<Vec<ImportNote>, NotificationData> {
let mut count_stmt = connection let mut count_stmt = connection
.prepare("select tags, flds from notes where mid == ?1;") .prepare("select tags, flds from notes where mid == ?1;")
.unwrap(); .map_err(|_| IncorrectAnkiFile)?;
count_stmt Ok(count_stmt
.query_map((group_id as i64,), |row| { .query_map((group_id as i64,), |row| {
Ok(ImportNote { Ok(ImportNote {
tags: row.get(0)?, tags: row.get(0)?,
@@ -83,7 +115,7 @@ pub fn get_words_of_group(connection: &Connection, group_id: u64) -> Vec<ImportN
.collect(), .collect(),
}) })
}) })
.unwrap() .map_err(|_| IncorrectAnkiFile)?
.map(|x| x.unwrap()) .filter_map(|x| x.ok())
.collect::<Vec<ImportNote>>() .collect::<Vec<ImportNote>>())
} }
+195 -2
View File
@@ -1,9 +1,202 @@
pub(crate) mod card_sets; use crate::data::Id;
pub(crate) mod card_stats; use crate::data::entities::EntityData;
use crate::data::repositories::{BatchedDbLayer, DbLayer};
use crate::notification::MaybeError;
use crate::notification::NotificationData::DbSaveError;
use log::{debug, error};
use rusqlite::{Connection, Error, Params, Row};
use std::marker::PhantomData;
use std::time::Instant;
pub(crate) mod card_states;
pub mod custom_fields;
pub(crate) mod decks;
pub(crate) mod history; pub(crate) mod history;
pub(crate) mod import; pub(crate) mod import;
pub(crate) mod settings; pub(crate) mod settings;
pub(crate) mod sqlite; pub(crate) mod sqlite;
pub(crate) mod voice; pub(crate) mod voice;
pub(crate) mod web_api; pub(crate) mod web_api;
pub mod word_groups;
pub(crate) mod words; pub(crate) mod words;
pub(crate) mod exam;
pub trait TableAccessQuerySet<T> {
fn load() -> &'static str;
fn save() -> &'static str;
fn add() -> &'static str;
fn name() -> &'static str;
fn id_field() -> &'static str {
"id"
}
fn from_sql(row: &Row) -> Result<T, Error>;
fn insert_params(entity: &T) -> impl Params;
fn update_params(entity: &T) -> impl Params;
}
#[derive(Clone)]
pub struct SqliteDbLayer<T, Q>
where
Q: TableAccessQuerySet<T>,
{
_phantom: PhantomData<(T, Q)>,
}
impl<T, Q: TableAccessQuerySet<T>> Default for SqliteDbLayer<T, Q> {
fn default() -> Self {
Self {
_phantom: PhantomData,
}
}
}
impl<T: EntityData, Q: TableAccessQuerySet<T>> DbLayer<T> for SqliteDbLayer<T, Q> {
fn write_entity(&self, entity: &T, connection: &Connection) -> MaybeError {
let time = Instant::now();
connection
.execute(Q::save(), Q::update_params(entity))
.map_err(|e| {
error!("{}", e);
DbSaveError
})?;
debug!("Updated in {} for {:?}", Q::name(), time.elapsed());
Ok(())
}
fn add_entity(&mut self, entity: &mut T, connection: &Connection) -> MaybeError {
let time = Instant::now();
let index: u32 = connection
.query_row(
format!("{} RETURNING {}", Q::add(), Q::id_field()).as_str(),
Q::insert_params(entity),
|row| row.get(0),
)
.map_err(|e| {
error!("{}", e);
DbSaveError
})?;
entity.set_id(index.into());
debug!("Added into {} for {:?}", Q::name(), time.elapsed());
Ok(())
}
fn delete_entity(&mut self, id: Id, connection: &Connection) -> MaybeError {
let time = Instant::now();
if !id.is_valid() {
return Err(DbSaveError);
}
connection
.execute(
format!("DELETE FROM {} WHERE id = ?1", Q::name()).as_str(),
(&id,),
)
.map_err(|e| {
error!("{}", e);
DbSaveError
})?;
debug!("Deleted from {} for {:?}", Q::name(), time.elapsed());
Ok(())
}
fn load_all(&self, storage: &mut Vec<T>, connection: &Connection) -> MaybeError {
let time = Instant::now();
let mut stmt = connection
.prepare(Q::load())
.expect("Failed to prepare state query");
let iter = stmt.query_map([], |row| Q::from_sql(row)).map_err(|e| {
error!("{}", e);
DbSaveError
})?;
iter.filter_map(|res| res.ok())
.for_each(|i| storage.push(i));
debug!(
"Loaded {} from {} for {:?}",
storage.len(),
Q::name(),
time.elapsed()
);
Ok(())
}
}
impl<T: EntityData, Q: TableAccessQuerySet<T>> BatchedDbLayer<T> for SqliteDbLayer<T, Q> {
fn delete_many_entities(&mut self, ids: &[Id], connection: &mut Connection) -> MaybeError {
let time = Instant::now();
for chunk in ids.chunks(1000) {
let stringed = chunk
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
connection
.execute(
format!("DELETE FROM {} WHERE id in ({})", Q::name(), stringed).as_str(),
(),
)
.map_err(|e| {
error!("{}", e);
DbSaveError
})?;
}
debug!(
"Deleted {} from {} for {:?}",
ids.len(),
Q::name(),
time.elapsed()
);
Ok(())
}
fn add_many_entities(&mut self, entities: &mut [T], connection: &mut Connection) -> MaybeError {
let time = Instant::now();
let tx = connection
.transaction()
.expect("Failed to create transaction");
let count = entities.len();
{
let mut stmt = tx.prepare(Q::add()).expect("Failed to prepare state query");
for entity in entities.iter() {
stmt.execute(Q::insert_params(entity)).map_err(|e| {
error!("{}", e);
DbSaveError
})?;
}
}
let last_index: u32 = tx.last_insert_rowid() as u32;
tx.commit().expect("Failed to commit");
let start_index = last_index - (count as u32) + 1;
for (index, id) in (start_index..=last_index).enumerate() {
entities[index].set_id(id.into());
}
debug!(
"Added {} into {} for {:?}",
entities.len(),
Q::name(),
time.elapsed()
);
Ok(())
}
}
#[macro_export]
macro_rules! data_access_methods {
($layer:ident, $repo:ident, $repoinner:ident, $data:ty, $queryset:ty, $query:ident) => {
pub type $repo = DataRepository<$data, $layer>;
pub type $repoinner = DataRepositoryInner<$data, $layer>;
pub type $layer = SqliteDbLayer<$data, $queryset>;
};
}
+28 -17
View File
@@ -1,10 +1,12 @@
use crate::notification::NotificationData::{DbLoadError, DbSaveError};
use crate::notification::{MaybeError, NotificationData};
use rusqlite::Connection; use rusqlite::Connection;
pub fn get_setting(key: String, connection: &Connection) -> Option<String> { pub fn get_setting(key: String, connection: &Connection) -> Option<String> {
let mut stmt = connection let mut stmt = connection
.prepare("SELECT value FROM settings WHERE id = ?1") .prepare("SELECT value FROM settings WHERE id = ?1")
.unwrap(); .expect("Failed to prepare statement.");
let mut iter = stmt.query_map((key,), |row| row.get(0)).unwrap(); let mut iter = stmt.query_map((key,), |row| row.get(0)).ok()?;
if let Some(row) = iter.next() if let Some(row) = iter.next()
&& let Ok(value) = row && let Ok(value) = row
@@ -15,31 +17,35 @@ pub fn get_setting(key: String, connection: &Connection) -> Option<String> {
None None
} }
pub fn set_setting(key: String, value: String, connection: &Connection) { pub fn set_setting(key: String, value: String, connection: &Connection) -> MaybeError {
let current = get_settings_list(connection); let current = get_settings_list(connection)?;
if current.contains(&key) { if current.contains(&key) {
update_settings(key, value, connection); update_settings(key, value, connection)?;
} else { } else {
create_settings(key, value, connection); create_settings(key, value, connection)?;
}
} }
pub fn delete_settings(key: String, connection: &Connection) { Ok(())
}
pub fn delete_settings(key: String, connection: &Connection) -> MaybeError {
connection connection
.execute("DELETE FROM settings WHERE id = ?1", (&key,)) .execute("DELETE FROM settings WHERE id = ?1", (&key,))
.unwrap(); .map_err(|_| DbSaveError)?;
Ok(())
} }
fn create_settings(key: String, value: String, connection: &Connection) { fn create_settings(key: String, value: String, connection: &Connection) -> MaybeError {
connection connection
.execute( .execute(
"INSERT into settings (id, value) VALUES (?1, ?2)", "INSERT into settings (id, value) VALUES (?1, ?2)",
(key, value), (key, value),
) )
.unwrap(); .map_err(|_| DbSaveError)?;
Ok(())
} }
fn update_settings(key: String, value: String, connection: &Connection) { fn update_settings(key: String, value: String, connection: &Connection) -> MaybeError {
connection connection
.execute( .execute(
"update settings "update settings
@@ -47,11 +53,16 @@ set value = ?2
where id = ?1", where id = ?1",
(key, value), (key, value),
) )
.unwrap(); .map_err(|_| DbSaveError)?;
Ok(())
} }
fn get_settings_list(connection: &Connection) -> Vec<String> { fn get_settings_list(connection: &Connection) -> Result<Vec<String>, NotificationData> {
let mut stmt = connection.prepare("SELECT id FROM settings").unwrap(); let mut stmt = connection
let iter = stmt.query_map((), |row| row.get(0)).unwrap(); .prepare("SELECT id FROM settings")
iter.map(|row| row.unwrap()).collect() .expect("prepare stmt failed");
let iter = stmt
.query_map((), |row| row.get(0))
.map_err(|_| DbLoadError)?;
Ok(iter.filter_map(|i| i.ok()).collect())
} }
+108 -25
View File
@@ -1,30 +1,31 @@
use crate::dictionary::app_data_dir; use crate::fs::*;
use log::error;
use rusqlite::Connection; use rusqlite::Connection;
use std::ops::{Deref, DerefMut};
pub fn create_db() { pub fn create_db() {
let path = app_data_dir(); let db_file = db_path();
let db_file = path.join("data.db");
if !db_file.exists() { if !db_file.exists() {
std::fs::File::create(&db_file).unwrap(); std::fs::File::create(&db_file).expect("Database file creation failed");
let connection = Connection::open(&db_file).unwrap(); let connection = Connection::open(&db_file).expect("Connection open failed");
connection.execute("PRAGMA foreign_keys = ON;", []).unwrap(); connection
.execute("PRAGMA foreign_keys = ON;", [])
.expect("Failed to set PRAGMA");
create_tables(&connection); create_tables(&connection);
} else {
let connection = Connection::open(&db_file).unwrap();
ensure_db_schema(&connection);
} }
} }
fn ensure_db_schema(_: &Connection) {}
fn create_tables(conn: &Connection) { fn create_tables(conn: &Connection) {
make_card_set(conn).unwrap(); make_card_set(conn).expect("Failed to init table");
make_settings(conn).unwrap(); make_settings(conn).expect("Failed to init table");
make_word_group(conn).unwrap(); make_word_group(conn).expect("Failed to init table");
make_words(conn).unwrap(); make_words(conn).expect("Failed to init table");
make_card_stats(conn).unwrap(); make_card_stats(conn).expect("Failed to init table");
make_history(conn).expect("Failed to init table");
make_custom_fields(conn).expect("Failed to init table");
make_exams(conn).expect("Failed to init table");
make_exam_entry(conn).expect("Failed to init table");
} }
fn make_settings(conn: &Connection) -> Result<(), rusqlite::Error> { fn make_settings(conn: &Connection) -> Result<(), rusqlite::Error> {
@@ -34,15 +35,15 @@ fn make_settings(conn: &Connection) -> Result<(), rusqlite::Error> {
} }
fn make_card_set(conn: &Connection) -> Result<(), rusqlite::Error> { fn make_card_set(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/card_set.sql"); let query = include_str!("../../sql/decks.sql");
conn.execute(query, ())?; conn.execute(query, ())?;
Ok(()) Ok(())
} }
fn make_word_group(conn: &Connection) -> Result<(), rusqlite::Error> { fn make_word_group(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/word_group.sql"); let query = include_str!("../../sql/word_groups.sql");
conn.execute(query, ())?; conn.execute(query, ())?;
conn.execute("insert into word_group (name) values (\"Слова\");", ())?; conn.execute("insert into word_groups (name) values (\"Слова\");", ())?;
Ok(()) Ok(())
} }
fn make_words(conn: &Connection) -> Result<(), rusqlite::Error> { fn make_words(conn: &Connection) -> Result<(), rusqlite::Error> {
@@ -51,15 +52,97 @@ fn make_words(conn: &Connection) -> Result<(), rusqlite::Error> {
Ok(()) Ok(())
} }
fn make_card_stats(conn: &Connection) -> Result<(), rusqlite::Error> { fn make_card_stats(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/card_stats.sql"); let query = include_str!("../../sql/states.sql");
conn.execute(query, ())?;
Ok(())
}
fn make_history(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/history.sql");
conn.execute(query, ())?;
Ok(())
}
fn make_custom_fields(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/custom_fields.sql");
conn.execute(query, ())?;
conn.execute("insert into custom_fields (name, identifier) values (\"Чтение\", \"reading\"), (\"Описание\", \"description\"), (\"Пример использования\", \"context\")", ())?;
Ok(())
}
fn make_exams(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/exam.sql");
conn.execute(query, ())?;
Ok(())
}
fn make_exam_entry(conn: &Connection) -> Result<(), rusqlite::Error> {
let query = include_str!("../../sql/exam_entry.sql");
conn.execute(query, ())?; conn.execute(query, ())?;
Ok(()) Ok(())
} }
pub fn default_connection() -> Connection { pub fn default_connection() -> Connection {
let path = app_data_dir(); let db_file = db_path();
let db_file = path.join("data.db"); let connection = Connection::open(db_file).expect("Failed to open database");
let connection = Connection::open(db_file).unwrap(); connection
connection.execute("PRAGMA foreign_keys = ON;", []).unwrap(); .execute("PRAGMA foreign_keys = ON;", [])
.expect("Failed to set PRAGMA");
connection
.query_one("PRAGMA journal_mode = WAL;", [], |row| {
Ok(())
})
.expect("Failed to init database");
connection
.execute("PRAGMA synchronous = NORMAL;", [])
.expect("Failed to set PRAGMA");
connection connection
} }
pub struct SqliteConnection(Option<Connection>);
impl Deref for SqliteConnection {
type Target = Connection;
fn deref(&self) -> &Self::Target {
self.0.as_ref().unwrap()
}
}
impl DerefMut for SqliteConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut().unwrap()
}
}
impl Default for SqliteConnection {
fn default() -> Self {
SqliteConnection(Some(default_connection()))
}
}
impl SqliteConnection {
pub fn finish(&mut self) {
let connection = self.0.take().unwrap();
connection
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
.unwrap_or_else(|e| {
error!("Ошибка при checkpoint WAL: {e}");
});
connection
.execute("PRAGMA optimize", [])
.unwrap_or_else(|e| {
error!("Ошибка при оптимизации БД: {e}");
0
});
self.0 = None;
}
pub fn restart(&mut self) {
self.0 = Some(default_connection());
}
}
impl Drop for SqliteConnection {
fn drop(&mut self) {
self.finish();
}
}
+19 -12
View File
@@ -1,15 +1,14 @@
use crate::dictionary::app_data_dir; use crate::fs::*;
use crate::notification::NotificationData;
use crate::notification::NotificationData::*;
use reqwest::Client; use reqwest::Client;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fs::File; use std::fs::File;
use std::io::BufReader; use std::io::BufReader;
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(); let mut path = voice_dir()?;
path.push("voice");
if !path.exists() {
std::fs::create_dir(path.clone()).map_err(|_| ())?;
}
let hash = format!("{}.wav", hex::encode(Sha256::digest(text.as_bytes()))); let hash = format!("{}.wav", hex::encode(Sha256::digest(text.as_bytes())));
path.push(hash); path.push(hash);
if !path.exists() { if !path.exists() {
@@ -20,7 +19,11 @@ pub async fn get_voice(text: &str) -> Result<BufReader<File>, ()> {
.send() .send()
.await; .await;
let query = query.map_err(|_| ())?.text().await.map_err(|_| ())?; let query = query
.map_err(|_| VoiceVoxUnavailable)?
.text()
.await
.map_err(|_| VoiceVoxUnavailable)?;
// Synthesis // Synthesis
let audio = client let audio = client
.post(format!("{engine_url}/synthesis?speaker=11")) .post(format!("{engine_url}/synthesis?speaker=11"))
@@ -28,13 +31,17 @@ pub async fn get_voice(text: &str) -> Result<BufReader<File>, ()> {
.body(query) .body(query)
.send() .send()
.await .await
.map_err(|_| ())? .map_err(|_| VoiceVoxUnavailable)?
.bytes() .bytes()
.await .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)?,
))
} }
+80 -49
View File
@@ -1,18 +1,24 @@
use crate::dictionary::app_data_dir; use crate::fs::*;
use crate::notification::NotificationData::{
CompressError, OpenFileError, PermissionDenied, WebApiUnavailable,
};
use crate::notification::{MaybeError, NotificationData};
use log::{debug, info};
use std::io; use std::io;
use reqwest::Error; use std::time::Instant;
use tokio::fs::{File, OpenOptions}; use tokio::fs::{File, OpenOptions, remove_file};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use zstd::{DEFAULT_COMPRESSION_LEVEL, Decoder, Encoder}; use zstd::{DEFAULT_COMPRESSION_LEVEL, Decoder, Encoder};
// const API_URL: &str = "http://127.0.0.1:8089/"; // const API_URL: &str = "http://127.0.0.1:8089/";
const API_URL: &str = "https://learning.micialware.ru/"; const API_URL: &str = "https://learning.micialware.ru/";
pub async fn send_data(id: String) -> Result<(), ()> { pub async fn send_data(id: String) -> MaybeError {
let path = app_data_dir(); let db_file = db_path();
let db_file = path.join("data.db"); let data = tokio::fs::read(db_file)
let data = tokio::fs::read(db_file).await.map_err(|_| ())?; .await
let data = compress(data); .map_err(|_| WebApiUnavailable)?;
let data = compress(data)?;
let api_url = API_URL.to_string(); let api_url = API_URL.to_string();
let id_url = format!("{api_url}upload/stream/{id}"); let id_url = format!("{api_url}upload/stream/{id}");
@@ -22,60 +28,75 @@ pub async fn send_data(id: String) -> Result<(), ()> {
.post(&id_url) .post(&id_url)
.body(data) .body(data)
.send() .send()
.await.map_err(|_| ())? .await
.map_err(|_| WebApiUnavailable)?
.text() .text()
.await.map_err(|_| ())?; .await
set_local_version(new_version.parse::<u32>().unwrap()).await; .map_err(|_| WebApiUnavailable)?;
set_local_version(new_version.parse::<u32>().unwrap_or_default()).await?;
Ok(()) Ok(())
} }
fn compress(data: Vec<u8>) -> Vec<u8> { fn compress(data: Vec<u8>) -> Result<Vec<u8>, NotificationData> {
let mut encoder = Encoder::new(Vec::new(), DEFAULT_COMPRESSION_LEVEL).unwrap(); let time = Instant::now();
io::copy(&mut &data[..], &mut encoder).unwrap(); let mut encoder =
encoder.finish().unwrap() Encoder::new(Vec::new(), 17).map_err(|_| CompressError)?;
io::copy(&mut &data[..], &mut encoder).map_err(|_| PermissionDenied)?;
let result = encoder.finish().map_err(|_| CompressError);
debug!("Compress time: {:?}", time.elapsed());
result
} }
pub async fn load_data(id: String, temp: bool) -> Result<(), Error> { pub async fn load_data(id: String, temp: bool) -> MaybeError {
let api_url = API_URL.to_string(); let api_url = API_URL.to_string();
let id_url = format!("{api_url}download/{id}"); let id_url = format!("{api_url}download/{id}");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let data = client let data = client
.get(&id_url) .get(&id_url)
.send() .send()
.await? .await
.map_err(|e| WebApiUnavailable)?
.bytes() .bytes()
.await? .await
.map_err(|_| WebApiUnavailable)?
.to_vec(); .to_vec();
let data = decompress(data); let data = decompress(data)?;
let path = app_data_dir(); let path = app_data_dir();
let file_name = if temp { "data.db.tmp" } else { "data.db" }; let file_name = if temp { "data.db.tmp" } else { "data.db" };
let db_file = path.join(file_name); let db_file = path.join(file_name);
tokio::fs::write(db_file, data).await.unwrap(); tokio::fs::write(db_file, data)
.await
.map_err(|_| PermissionDenied)?;
let version_url = format!("{API_URL}{id}/version"); let version_url = format!("{API_URL}{id}/version");
let version = client let version = client
.get(&version_url) .get(&version_url)
.send() .send()
.await? .await
.map_err(|_| WebApiUnavailable)?
.text() .text()
.await?; .await
set_local_version(version.parse::<u32>().unwrap_or_default()).await; .map_err(|_| WebApiUnavailable)?;
set_local_version(version.parse::<u32>().unwrap_or_default()).await?;
if db_wal().exists() {
remove_file(db_wal()).await.map_err(|_| OpenFileError)?;
}
if db_shm().exists() {
remove_file(db_shm()).await.map_err(|_| OpenFileError)?;
}
Ok(()) Ok(())
} }
pub fn decompress(data: Vec<u8>) -> Vec<u8> { pub fn decompress(data: Vec<u8>) -> Result<Vec<u8>, NotificationData> {
let mut decoder = Decoder::new(&data[..]).unwrap(); let mut decoder = Decoder::new(&data[..]).map_err(|_| CompressError)?;
let mut decompressed = Vec::new(); let mut decompressed = Vec::new();
let res = io::copy(&mut decoder, &mut decompressed); io::copy(&mut decoder, &mut decompressed).map_err(|_| PermissionDenied)?;
if let Err(e) = res { Ok(decompressed)
println!("{}", e);
}
decompressed
} }
pub async fn first_sync() -> Result<String, ()> { pub async fn first_sync() -> Result<String, NotificationData> {
let id_url = API_URL.to_string() + "generate"; let id_url = API_URL.to_string() + "generate";
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@@ -83,33 +104,40 @@ pub async fn first_sync() -> Result<String, ()> {
.get(&id_url) .get(&id_url)
.send() .send()
.await .await
.map_err(|_| ())? .map_err(|_| WebApiUnavailable)?
.text() .text()
.await .await
.map_err(|_| ())?; .map_err(|_| WebApiUnavailable)?;
println!("id: {}", id); info!("id: {}", id);
Ok(id) Ok(id)
} }
pub async fn get_web_version(key: &str) -> Result<u32, reqwest::Error> { pub async fn get_web_version(key: &str) -> Result<u32, NotificationData> {
let id_url = format!("{API_URL}{key}/version"); let id_url = format!("{API_URL}{key}/version");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let version = client.get(&id_url).send().await?.text().await?; let version = client
println!("version: {}", version); .get(&id_url)
.send()
.await
.map_err(|_| WebApiUnavailable)?
.text()
.await
.map_err(|_| WebApiUnavailable)?;
info!("version: {}", version);
Ok(version.parse::<u32>().unwrap_or_default()) Ok(version.parse::<u32>().unwrap_or_default())
} }
pub async fn get_local_version() -> u32 { pub async fn get_local_version() -> Result<u32, NotificationData> {
let file = app_data_dir().join("data.meta"); let file = db_meta_path();
if file.exists() { if file.exists() {
let mut data = String::new(); let mut data = String::new();
File::open(file.clone()) File::open(file.clone())
.await .await
.unwrap() .map_err(|_| PermissionDenied)?
.read_to_string(&mut data) .read_to_string(&mut data)
.await .await
.unwrap(); .map_err(|_| PermissionDenied)?;
return data.parse::<u32>().unwrap_or_default(); return Ok(data.parse::<u32>().unwrap_or_default());
} }
let mut file = OpenOptions::new() let mut file = OpenOptions::new()
@@ -118,21 +146,24 @@ pub async fn get_local_version() -> u32 {
.truncate(true) .truncate(true)
.open(file) .open(file)
.await .await
.unwrap(); .map_err(|_| PermissionDenied)?;
file.write_all("0".as_bytes()).await.unwrap(); file.write_all("0".as_bytes())
0 .await
.map_err(|_| PermissionDenied)?;
Ok(0)
} }
pub async fn set_local_version(version: u32) { pub async fn set_local_version(version: u32) -> Result<(), NotificationData> {
let file_path = app_data_dir().join("data.meta"); let file_path = db_meta_path();
let mut file = OpenOptions::new() let mut file = OpenOptions::new()
.write(true) .write(true)
.create(true) .create(true)
.truncate(true) .truncate(true)
.open(file_path) .open(file_path)
.await .await
.unwrap(); .map_err(|_| PermissionDenied)?;
file.write_all(version.to_string().as_bytes()) file.write_all(version.to_string().as_bytes())
.await .await
.unwrap(); .map_err(|_| PermissionDenied)?;
Ok(())
} }
+42
View File
@@ -0,0 +1,42 @@
use crate::data::entities::WordGroup;
use crate::data::repositories::{DataRepository, DataRepositoryInner};
use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use rusqlite::{Error, Params, Row};
use crate::data_access_methods;
data_access_methods!(WordGroupDataLayer, WordGroupRepository, WordGroupRepositoryInner, WordGroup, WordGroupQuerySet, WordGroupQuery);
#[derive(Clone)]
pub struct WordGroupQuerySet;
impl TableAccessQuerySet<WordGroup> for WordGroupQuerySet {
fn load() -> &'static str {
"SELECT id, name FROM word_groups"
}
fn save() -> &'static str {
"UPDATE word_groups SET name = ?1 WHERE id = ?2"
}
fn add() -> &'static str {
"INSERT INTO word_groups (name) VALUES (?1)"
}
fn name() -> &'static str {
"word_groups"
}
fn from_sql(row: &Row) -> Result<WordGroup, Error> {
Ok(WordGroup {
id: row.get(0)?,
name: row.get(1)?,
})
}
fn insert_params(entity: &WordGroup) -> impl Params {
(&entity.name,)
}
fn update_params(entity: &WordGroup) -> impl Params {
(&entity.name, entity.id)
}
}
+56 -133
View File
@@ -1,162 +1,85 @@
use crate::lang::{WordData, WordGroup}; use crate::data::Id;
use rusqlite::{Connection, params}; use crate::data::entities::WordData;
use std::collections::HashMap; use crate::data::repositories::{DataRepository, DataRepositoryInner};
use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet};
use litemap::LiteMap;
use rusqlite::{Error, Params, Row};
use crate::data_access_methods;
pub fn add_word(word: &mut WordData, connection: &Connection) { data_access_methods!(WordDataLayer, WordRepository, WordRepositoryInner, WordData, WordQuerySet, WordQuery);
let index : u32 = connection
.query_row(
"INSERT INTO words (key, value, tags, more, group_id) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING id",
(
&word.key,
&word.value,
&word.tags,
serde_json::to_string(&word.additional).unwrap(),
&word.group_id,
),
|row| row.get(0),
).unwrap();
word.id = index.into(); #[derive(Clone)]
pub struct WordQuerySet;
impl TableAccessQuerySet<WordData> for WordQuerySet {
fn load() -> &'static str {
"SELECT id, key, value, tags, more, group_id FROM words"
} }
pub fn add_words(words: &mut [WordData], connection: &mut Connection) { fn save() -> &'static str {
let tx = connection.transaction().unwrap(); "UPDATE words SET key = ?1, value = ?2, tags = ?3, more = ?4 WHERE id = ?5"
let count = words.len();
{
let mut stmt = tx
.prepare(
"INSERT INTO words (key, value, tags, more, group_id) VALUES (?1, ?2, ?3, ?4, ?5)",
)
.unwrap();
for word in words.iter() {
stmt.execute(params![
word.key,
word.value,
word.tags,
serde_json::to_string(&word.additional).unwrap(),
word.group_id
])
.unwrap();
}
} }
let last_index: u32 = tx.last_insert_rowid() as u32; fn add() -> &'static str {
tx.commit().unwrap(); "INSERT INTO words (key, value, tags, more, group_id) VALUES (?1, ?2, ?3, ?4, ?5)"
let start_index = last_index - (count as u32) + 1;
for (index, id) in (start_index..=last_index).enumerate() {
words[index].id = id.into();
}
} }
pub fn update_word(word: &mut WordData, connection: &Connection) { fn name() -> &'static str {
if !word.id.is_valid() { "words"
add_word(word, connection);
} else {
connection
.execute(
"UPDATE words SET key = ?1, value = ?2, tags = ?3, more = ?4 WHERE id = ?5",
(
&word.key,
&word.value,
&word.tags,
serde_json::to_string(&word.additional).unwrap(),
&word.id,
),
)
.unwrap();
}
} }
pub fn delete_word(word: &WordData, connection: &Connection) { fn from_sql(row: &Row) -> Result<WordData, Error> {
if !word.id.is_valid() {
return;
}
connection
.execute("DELETE FROM words WHERE id = ?1", (&word.id,))
.unwrap();
}
pub fn load_words(connection: &Connection) -> Vec<WordData> {
let mut stmt = connection
.prepare("SELECT id, key, value, tags, more, group_id FROM words")
.unwrap();
let word_iter = stmt
.query_map([], |row| {
let additional: String = row.get(4)?; let additional: String = row.get(4)?;
Ok(WordData { Ok(WordData {
id: row.get(0)?, id: row.get(0)?,
key: row.get(1)?, key: row.get(1)?,
value: row.get(2)?, value: row.get(2)?,
tags: row.get(3)?, tags: row.get(3)?,
additional: serde_json::from_str::<HashMap<String, String>>(&additional).unwrap(), additional: additional_from_string(additional),
group_id: row.get(5)?, group_id: row.get(5)?,
}) })
})
.unwrap();
let mut buffer = vec![];
for word in word_iter {
buffer.push(word.unwrap());
} }
buffer fn insert_params(entity: &WordData) -> impl Params {
} (
&entity.key,
pub fn load_word_groups(connection: &Connection) -> Vec<WordGroup> { &entity.value,
let mut stmt = connection &entity.tags,
.prepare("SELECT id, name FROM word_group") additional_to_string(&entity.additional),
.unwrap(); entity.group_id,
let group_iter = stmt
.query_map([], |row| {
Ok(WordGroup {
id: row.get(0)?,
name: row.get(1)?,
})
})
.unwrap();
let mut buffer = vec![];
for group in group_iter {
buffer.push(group.unwrap());
}
buffer
}
pub fn add_group(group: &mut WordGroup, connection: &Connection) {
let index: u32 = connection
.query_row(
"INSERT INTO word_group (name) VALUES (?1) RETURNING id",
(&group.name,),
|row| row.get(0),
) )
.unwrap();
group.id = index.into();
} }
pub fn update_group(group: &mut WordGroup, connection: &Connection) { fn update_params(entity: &WordData) -> impl Params {
if !group.id.is_valid() { (
add_group(group, connection); &entity.key,
} else { &entity.value,
connection &entity.tags,
.execute( additional_to_string(&entity.additional),
"UPDATE word_group SET name = ?1 WHERE id = ?2", entity.id,
(&group.name, &group.id),
) )
.unwrap();
} }
} }
pub fn delete_group(group: &WordGroup, connection: &Connection) { fn additional_to_string(map: &LiteMap<Id, String>) -> String {
if !group.id.is_valid() { let mut parts = Vec::with_capacity(map.len() * 2);
return; for (id, value) in map {
parts.push(id.to_string());
parts.push(value.clone());
} }
connection parts.join("")
.execute("DELETE FROM word_group WHERE id = ?1", (&group.id,)) }
.unwrap();
fn additional_from_string(data: String) -> LiteMap<Id, String> {
if data.is_empty() {
return LiteMap::new();
}
let parts = data.split('').collect::<Vec<&str>>();
let chunks = parts.chunks(2).collect::<Vec<&[&str]>>();
let mut map = LiteMap::with_capacity(parts.len());
for part in &chunks {
let id: Id = part[0].parse().unwrap();
let value: String = part[1].to_string();
map.insert(id, value);
}
map
} }
-639
View File
@@ -1,639 +0,0 @@
use crate::data_provider::words::{delete_group, delete_word, update_group, update_word};
use crate::dictionary::DictionaryMessage::*;
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::styling::*;
use crate::word::WordState;
use crate::{AppState, RootMessage};
use chrono::{DateTime, TimeDelta, Utc};
use hashbrown::{HashMap, HashSet};
use iced::alignment::Vertical::Center;
use iced::widget::button::Style;
use iced::widget::button::{danger, text};
use iced::widget::space::horizontal;
use iced::widget::text_input::default;
use iced::widget::*;
use iced::{Border, Color, Shadow, Task};
use iced_core::Length::Fill;
use parking_lot::Mutex;
use rand::random_range;
use std::fs;
use std::ops::Add;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Clone)]
pub struct DictionaryState {
state: Arc<Mutex<AppState>>,
include_map: Vec<bool>,
tag_map: HashMap<String, bool>,
reverse: bool,
search: String,
no_typing: bool,
selected_group_index: usize,
reverse_list: bool,
auto_save_queue: HashMap<usize, DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub enum DictionaryMessage {
Back,
SetTags(usize, String),
SetKey(usize, String),
SetValue(usize, String),
SubmitWord(usize),
WordAction(usize),
NewWord,
Include(usize, bool),
IncludeTag(String, bool),
Test,
ResetTags,
SetReverse(bool),
Search(String),
SetTyping(bool),
CreateGroup,
EditGroup(String),
SaveGroup,
SelectGroup(usize),
DeleteGroup,
ChangeDirection,
TrySave(usize),
ToImport,
}
impl NavigatedPage<DictionaryMessage> for DictionaryState {
fn navigate(&mut self, message: &DictionaryMessage) -> Option<Page> {
if let Back = message {
return Some(Page::PreviousPage);
}
if let Test = message
&& self.include_map.iter().any(|x| *x)
{
let mut words = vec![];
let dict = &self.state.lock().dictionary;
words = self
.include_map
.iter()
.zip(0..self.include_map.len())
.filter(|(flag, _)| **flag)
.map(|(_, index)| dict[index].clone())
.collect();
return Some(Page::DictionaryQuiz(DictionaryQuizState::new(
words,
self.reverse,
self.no_typing,
)));
}
if let WordAction(index) = message {
let word: WordData;
{
let state = self.state.lock();
let dict = &state.dictionary;
word = dict[*index].clone();
}
if word.id.is_valid() {
return Some(Word(WordState::new(word, *index, self.state.clone())));
}
}
if let ToImport = message {
return Some(Import(ImportState::new(self.state.clone())));
}
None
}
fn navigated(&mut self) {
let len = self.state.lock().dictionary.len();
self.include_map = vec![false; len];
self.tag_map = Default::default();
self.update_tags();
}
fn update(&mut self, message: DictionaryMessage) -> Task<RootMessage> {
match message {
NewWord => {
let mut state = self.state.lock();
let mut word = WordData::new();
word.group_id = state.word_groups[self.selected_group_index].id;
let dict = &mut state.dictionary;
dict.push(word);
self.include_map.push(false);
}
SetKey(i, v) => {
{
let dict = &mut self.state.lock().dictionary;
dict[i].key = v;
}
return self.launch_auto_save_offset(i);
}
SetValue(i, v) => {
{
let dict = &mut self.state.lock().dictionary;
dict[i].value = v
}
return self.launch_auto_save_offset(i);
}
SetTags(i, mut v) => {
{
let dict = &mut self.state.lock().dictionary;
let current_tags_value = dict[i].tags.clone();
if v.ends_with(", ") && v.len() < current_tags_value.len() {
v = v[..v.len() - 2].to_string()
}
while let Some(index) = v.find(",,") {
v.remove(index);
}
dict[i].tags = v;
}
self.update_tags();
return self.launch_auto_save_offset(i);
}
WordAction(i) => {
let state = &mut self.state.lock();
let dict = &mut state.dictionary;
let word = dict.remove(i);
self.include_map.remove(i);
self.auto_save_queue.remove(&i);
delete_word(&word, &state.connection)
}
Include(i, b) => self.include_map[i] = b,
IncludeTag(t, v) => {
let index;
{
let state = self.state.lock();
index = state.word_groups[self.selected_group_index].id;
}
self.tag_map.insert(t, v);
self.update_words_include(index)
}
ResetTags => {
self.tag_map.iter_mut().for_each(|(_, v)| *v = false);
self.include_map.iter_mut().for_each(|x| *x = false)
}
SetReverse(v) => self.reverse = v,
Search(s) => {
self.search = s;
}
SetTyping(b) => self.no_typing = b,
SubmitWord(i) => self.save_word(i),
Back => {}
Test => {}
CreateGroup => {
let state = &mut self.state.lock();
state.word_groups.push(WordGroup {
id: 0.into(),
name: format!("Группа слов {}", random_range(100..1000)),
});
}
EditGroup(new) => {
let state = &mut self.state.lock();
let group = state.word_groups.get_mut(self.selected_group_index);
if let Some(group) = group {
group.name = new.clone();
}
}
SaveGroup => {
let state = &mut self.state.lock();
let connection = &state.connection;
let mut group = state.word_groups[self.selected_group_index].clone();
update_group(&mut group, connection);
state.word_groups[self.selected_group_index] = group;
}
SelectGroup(i) => {
self.selected_group_index = i;
let index;
{
let state = self.state.lock();
index = state.word_groups[i].id;
}
self.update_words_include(index)
}
DeleteGroup => {
if self.selected_group_index == 0 {
return Task::none();
}
{
let state = &mut self.state.lock();
if let Some(group) = state.word_groups.get(self.selected_group_index) {
let remove_group_id = group.id;
let connection = &state.connection;
delete_group(group, connection);
state.word_groups.remove(self.selected_group_index);
state
.dictionary
.retain(|word| word.group_id != remove_group_id);
self.selected_group_index = 0;
}
}
self.update_tags()
}
ChangeDirection => {
self.reverse_list = !self.reverse_list;
}
TrySave(word_index) => {
let now = Utc::now();
if !self.auto_save_queue.contains_key(&word_index) {
return Task::none();
}
if self.auto_save_queue[&word_index] <= now {
self.auto_save_queue.remove(&word_index);
self.save_word(word_index);
}
}
ToImport => {}
}
Task::none()
}
fn view(&self) -> iced::Element<'_, DictionaryMessage> {
back_overlay(
row![
iced::widget::column![
self.groups_panel(),
row![horizontal().width(8), self.words_list(),],
row![
self.add_word_button(),
horizontal().width(Fill),
button("Импорт").style(text).on_press(ToImport),
]
]
.spacing(5),
self.filters(),
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
impl DictionaryState {
pub fn new(state: Arc<Mutex<AppState>>) -> Self {
let len = state.lock().dictionary.len();
let mut result = DictionaryState {
include_map: vec![false; len],
selected_group_index: 0,
state,
tag_map: HashMap::new(),
reverse: false,
search: "".to_string(),
no_typing: true,
reverse_list: true,
auto_save_queue: HashMap::new(),
};
result.update_tags();
result
}
fn save_word(&mut self, i: usize) {
let state = &mut self.state.lock();
let connection = &state.connection;
let word = &mut state.dictionary[i].clone();
update_word(word, connection);
state.dictionary[i] = word.clone();
}
fn launch_auto_save_offset(&mut self, index: usize) -> Task<RootMessage> {
let save_time = Utc::now().add(TimeDelta::milliseconds(900));
self.auto_save_queue.insert(index, save_time);
let message = RootMessage::Dictionary(TrySave(index));
Task::perform(
async { tokio::time::sleep(Duration::from_secs(1)).await },
|_| message,
)
}
fn words_list(&self) -> iced::Element<'_, DictionaryMessage> {
let time = Instant::now();
let mut col = Column::new().width(Fill);
let state = self.state.lock();
let group_id = state.word_groups[self.selected_group_index].id;
let dict = &state.dictionary;
let mut index = 0;
for access_index in 0..dict.len() {
let mut i = access_index;
if self.reverse_list {
i = dict.len() - access_index - 1;
}
let word = &dict[i];
if word.group_id != group_id {
continue;
}
if !self.search.is_empty()
&& !word.key.contains(&self.search)
&& !word.value.contains(&self.search)
&& !word.tags.contains(&self.search)
{
continue;
}
let word_line_data = WordLineState {
is_included: self.include_map[i],
key: word.key.clone(),
value: word.value.clone(),
tags: word.tags.clone(),
id: word.id,
index: i,
};
index += 1;
let lazy_line = lazy(word_line_data, move |data| {
let index = data.index;
let mut line = Row::new().width(Fill).align_y(Center);
line = line.push(
checkbox(data.is_included)
.label("")
.spacing(15)
.on_toggle(move |b| Include(index, b)),
);
line = line.push(
text_input("Слово", &data.key)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.on_input(move |string| SetKey(index, string))
.on_submit(SubmitWord(index))
.style(|x, status| {
let mut default_style = default(x, status);
default_style.border.radius = 0.0.into();
default_style
}),
);
line = line.push(
text_input("Перевод", &data.value)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.on_input(move |string| SetValue(index, string))
.on_submit(SubmitWord(index))
.style(|x, status| {
let mut default_style = default(x, status);
default_style.border.radius = 0.0.into();
default_style
}),
);
line = line.push(
text_input("Теги", &data.tags)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.on_input(move |string| SetTags(index, string))
.on_submit(SubmitWord(index))
.style(|x, status| {
let mut default_style = default(x, status);
default_style.border.radius = 0.0.into();
default_style
}),
);
let line_button = || {
let action = WordAction(index);
if !data.id.is_valid() {
return button("-").on_press(action).style(|_x, _status| Style {
background: None,
text_color: Color::BLACK,
border: Border::default(),
shadow: Shadow::default(),
snap: false,
});
}
button("").on_press(WordAction(index)).width(15)
};
line = line.push(line_button()).push(space().width(10));
line
});
col = col.push(lazy_line);
}
println!("Drawing {index} lines");
println!("Words rendering time: {:?}", time.elapsed());
scrollable(col).height(Fill).into()
}
fn filters(&self) -> iced::Element<'_, DictionaryMessage> {
let dict = &self.state.lock().dictionary;
iced::widget::column![
text_input("Поиск", &self.search)
.on_input(Search)
.width(Fill),
text!("Всего слов: {}", dict.len()),
text!(
"Выбрано слов: {}",
self.include_map.iter().filter(|i| **i).count()
),
self.tags_selector(),
toggler(self.no_typing)
.label("Без набора")
.on_toggle(SetTyping),
toggler(self.reverse)
.label("Обратный тест")
.on_toggle(SetReverse),
button(text!("Тест").center().width(Fill))
.style(cta_button)
.on_press(Test)
.width(Fill),
]
.width(250)
.spacing(DEFAULT_SPACING)
.into()
}
fn tags_selector(&self) -> iced::Element<'_, DictionaryMessage> {
let mut col = Column::new().width(Fill);
col = col.push(
button("Сбросить")
.on_press(ResetTags)
.style(|x: &Theme, _status| Style {
background: None,
text_color: x.palette().primary,
border: Border::default(),
shadow: Shadow::default(),
snap: false,
}),
);
let mut sorted_tags = self.tag_map.iter().collect::<Vec<_>>();
sorted_tags.sort();
for tag in sorted_tags {
col = col.push(
checkbox(*tag.1)
.label(tag.0)
.on_toggle(|x1| IncludeTag(tag.0.clone(), x1)),
)
}
container(scrollable(col)).height(Fill).into()
}
fn update_tags(&mut self) {
let mut tags_list: HashSet<String> = HashSet::new();
let dict = &self.state.lock().dictionary;
dict.iter().for_each(|element| {
split_with_coma(element.tags.as_str())
.iter()
.for_each(|tag| {
tags_list.insert(tag.clone());
});
});
let current_tags = self
.tag_map
.keys()
.map(|k| k.clone().to_string())
.collect::<Vec<String>>();
current_tags
.iter()
.filter(|tag| !tags_list.contains(*tag))
.for_each(|tag| {
self.tag_map.remove(tag);
});
tags_list.iter().for_each(|tag| {
self.tag_map.insert(tag.clone(), false);
});
}
fn update_words_include(&mut self, group_id: crate::lang::Id) {
let include_tags = self
.tag_map
.iter()
.filter(|(_, value)| **value)
.map(|(t, _)| t.clone())
.collect::<Vec<String>>();
if include_tags.is_empty() {
self.include_map.iter_mut().for_each(|x| *x = false);
return;
}
let dict = &self.state.lock().dictionary;
let time = Instant::now();
self.include_map = dict
.iter()
.map(|word| (split_with_coma(word.tags.as_str()), word.group_id))
.map(|(tags, word_group_id)| {
!tags.is_empty()
&& tags.iter().all(|t| include_tags.contains(t))
&& word_group_id == group_id
})
.collect();
println!("Time {}", time.elapsed().as_micros());
}
fn groups_panel(&self) -> iced::Element<'_, DictionaryMessage> {
let mut row = Row::new();
row = row.push(button("+").style(text).on_press(CreateGroup));
let state = &self.state.lock();
let groups = &state.word_groups;
for (index, group) in groups.iter().enumerate() {
row = row.push(
button(text!("{}", group.name.clone()))
.style(text)
.on_press(SelectGroup(index)),
);
}
let group = state.word_groups[self.selected_group_index].clone();
iced::widget::column![
scrollable(row).width(Fill).horizontal(),
row![
button("⇳").on_press(ChangeDirection).style(jl_button),
text_input("Название группы слов", &group.name)
.on_input(EditGroup)
.width(250)
.on_submit(SaveGroup),
horizontal(),
self.group_delete_button(),
]
.spacing(DEFAULT_SPACING / 2.0)
]
.into()
}
fn group_delete_button(&self) -> iced::Element<'_, DictionaryMessage> {
if self.selected_group_index != 0 {
button("Удалить").style(danger).on_press(DeleteGroup).into()
} else {
space().into()
}
}
fn add_word_button(&self) -> iced::Element<'_, DictionaryMessage> {
let state = self.state.lock();
let group_id = state.word_groups[self.selected_group_index].id;
let button = button("Добавить слово").style(jl_button);
if group_id.is_valid() {
return button.on_press(NewWord).into();
}
button.into()
}
}
pub fn split_with_coma(ts: &str) -> Vec<String> {
ts.split(',')
.map(|ts| ts.to_lowercase().trim().to_string())
.filter(|t| !t.is_empty())
.collect::<Vec<String>>()
}
pub fn app_data_dir() -> PathBuf {
let mut dir = dirs::data_dir().unwrap();
dir.push("jap_learn");
if !dir.exists() {
fs::create_dir_all(dir.clone()).unwrap();
}
dir
}
#[derive(Hash, PartialEq, Eq)]
struct WordLineState {
is_included: bool,
key: String,
value: String,
tags: String,
id: crate::lang::Id,
index: usize,
}
+45
View File
@@ -0,0 +1,45 @@
use crate::notification::NotificationData;
use crate::notification::NotificationData::PermissionDenied;
use std::fs;
use std::path::PathBuf;
pub fn app_data_dir() -> PathBuf {
let mut dir = dirs::data_dir().expect("Unsupported OS");
dir.push("jap_learn");
if !dir.exists() {
fs::create_dir_all(dir.clone()).expect("Unable to create directory");
}
dir
}
pub fn db_path() -> PathBuf {
app_data_dir().join("data.db")
}
pub fn db_wal() -> PathBuf {
app_data_dir().join("data.db-wal")
}
pub fn db_shm() -> PathBuf {
app_data_dir().join("data.db-shm")
}
pub fn temp_db_path() -> PathBuf {
app_data_dir().join("data.db.tmp")
}
pub fn temp_import_path() -> PathBuf {
app_data_dir().join("import")
}
pub fn db_meta_path() -> PathBuf {
app_data_dir().join("data.meta")
}
pub fn voice_dir() -> Result<PathBuf, NotificationData> {
let directory = app_data_dir().join("voice");
if !directory.exists() {
fs::create_dir_all(directory.clone()).map_err(|_| PermissionDenied)?;
}
Ok(directory)
}
+24
View File
@@ -0,0 +1,24 @@
#[macro_export]
macro_rules! push_error_task {
($e:expr) => {
iced::Task::done(push_error!($e))
};
}
#[macro_export]
macro_rules! push_error {
($e:expr) => {
$crate::navigation::RootMessage::PushNotification(
$crate::notification::NotificationRequest::Error($e),
)
};
}
#[inline(always)]
pub fn into_result<R, E>(option: Option<R>, error: E) -> Result<R, E> {
if let Some(r) = option {
Ok(r)
} else {
Err(error)
}
}
-718
View File
@@ -1,718 +0,0 @@
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 chrono::{DateTime, Utc};
use parking_lot::Mutex;
use rand::distr::weighted::WeightedIndex;
use rand::distr::Distribution;
use rand::prelude::SliceRandom;
use rand::rng;
use rand::rngs::ThreadRng;
use rayon::iter::IndexedParallelIterator;
use rayon::iter::IntoParallelRefIterator;
use rayon::iter::ParallelIterator;
use rhai::{Engine, Scope};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef};
use rusqlite::ToSql;
use std::cmp::PartialEq;
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::num::ParseIntError;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
const MAX_HISTORY_LEN: usize = 20;
const MAX_HISTORY_LEN_PART: f32 = 0.33;
const MAX_SCORE: u8 = 25;
const FADE_PER_DAY: f32 = 0.95;
#[derive(Clone, PartialEq, Eq, Debug, Hash, Copy, Ord, PartialOrd)]
pub struct Id(u32);
pub const INVALID_ID: Id = Id(0);
impl FromStr for Id {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Id(s.parse::<u32>()?))
}
}
impl From<u32> for Id {
fn from(value: u32) -> Self {
Id(value)
}
}
impl From<Id> for u32 {
fn from(value: Id) -> Self {
value.0
}
}
impl Display for Id {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromSql for Id {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
match value {
ValueRef::Integer(i) => Ok(Id(i as u32)),
_ => Err(FromSqlError::InvalidType),
}
}
}
impl ToSql for Id {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0 as i64))
}
}
impl From<Id> for String {
fn from(value: Id) -> Self {
value.0.to_string()
}
}
impl Id {
pub(crate) fn is_valid(self) -> bool {
self.0 != 0
}
}
#[derive(Clone, Debug)]
pub struct KanaSet {
name: String,
pub(crate) chars_type: KanaType,
pub(crate) dictionary: Vec<Vec<(String, String)>>,
pub(crate) include_map: [bool; 10],
}
#[derive(Clone, Debug)]
pub enum KanaType {
Hiragana,
Katakana,
}
impl KanaSet {
pub fn hiragana() -> Self {
Self {
name: "Хирагана".to_string(),
chars_type: KanaType::Hiragana,
dictionary: vec![
vec![
(String::from("ぁ"), String::from("a")),
(String::from("ぃ"), String::from("i")),
(String::from("ぅ"), String::from("u")),
(String::from("ぇ"), String::from("e")),
(String::from("ぉ"), String::from("o")),
],
vec![
(String::from("さ"), String::from("sa")),
(String::from("し"), String::from("shi")),
(String::from("す"), String::from("su")),
(String::from("せ"), String::from("se")),
(String::from("そ"), String::from("so")),
],
vec![
(String::from("か"), String::from("ka")),
(String::from("き"), String::from("ki")),
(String::from("く"), String::from("ku")),
(String::from("け"), String::from("ke")),
(String::from("こ"), String::from("ko")),
],
// Ряд «та» (たちつてと)
vec![
(String::from("た"), String::from("ta")),
(String::from("ち"), String::from("chi")),
(String::from("つ"), String::from("tsu")),
(String::from("て"), String::from("te")),
(String::from("と"), String::from("to")),
],
// Ряд «на» (なにぬねの)
vec![
(String::from("な"), String::from("na")),
(String::from("に"), String::from("ni")),
(String::from("ぬ"), String::from("nu")),
(String::from("ね"), String::from("ne")),
(String::from("の"), String::from("no")),
],
// Ряд «ха» (はひふへほ)
vec![
(String::from("は"), String::from("ha")),
(String::from("ひ"), String::from("hi")),
(String::from("ふ"), String::from("fu")),
(String::from("へ"), String::from("he")),
(String::from("ほ"), String::from("ho")),
],
// Ряд «ма» (まみむめも)
vec![
(String::from("ま"), String::from("ma")),
(String::from("み"), String::from("mi")),
(String::from("む"), String::from("mu")),
(String::from("め"), String::from("me")),
(String::from("も"), String::from("mo")),
],
// Ряд «я» (やゆよ) — только 3 символа
vec![
(String::from("や"), String::from("ya")),
(String::from("ゆ"), String::from("yu")),
(String::from("よ"), String::from("yo")),
],
// Ряд «ра» (らりるれろ)
vec![
(String::from("ら"), String::from("ra")),
(String::from("り"), String::from("ri")),
(String::from("る"), String::from("ru")),
(String::from("れ"), String::from("re")),
(String::from("ろ"), String::from("ro")),
],
vec![
(String::from("わ"), String::from("wa")),
(String::from("を"), String::from("wo")),
(String::from("ん"), String::from("n")),
],
],
include_map: [true; 10],
}
}
pub fn katakana() -> Self {
Self {
name: "Катакана".to_string(),
chars_type: KanaType::Katakana,
dictionary: vec![
vec![
(String::from("ァ"), String::from("a")),
(String::from("ィ"), String::from("i")),
(String::from("ゥ"), String::from("u")),
(String::from("ェ"), String::from("e")),
(String::from("ォ"), String::from("o")),
],
vec![
(String::from("サ"), String::from("sa")),
(String::from("シ"), String::from("shi")),
(String::from("ス"), String::from("su")),
(String::from("セ"), String::from("se")),
(String::from("ソ"), String::from("so")),
],
vec![
(String::from("カ"), String::from("ka")),
(String::from("キ"), String::from("ki")),
(String::from("ク"), String::from("ku")),
(String::from("ケ"), String::from("ke")),
(String::from("コ"), String::from("ko")),
],
// Ряд «та» (タチツテト)
vec![
(String::from("タ"), String::from("ta")),
(String::from("チ"), String::from("chi")),
(String::from("ツ"), String::from("tsu")),
(String::from("テ"), String::from("te")),
(String::from("ト"), String::from("to")),
],
// Ряд «на» (ナニヌネノ)
vec![
(String::from("ナ"), String::from("na")),
(String::from("ニ"), String::from("ni")),
(String::from("ヌ"), String::from("nu")),
(String::from("ネ"), String::from("ne")),
(String::from("ノ"), String::from("no")),
],
// Ряд «ха» (ハヒフヘホ)
vec![
(String::from("ハ"), String::from("ha")),
(String::from("ヒ"), String::from("hi")),
(String::from("フ"), String::from("fu")),
(String::from("ヘ"), String::from("he")),
(String::from("ホ"), String::from("ho")),
],
// Ряд «ма» (マミムメモ)
vec![
(String::from("マ"), String::from("ma")),
(String::from("ミ"), String::from("mi")),
(String::from("ム"), String::from("mu")),
(String::from("メ"), String::from("me")),
(String::from("モ"), String::from("mo")),
],
// Ряд «я» (ヤユヨ) — только 3 символа
vec![
(String::from("ヤ"), String::from("ya")),
(String::from("ユ"), String::from("yu")),
(String::from("ヨ"), String::from("yo")),
],
// Ряд «ра» (ラリルレロ)
vec![
(String::from("ラ"), String::from("ra")),
(String::from("リ"), String::from("ri")),
(String::from("ル"), String::from("ru")),
(String::from("レ"), String::from("re")),
(String::from("ロ"), String::from("ro")),
],
vec![
(String::from("ワ"), String::from("wa")),
(String::from("ヲ"), String::from("wo")),
(String::from("ン"), String::from("n")),
],
],
include_map: [true; 10],
}
}
pub fn list(&self) -> Vec<(String, String)> {
let mut current_set: Vec<(String, String)> = Vec::new();
for i in 0..10 {
if self.include_map[i] {
self.dictionary[i].iter().for_each(|v| {
current_set.push(v.clone());
});
}
}
current_set
}
}
impl Default for KanaSet {
fn default() -> Self {
KanaSet::hiragana()
}
}
impl PartialEq<Self> for KanaSet {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
#[derive(Clone, Debug)]
pub struct WordData {
pub id: Id,
pub key: String,
pub value: String,
pub tags: String,
pub additional: HashMap<String, String>,
pub group_id: Id,
}
impl WordData {
pub fn new() -> Self {
Self {
id: INVALID_ID,
key: String::new(),
value: String::new(),
tags: String::new(),
additional: Default::default(),
group_id: 1.into(),
}
}
}
#[derive(Clone)]
pub struct WordGroup {
pub id: Id,
pub name: String,
}
#[derive(Clone, PartialEq)]
pub struct CardStatistics {
pub id: Id,
pub word_id: Id,
pub set_id: Id,
pub last_open: DateTime<Utc>,
pub score: u8,
}
impl CardStatistics {
pub fn update(&mut self, status: WordOpenMode) {
match status {
WordOpenMode::Easy => {
self.score = (self.calculated_score() + 5.0).round() as u8;
}
WordOpenMode::Ok => self.score = (self.calculated_score() + 2.0).round() as u8,
WordOpenMode::Hard => {
self.score = (self.calculated_score() * 0.75).round() as u8;
}
WordOpenMode::None => {
self.score = (self.calculated_score() * 0.4) as u8;
}
}
self.score = self.score.clamp(1, MAX_SCORE);
self.last_open = Utc::now();
}
pub fn calculated_score(&self) -> f32 {
let time = Utc::now() - self.last_open;
let days = time.num_days();
let multiplier = FADE_PER_DAY.powi(days as i32);
(self.score as f32 * multiplier).max(1.0)
}
}
#[derive(Clone, Copy)]
pub enum WordOpenMode {
Easy,
Ok,
Hard,
None,
}
#[derive(Clone)]
pub struct DeckData {
words: Vec<WordData>,
set: Vec<CardStatistics>,
current_word_index: Option<usize>,
state: Arc<Mutex<AppState>>,
order_module: OrderModule,
settings: DeckSettings,
}
impl DeckData {
pub fn new(settings: &DeckSettings, state: Arc<Mutex<AppState>>) -> Self {
let state_for = state.clone();
let state_locked = state.lock();
let stats = load_stats_of_deck(settings, &state_locked.connection);
let words = stats
.iter()
.map(|stat| {
state_locked
.dictionary
.binary_search_by_key(&stat.word_id, |w| w.id)
.unwrap()
})
.map(|index| &state_locked.dictionary[index])
.cloned()
.collect();
Self {
set: stats,
words,
current_word_index: None,
state: state_for,
order_module: match settings.open_mode {
OrderMode::Default => OrderModule::SemiRandomSRS(SemiRandomSRSModule::new()),
OrderMode::TrainWorstFirst => {
OrderModule::WorstWordsSRS(WorstWordsSRSModule::new())
}
OrderMode::FullRandom => OrderModule::RandomSRS(RandomSRSModule::new()),
},
settings: settings.clone(),
}
}
pub fn next(&mut self) -> (WordData, CardStatistics) {
let index = match self.order_module.clone() {
OrderModule::SemiRandomSRS(mut module) => {
if !module.initialized {
module.init(self)
}
let index = module.next(self);
self.order_module = OrderModule::SemiRandomSRS(module);
index
}
OrderModule::RandomSRS(mut module) => {
if !module.initialized {
module.init(self)
}
let index = module.next(self);
self.order_module = OrderModule::RandomSRS(module);
index
}
OrderModule::WorstWordsSRS(mut module) => {
if !module.initialized {
module.init(self)
}
let index = module.next(self);
self.order_module = OrderModule::WorstWordsSRS(module);
index
}
};
self.current_word_index = Some(index);
(self.words[index].clone(), self.set[index].clone())
}
pub fn open(&mut self, status: WordOpenMode) {
if self.current_word_index.is_none() {
return;
}
let index = self.current_word_index.unwrap();
let word = &mut self.set[index];
let old_score = word.score;
word.update(status);
let new_score = word.score;
match self.order_module.clone() {
OrderModule::SemiRandomSRS(mut module) => {
module.open(status, index, word.clone());
self.order_module = OrderModule::SemiRandomSRS(module);
}
OrderModule::RandomSRS(mut module) => {
module.open(status, index, word.clone());
self.order_module = OrderModule::RandomSRS(module);
}
OrderModule::WorstWordsSRS(mut module) => {
module.open(status, index, word.clone());
self.order_module = OrderModule::WorstWordsSRS(module);
}
}
update_stat_score(word, &self.state.lock().connection);
push_note(
self.settings.id,
HistoryItem {
timestamp: Utc::now(),
word_id: word.word_id,
mode: status,
before: old_score,
after: new_score,
},
)
}
pub fn len(&self) -> usize {
self.set.len()
}
}
#[derive(Clone, PartialEq, Copy, Eq)]
pub enum OrderMode {
Default,
TrainWorstFirst,
FullRandom,
}
#[derive(Clone, Copy, Eq, PartialEq, Default)]
pub enum AppendMode {
Full,
#[default]
Manual,
}
#[derive(Clone)]
enum OrderModule {
SemiRandomSRS(SemiRandomSRSModule),
RandomSRS(RandomSRSModule),
WorstWordsSRS(WorstWordsSRSModule),
}
trait SRSModule {
fn next(&mut self, set: &mut DeckData) -> usize;
fn open(&mut self, status: WordOpenMode, index: usize, updated_word: CardStatistics);
fn init(&mut self, set: &mut DeckData);
}
#[derive(Clone)]
struct RandomSRSModule {
basket: Vec<usize>,
initialized: bool,
}
impl RandomSRSModule {
fn new() -> RandomSRSModule {
Self {
basket: vec![],
initialized: false,
}
}
}
impl SRSModule for RandomSRSModule {
fn next(&mut self, set: &mut DeckData) -> usize {
match self.basket.pop() {
Some(index) => index,
None => {
self.basket = (0..set.words.len()).collect::<Vec<usize>>();
self.basket.shuffle(&mut rand::rng());
self.basket.pop().expect("empty basket")
}
}
}
fn open(&mut self, _: WordOpenMode, _: usize, _: CardStatistics) {}
fn init(&mut self, set: &mut DeckData) {
self.initialized = true;
self.basket = (0..set.set.len()).collect::<Vec<usize>>();
self.basket.shuffle(&mut rand::rng())
}
}
#[derive(Clone)]
struct SemiRandomSRSModule {
history: Vec<usize>,
last_weights: WeightedIndex<f32>,
generator: ThreadRng,
initialized: bool,
}
impl SemiRandomSRSModule {
fn new() -> SemiRandomSRSModule {
SemiRandomSRSModule {
history: vec![],
last_weights: WeightedIndex::new([1.0]).unwrap(),
generator: rng(),
initialized: false,
}
}
}
impl SRSModule for SemiRandomSRSModule {
fn next(&mut self, set: &mut DeckData) -> usize {
let index = self.last_weights.sample(&mut self.generator);
if self.history.contains(&index) && self.history.len() != 1 {
return self.next(set);
}
if self.history.len() == self.history_len(set) {
self.history.remove(0);
}
self.history.push(index);
index
}
fn open(&mut self, _: WordOpenMode, index: usize, word: CardStatistics) {
let new_weight = (100.0 / word.calculated_score()).powf(2.0);
self.last_weights
.update_weights(&[(index, &new_weight)])
.expect("update weights error");
}
fn init(&mut self, set: &mut DeckData) {
self.initialized = true;
let weights = set
.set
.iter()
.map(|s| (100.0 / s.calculated_score()).powf(2.0) * 2.0)
.collect::<Vec<f32>>();
self.last_weights = WeightedIndex::new(weights).unwrap();
}
}
impl SemiRandomSRSModule {
fn history_len(&self, set: &DeckData) -> usize {
((set.len() as f32 * MAX_HISTORY_LEN_PART) as usize).clamp(1, MAX_HISTORY_LEN)
}
}
#[derive(Clone)]
struct WorstWordsSRSModule {
initialized: bool,
pool_size: usize,
rounds_remaining: u8,
pool: Vec<usize>,
queue: Vec<usize>,
rounds_count: u8,
}
impl SRSModule for WorstWordsSRSModule {
fn next(&mut self, set: &mut DeckData) -> usize {
if self.rounds_remaining == 0 {
self.fill_pool(set);
self.rounds_remaining = self.rounds_count;
}
match self.queue.pop() {
Some(index) => index,
None => {
self.queue.append(&mut self.pool.clone());
self.rounds_remaining -= 1;
self.queue.pop().expect("empty queue")
}
}
}
fn open(&mut self, _: WordOpenMode, _: usize, _: CardStatistics) {}
fn init(&mut self, _: &mut DeckData) {
self.initialized = true;
}
}
impl WorstWordsSRSModule {
fn new() -> WorstWordsSRSModule {
WorstWordsSRSModule {
initialized: false,
pool_size: 15,
rounds_count: 2,
rounds_remaining: 0,
pool: vec![],
queue: vec![],
}
}
fn fill_pool(&mut self, set: &DeckData) {
let mut sorted = set
.set
.clone()
.into_iter()
.zip(0..set.set.len())
.collect::<Vec<_>>();
sorted.sort_by_key(|c| c.0.score);
let mut worst = sorted[0..self.pool_size]
.iter()
.map(|(_, index)| *index)
.collect::<Vec<usize>>();
worst.shuffle(&mut rand::rng());
self.pool = worst;
}
}
#[derive(Clone)]
pub struct DeckSettings {
pub id: Id,
pub name: String,
pub forward: String,
pub backward: String,
pub filter: String,
pub count: Option<usize>,
pub worst_words_list: Option<Vec<WordData>>,
pub open_mode: OrderMode,
}
impl DeckSettings {
pub(crate) fn with_name(name: String) -> DeckSettings {
DeckSettings {
id: 0.into(),
name,
forward: "".to_string(),
backward: "".to_string(),
filter: "true".to_string(),
count: None,
worst_words_list: None,
open_mode: OrderMode::Default,
}
}
pub fn check_filter(&self) -> bool {
let time = Instant::now();
let engine = Engine::new();
let ast = engine.compile(&self.filter);
println!("Compile time is {:?}", time.elapsed());
ast.is_ok()
}
pub fn get_word_list(&self, state: &AppState) -> Vec<usize> {
let time = Instant::now();
let mut list = vec![];
let engine = Engine::new();
let ast = engine.compile(&self.filter);
if let Ok(ast) = ast {
let groups = &state.word_groups;
list = state
.dictionary
.par_iter()
.enumerate()
.filter(|(_, word)| {
let mut more = rhai::Map::new();
for iced in &word.additional {
more.insert(iced.0.clone().into(), iced.1.clone().into());
}
let mut scope = Scope::new();
scope
.push_constant("id", word.id.0)
.push_constant("key", word.key.clone())
.push_constant("value", word.value.clone())
.push_constant("tags", word.tags.clone())
.push_constant("more", more)
.push_constant(
"group",
groups
.iter()
.find(|g| g.id == word.group_id)
.cloned()
.unwrap()
.name,
);
let result = engine.eval_ast_with_scope::<bool>(&mut scope, &ast);
result.is_ok() && result.unwrap()
})
.map(|(index, _)| index)
.collect();
println!("Collecting available words is {:?}", time.elapsed());
list
} else {
list
}
}
pub fn require_speech(&self) -> bool {
self.forward == "speech" || self.backward == "speech"
}
}
+15 -126
View File
@@ -1,135 +1,24 @@
#![windows_subsystem = "windows"] #![windows_subsystem = "windows"]
pub mod app;
mod core;
mod data;
mod data_provider; mod data_provider;
mod dictionary; mod fs;
mod dictionary_quiz; pub mod helpers;
mod history; mod navigation;
pub mod import; pub mod notification;
mod lang; mod pages;
pub mod navigation; mod styling;
mod quiz; pub mod tests;
mod randomizer; pub mod widgets;
mod repetition;
mod repetition_settings;
mod repetitions;
mod selector;
pub mod styling;
mod sync;
mod word;
mod writing;
use crate::RootMessage::Keyboard; use crate::app::start;
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::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 mimalloc::MiMalloc;
use rusqlite::Connection;
#[global_allocator] #[global_allocator]
static GLOBAL: MiMalloc = MiMalloc; static GLOBAL: MiMalloc = MiMalloc;
const USER_FONT: Font = Font::with_name("Noto Sans JP");
fn main() -> iced::Result { fn main() -> iced::Result {
println!("Welcome to JapLearn"); env_logger::init();
iced::application(ScreenState::boot, ScreenState::update, ScreenState::view) start()
.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(),
}
}
}
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,
}
} }
+103 -114
View File
@@ -1,45 +1,48 @@
use crate::data_provider::history::{get_history_of_set, history_dir}; use crate::app::{fill_state, AppState};
use crate::data_provider::sqlite::{create_db, default_connection}; use crate::data_provider::sqlite::create_db;
use crate::data_provider::web_api::{get_local_version, get_web_version, load_data, set_local_version}; use crate::data_provider::web_api::{
use crate::dictionary::{DictionaryMessage, DictionaryState, app_data_dir}; get_local_version, get_web_version, load_data, set_local_version,
use crate::dictionary_quiz::{DictionaryQuizMessage, DictionaryQuizState}; };
use crate::history::{HistoryMessage, HistoryState}; use crate::fs::*;
use crate::import::{ImportMessage, ImportState};
use crate::lang::Id;
use crate::message_navigation;
use crate::navigation::Page::*; use crate::navigation::Page::*;
use crate::navigation::RootMessage::{DataLoaded, Keyboard, UpdateData}; use crate::navigation::RootMessage::{Keyboard, Notification, PushNotification, UpdateData};
use crate::quiz::{QuizMessage, QuizState}; use crate::notification::NotificationData::PermissionDenied;
use crate::randomizer::{RandomizerMessage, RandomizerState}; use crate::notification::{
use crate::repetition::{RepetitionMessage, RepetitionState}; NotificationData, NotificationManager, NotificationMessage, NotificationRequest,
use crate::repetition_settings::{RepetitionSettingsMessage, RepetitionSettingsState}; };
use crate::repetitions::{RepetitionsMessage, RepetitionsState}; use crate::pages::dictionary::{DictionaryMessage, DictionaryState};
use crate::selector::{SelectorMessage, SelectorState}; use crate::pages::dictionary_quiz::{DictionaryQuizMessage, DictionaryQuizState};
use crate::pages::exams::{ExamsMessage, ExamsState};
use crate::pages::history::{HistoryMessage, HistoryState};
use crate::pages::import::{ImportMessage, ImportState};
use crate::pages::quiz::{QuizMessage, QuizState};
use crate::pages::randomizer::{RandomizerMessage, RandomizerState};
use crate::pages::repetition::{RepetitionMessage, RepetitionState};
use crate::pages::repetition_settings::{RepetitionSettingsMessage, RepetitionSettingsState};
use crate::pages::repetitions::{RepetitionsMessage, RepetitionsState};
use crate::pages::selector::{SelectorMessage, SelectorState};
use crate::pages::sync::{SyncMessage, SyncState};
use crate::pages::writing::{WritingMessage, WritingState};
use crate::state_navigate_handle; use crate::state_navigate_handle;
use crate::state_update; use crate::state_update;
use crate::sync::{SyncMessage, SyncState};
use crate::view_navigation; use crate::view_navigation;
use crate::word::{WordMessage, WordState}; use crate::{message_navigation, push_error, push_error_task};
use crate::writing::{WritingMessage, WritingState};
use crate::{AppState, fill_state};
use chrono::NaiveDate;
use hashbrown::HashMap;
use iced::keyboard::Event; use iced::keyboard::Event;
use iced::widget::stack;
use iced::{Element, Task}; use iced::{Element, Task};
use parking_lot::Mutex; use iced_core::Length::Fill;
use reqwest::Error; use log::{debug, info};
use rusqlite::Connection;
use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use crate::pages::exam::{ExamMessage, ExamState};
impl Default for ScreenState { impl Default for ScreenState {
fn default() -> Self { fn default() -> Self {
let mut state = AppState::new(); let mut state = AppState::new();
fill_state(&mut state); fill_state(&mut state);
let state = Arc::new(Mutex::new(state));
ScreenState { ScreenState {
stack: vec![Selector(SelectorState::new(state.clone()))], stack: vec![Selector(SelectorState::new(state.clone()))],
app_state: state.clone(), app_state: state.clone(),
notification_manager: NotificationManager::new(),
} }
} }
} }
@@ -54,17 +57,20 @@ pub enum RootMessage {
Randomizer(RandomizerMessage), Randomizer(RandomizerMessage),
Repetitions(RepetitionsMessage), Repetitions(RepetitionsMessage),
Repetition(RepetitionMessage), Repetition(RepetitionMessage),
Word(WordMessage),
Sync(SyncMessage), Sync(SyncMessage),
History(HistoryMessage), History(HistoryMessage),
RepetitionSettings(RepetitionSettingsMessage), RepetitionSettings(RepetitionSettingsMessage),
Import(ImportMessage), Import(ImportMessage),
Exams(ExamsMessage),
Exam(ExamMessage),
Keyboard(Event), Keyboard(Event),
DataLoaded(HashMap<Id, Vec<(NaiveDate, u32)>>), PushNotification(NotificationRequest),
Notification(NotificationMessage),
None, None,
UpdateData, UpdateData,
} }
#[allow(clippy::enum_variant_names)]
#[allow(clippy::large_enum_variant)]
pub enum Page { pub enum Page {
Selector(SelectorState), Selector(SelectorState),
Quiz(QuizState), Quiz(QuizState),
@@ -74,86 +80,60 @@ pub enum Page {
Randomizer(RandomizerState), Randomizer(RandomizerState),
Repetitions(RepetitionsState), Repetitions(RepetitionsState),
Repetition(RepetitionState), Repetition(RepetitionState),
Word(WordState),
Sync(SyncState), Sync(SyncState),
History(HistoryState), History(HistoryState),
RepetitionSettings(RepetitionSettingsState), RepetitionSettings(RepetitionSettingsState),
Import(ImportState), Import(ImportState),
Exams(ExamsState),
Exam(ExamState),
PreviousPage, PreviousPage,
} }
pub struct ScreenState { pub struct ScreenState {
stack: Vec<Page>, stack: Vec<Page>,
app_state: Arc<Mutex<AppState>>, app_state: AppState,
notification_manager: NotificationManager,
} }
impl ScreenState { impl ScreenState {
pub fn boot() -> (ScreenState, Task<RootMessage>) { pub fn boot() -> (ScreenState, Task<RootMessage>) {
println!("Booting"); info!("Booting");
create_db(); create_db();
println!("Db created"); info!("Db created");
let state = ScreenState::default(); let state = ScreenState::default();
let reading_additional_task = Task::perform(Self::load_additional_data(), |data| data);
let final_task; let mut final_task = Task::none();
let state_guard = state.app_state.lock(); let state_guard = &state.app_state;
if state_guard.sync_data.auto_web_fetch { if state_guard.sync_data.read().auto_web_fetch
let key = state_guard.sync_data.key.clone().unwrap(); && let Some(key) = state_guard.sync_data.read().key.clone()
final_task = Task::batch([
reading_additional_task,
Task::perform(Self::load_web_backup(key), |result| {
if let Ok(update) = result
&& update
{ {
final_task = Task::batch([
final_task,
Task::perform(Self::load_web_backup(key), |result| match result {
Ok(update) => {
if update {
UpdateData UpdateData
} else { } else {
RootMessage::None RootMessage::None
} }
}
Err(err) => push_error!(err),
}), }),
]); ]);
} else {
final_task = reading_additional_task;
} }
drop(state_guard); info!("Boot finished");
println!("Boot finished");
(state, final_task) (state, final_task)
} }
async fn load_additional_data() -> RootMessage { async fn load_web_backup(string: String) -> Result<bool, NotificationData> {
let directory = history_dir(); let local = get_local_version().await?;
let mut map = HashMap::new();
for file in directory.read_dir().unwrap().flatten() {
let mut vec = vec![];
let history_file_name = match file.file_name().into_string() {
Err(_) => continue,
Ok(v) => v,
};
let id = history_file_name[4..history_file_name.len() - 12]
.parse::<Id>()
.unwrap();
let 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);
}
DataLoaded(map)
}
async fn load_web_backup(string: String) -> Result<bool, Error> {
let local = get_local_version().await;
let web = get_web_version(string.as_str()).await?; let web = get_web_version(string.as_str()).await?;
println!("{} {}", local, web); info!("{} {}", local, web);
if web > local { if web > local {
println!("Web version is newer than local version"); info!("Web version is newer than local version");
load_data(string, true).await?; load_data(string, true).await?;
set_local_version(web).await; set_local_version(web).await?;
} else { } else {
return Ok(false); return Ok(false);
} }
@@ -161,32 +141,37 @@ impl ScreenState {
} }
pub fn update(&mut self, message: RootMessage) -> Task<RootMessage> { pub fn update(&mut self, message: RootMessage) -> Task<RootMessage> {
println!("Updating"); info!("Updating");
let time = Instant::now(); let time = Instant::now();
if let Keyboard(e) = message { if let Keyboard(e) = message {
return self.handle_keyboard(e); return self.handle_keyboard(e);
} }
if let DataLoaded(data) = message {
self.app_state.lock().activity = data;
return Task::none();
}
if let UpdateData = message { if let UpdateData = message {
println!("Loading data"); info!("Loading data");
let mut state = self.app_state.lock(); let state = &mut self.app_state;
if cfg!(windows) { if cfg!(windows) {
state.connection = Connection::open_in_memory().unwrap(); state.connection.lock().finish();
}
let db_file = db_path();
let temp_db_file = temp_db_path();
if std::fs::rename(temp_db_file, &db_file).is_err() {
return push_error_task!(PermissionDenied);
} }
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(); state.connection = Default::default();
fill_state(&mut state); fill_state(state);
return Task::none(); return Task::none();
} }
if let Notification(message) = message {
return self.notification_manager.update(message);
}
if let PushNotification(notification) = &message {
return self.notification_manager.push(notification.clone());
}
let task: (Task<RootMessage>, bool) = self.navigate_or_update(message); let task: (Task<RootMessage>, bool) = self.navigate_or_update(message);
if task.1 { if task.1 {
@@ -200,14 +185,15 @@ impl ScreenState {
Randomizer, Randomizer,
Repetitions, Repetitions,
Repetition, Repetition,
Word,
Sync, Sync,
History, History,
RepetitionSettings, RepetitionSettings,
Import Import,
Exams,
Exam
); );
} }
println!("Update took {:?}", time.elapsed()); debug!("Update took {:?}", time.elapsed());
task.0 task.0
} }
@@ -224,16 +210,17 @@ impl ScreenState {
Randomizer, Randomizer,
Repetitions, Repetitions,
Repetition, Repetition,
Word,
Sync, Sync,
History, History,
RepetitionSettings, RepetitionSettings,
Import Import,
Exams,
Exam
) )
} }
pub fn view(&self) -> Element<'_, RootMessage> { pub fn view(&self) -> Element<'_, RootMessage> {
println!("View update"); info!("View update");
let time = Instant::now(); let time = Instant::now();
let view = view_navigation!( let view = view_navigation!(
self.stack, self.stack,
@@ -245,23 +232,30 @@ impl ScreenState {
Randomizer, Randomizer,
Repetitions, Repetitions,
Repetition, Repetition,
Word,
Sync, Sync,
History, History,
RepetitionSettings, RepetitionSettings,
Import Import,
Exams,
Exam
); );
println!("View took {:?}", time.elapsed()); let notifications = self.notification_manager.draw().map(Notification);
view
debug!("View took {:?}", time.elapsed());
stack![view, notifications].width(Fill).height(Fill).into()
} }
fn handle_keyboard(&mut self, message: Event) -> Task<RootMessage> { fn handle_keyboard(&mut self, message: Event) -> Task<RootMessage> {
let page = self.stack.last_mut().unwrap(); if let Some(page) = self.stack.last_mut() {
match page { return match page {
Repetition(page) => page.press(&message), Repetition(page) => page.press(&message),
Dictionary(page) => page.press(&message),
Exam(page) => page.press(&message),
_ => Task::none(), _ => Task::none(),
};
} }
Task::none()
} }
} }
@@ -273,7 +267,7 @@ pub struct AppSettings {
#[macro_export] #[macro_export]
macro_rules! view_navigation { macro_rules! view_navigation {
($stack:expr, $($e:ident), *) => { ($stack:expr, $($e:ident), *) => {
match &$stack.last().unwrap() { match &$stack.last().expect("Unexpected stack state") {
$( $(
$e(s) => s.view().map(RootMessage::$e), $e(s) => s.view().map(RootMessage::$e),
)* )*
@@ -281,14 +275,13 @@ macro_rules! view_navigation {
} }
} }
} }
#[macro_export] #[macro_export]
macro_rules! state_update { macro_rules! state_update {
($message:expr, $stack:expr, $($e:ident), *) => { ($message:expr, $stack:expr, $($e:ident), *) => {
match $message { match $message {
$( $(
RootMessage::$e(msg) => { RootMessage::$e(msg) => {
if let $e(s) = $stack.last_mut().unwrap() { if let $e(s) = $stack.last_mut().expect("Unexpected stack state") {
message_navigation!(msg, $stack, s) message_navigation!(msg, $stack, s)
} }
else { else {
@@ -300,7 +293,6 @@ macro_rules! state_update {
} }
} }
} }
#[macro_export] #[macro_export]
macro_rules! message_navigation { macro_rules! message_navigation {
($msg:expr, $stack:expr, $state:expr) => { ($msg:expr, $stack:expr, $state:expr) => {
@@ -321,11 +313,10 @@ macro_rules! message_navigation {
} }
}; };
} }
#[macro_export] #[macro_export]
macro_rules! state_navigate_handle { macro_rules! state_navigate_handle {
($stack:expr, $($e:ident), *) => { ($stack:expr, $($e:ident), *) => {
match $stack.last_mut().unwrap() { match $stack.last_mut().expect("Unexpected stack state") {
$( $(
Page::$e(page) => { Page::$e(page) => {
page.navigated(); page.navigated();
@@ -335,14 +326,12 @@ macro_rules! state_navigate_handle {
} }
} }
} }
pub trait NavigatedPage<T> { pub trait NavigatedPage<T> {
fn navigate(&mut self, message: &T) -> Option<Page>; fn navigate(&mut self, message: &T) -> Option<Page>;
fn navigated(&mut self); fn navigated(&mut self);
fn update(&mut self, message: T) -> Task<RootMessage>; fn update(&mut self, message: T) -> Task<RootMessage>;
fn view(&self) -> Element<'_, T>; fn view(&self) -> Element<'_, T>;
} }
pub trait KeyPressedPage { pub trait KeyPressedPage {
fn press(&mut self, message: &Event) -> Task<RootMessage>; fn press(&mut self, message: &Event) -> Task<RootMessage>;
} }
+202
View File
@@ -0,0 +1,202 @@
use crate::navigation::RootMessage;
use crate::styling::{BUTTON_RADIUS, HALF_SPACING};
use chrono::Utc;
use iced::widget::container::Style;
use iced::widget::{Column, container, row, scrollable, text};
use iced::{Element, Task};
use iced_core::Alignment::Center;
use iced_core::Background::Color;
use iced_core::Border;
use iced_core::Length::Fill;
use iced_core::alignment::Horizontal::Right;
use iced_core::alignment::Vertical;
pub struct NotificationManager {
list: Vec<(u32, NotificationRequest, u32)>,
notification_lifetime: u32,
next_id: u32,
}
#[derive(Clone)]
pub enum NotificationMessage {
Remove(u32),
}
impl Default for NotificationManager {
fn default() -> Self {
Self::new()
}
}
impl NotificationManager {
pub fn new() -> Self {
Self {
list: Vec::new(),
notification_lifetime: 10,
next_id: 1,
}
}
pub fn draw(&self) -> Element<'_, NotificationMessage> {
let mut column = Column::new().spacing(HALF_SPACING);
for (id, notif, time) in &self.list {
column = column.push(Self::draw_notif(id, notif, time));
}
container(scrollable(column))
.width(Fill)
.height(Fill)
.align_x(Right)
.align_y(Vertical::Top)
.padding(HALF_SPACING)
.into()
}
pub fn push(&mut self, notification: NotificationRequest) -> Task<RootMessage> {
let id = self.next_id;
self.list.push((
id,
notification,
Utc::now().timestamp() as u32 + self.notification_lifetime,
));
self.next_id += 1;
let message = NotificationMessage::Remove(id);
let time = self.notification_lifetime;
Task::perform(
async move {
tokio::time::sleep(core::time::Duration::from_secs(time as u64)).await;
},
|_| RootMessage::Notification(message),
)
}
pub fn update(&mut self, message: NotificationMessage) -> Task<RootMessage> {
match message {
NotificationMessage::Remove(id) => {
if let Some(index) = self.list.iter().position(|item| item.0 == id) {
self.list.remove(index);
}
}
}
Task::none()
}
fn draw_notif<'a>(
id: &u32,
notif: &NotificationRequest,
_time: &u32,
) -> Element<'a, NotificationMessage> {
let color_id;
let content = match notif {
NotificationRequest::Info(data) => {
color_id = 1;
Self::draw_data(data)
}
NotificationRequest::Warning(data) => {
color_id = 2;
Self::draw_data(data)
}
NotificationRequest::Error(data) => {
color_id = 3;
Self::draw_data(data)
}
};
let container = container(
row![
content,
iced::widget::button("✕")
.on_press(NotificationMessage::Remove(*id))
.style(iced::widget::button::text)
]
.align_y(Center)
.width(300)
.padding(HALF_SPACING),
);
container
.style(move |theme| {
let color = match color_id {
1 => theme.palette().success,
2 => theme.palette().warning,
3 => theme.palette().danger,
_ => theme.palette().warning,
};
Style {
text_color: None,
background: Some(Color(color.scale_alpha(0.3))),
border: Border::default()
.width(1)
.color(color)
.rounded(BUTTON_RADIUS),
shadow: Default::default(),
snap: false,
}
})
.into()
}
fn draw_data<'a>(data: &NotificationData) -> Element<'a, NotificationMessage> {
match data {
NotificationData::DataSynced => {
text!("Данные успешно синхронизированы")
}
NotificationData::ImportError => {
text!("Ошибка при импорте данных из Anki")
}
NotificationData::IncorrectAnkiFile => {
text!("Неподдерживаемый формат файла .apkg")
}
NotificationData::OpenFileError => {
text!("Ошибка открытия файла")
}
NotificationData::VoiceVoxUnavailable => {
text!("Не получается подключиться к VoiceVox")
}
NotificationData::PermissionDenied => {
text!("У приложение недостаточно прав для доступа в файлам")
}
NotificationData::WebApiUnavailable => {
text!("Не получается подключиться к Api")
}
NotificationData::DbSaveError => {
text!("Не получается сохранить данные")
}
NotificationData::DbLoadError => {
text!("Не получается загрузить данные")
}
NotificationData::CompressError => {
text!("Ошибка сжатия или распаковки данных")
}
NotificationData::LocalVersionChanged => {
text!("Локальная версия будет заменена")
}
}
.width(Fill)
.into()
}
}
#[derive(Clone)]
pub enum NotificationRequest {
Info(NotificationData),
Warning(NotificationData),
Error(NotificationData),
}
#[derive(Clone, Debug)]
pub enum NotificationData {
PermissionDenied,
DataSynced,
ImportError,
IncorrectAnkiFile,
OpenFileError,
VoiceVoxUnavailable,
WebApiUnavailable,
DbSaveError,
DbLoadError,
CompressError,
LocalVersionChanged,
}
pub type MaybeError = Result<(), NotificationData>;
+15
View File
@@ -0,0 +1,15 @@
pub mod dictionary;
pub mod dictionary_quiz;
pub mod history;
pub mod import;
pub mod quiz;
pub mod randomizer;
pub mod repetition;
pub mod repetition_settings;
pub mod repetitions;
pub mod selector;
pub mod sync;
pub mod word;
pub mod writing;
pub mod exams;
pub mod exam;
+904
View File
@@ -0,0 +1,904 @@
use crate::app::AppState;
use crate::data::Id;
use crate::data::entities::{EntityHandle, WordData, WordGroup};
use crate::data::repositories::{Query, Repository};
use crate::data_provider::word_groups::{WordGroupDataLayer, WordGroupRepositoryInner};
use crate::data_provider::words::WordDataLayer;
use crate::data_provider::words::WordRepositoryInner;
use crate::navigation::Page::Import;
use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
use crate::pages::dictionary::BasicWordFields::*;
use crate::pages::dictionary::DictionaryMessage::*;
use crate::pages::dictionary::SelectionChangeDirection::*;
use crate::pages::dictionary_quiz::DictionaryQuizState;
use crate::pages::import::ImportState;
use crate::pages::word::{WordEditor, WordMessage};
use crate::styling::*;
use crate::widgets::hard_mouse_area::hard_mouse_area;
use crate::{jl_button, push_error};
use crate::{jl_text_input, push_error_task};
use chrono::{DateTime, TimeDelta, Utc};
use hashbrown::{HashMap, HashSet};
use iced::alignment::Vertical::Center;
use iced::widget::button::Style;
use iced::widget::button::{danger};
use iced::widget::operation::focus;
use iced::widget::space::horizontal;
use iced::widget::text_input::Status::Active;
use iced::widget::text_input::default;
use iced::widget::{row, container, column, text_input, space, button, scrollable, text, Row, checkbox, Column, toggler, lazy};
use iced::{Border, Color, Element, Shadow, Task};
use iced_core::Length::{Fill, FillPortion};
use iced_core::border::Radius;
use iced_core::keyboard::Key::Named;
use iced_core::keyboard::key::Physical::Code;
use iced_core::keyboard::{Event, Modifiers};
use iced_core::{keyboard, Padding, Theme};
use log::info;
use rand::random_range;
use std::ops::Add;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct DictionaryState {
state: AppState,
included: HashSet<EntityHandle<WordData>>,
tag_map: HashMap<String, bool>,
words_tags: Vec<(EntityHandle<WordData>, Vec<String>)>,
reverse: bool,
search: String,
no_typing: bool,
selected_group: EntityHandle<WordGroup>,
reverse_list: bool,
auto_save_queue: HashMap<EntityHandle<WordData>, DateTime<Utc>>,
displayed_words_query: Query<WordData, WordRepositoryInner, WordDataLayer>,
groups_query: Query<WordGroup, WordGroupRepositoryInner, WordGroupDataLayer>,
selected_cell: Option<(EntityHandle<WordData>, BasicWordFields, usize)>,
word_editor: Option<WordEditor<DictionaryMessage>>,
}
#[derive(Clone)]
pub enum DictionaryMessage {
Back,
ExtendedEdit(EntityHandle<WordData>),
DeleteWord(EntityHandle<WordData>),
NewWord,
Include(EntityHandle<WordData>, bool),
IncludeTag(String, bool),
GotoTest,
ResetTags,
SetReverse(bool),
Search(String),
SetTyping(bool),
CreateGroup,
EditGroup(String),
SaveGroup,
SelectGroup(EntityHandle<WordGroup>),
DeleteGroup,
ChangeDirection,
TrySave(EntityHandle<WordData>),
GotoImport,
SelectField(EntityHandle<WordData>, BasicWordFields, usize),
EditField(String),
ChangeSelectedField(SelectionChangeDirection),
EditorAction(WordMessage),
ExtendedWordEdited,
}
#[derive(Clone, PartialEq, Eq, Hash, Copy)]
pub enum SelectionChangeDirection {
Next,
Previous,
Forward,
Backward,
}
#[derive(Clone, PartialEq, Eq, Hash, Copy)]
pub enum BasicWordFields {
Key,
Value,
Tags,
TotalLine,
}
const FIELDS: &[BasicWordFields] = &[Key, Value, Tags];
impl NavigatedPage<DictionaryMessage> for DictionaryState {
fn navigate(&mut self, message: &DictionaryMessage) -> Option<Page> {
if let Back = message {
return Some(Page::PreviousPage);
}
if let GotoTest = message
&& !self.included.is_empty()
{
let mut words = vec![];
let dict = &self.state.dictionary.read();
let query = dict.query(Arc::new(|_| true));
let handles = query.handles();
words = handles.filter(|h| self.included.contains(h)).collect();
return Some(Page::DictionaryQuiz(DictionaryQuizState::new(
words,
self.reverse,
self.no_typing,
self.state.clone(),
)));
}
if let GotoImport = message {
return Some(Import(ImportState::new(self.state.clone())));
}
None
}
fn navigated(&mut self) {
self.included = Default::default();
self.tag_map = Default::default();
let groups = self.state.word_groups.read();
self.groups_query.load(&groups);
drop(groups);
self.update_tags();
}
fn update(&mut self, message: DictionaryMessage) -> Task<RootMessage> {
match message {
NewWord => {
let mut dictionary = self.state.dictionary.write();
let mut word = WordData::new();
word.group_id = self.selected_group.id;
dictionary.create(word);
self.displayed_words_query.load(&dictionary);
}
ExtendedEdit(i) => {
self.word_editor = Some(self.editor_for_word(i));
if let Some((_, _, index)) = self.selected_cell {
self.selected_cell = Some((i, TotalLine, index))
}
}
Include(i, b) => {
if b {
self.included.insert(i);
} else {
self.included.remove(&i);
}
}
IncludeTag(t, v) => {
self.tag_map.insert(t, v);
self.update_words_include(self.selected_group.id)
}
ResetTags => {
self.tag_map.iter_mut().for_each(|(_, v)| *v = false);
self.included.clear();
}
SetReverse(v) => self.reverse = v,
Search(s) => {
self.search = s;
}
SetTyping(b) => self.no_typing = b,
Back => {}
GotoTest => {}
CreateGroup => {
let mut groups = self.state.word_groups.write();
groups.create(WordGroup {
id: 0.into(),
name: format!("Группа слов {}", random_range(100..1000)),
});
self.groups_query.load(&groups);
}
EditGroup(new) => {
let mut groups = self.state.word_groups.write();
let group = groups.get_mut(&mut self.selected_group);
if let Some(group) = group {
group.name = new;
}
}
SaveGroup => {
{
let mut groups = self.state.word_groups.write();
if let Err(err) = groups.save(&mut self.selected_group) {
return push_error_task!(err);
}
self.groups_query.load(&groups);
}
self.setup_words_query();
}
SelectGroup(i) => {
self.selected_group = i;
self.setup_words_query();
self.update_words_include(i.id)
}
DeleteGroup => {
if self.selected_group.id.0 == 1 {
return Task::none();
}
{
let delete_group = self.selected_group;
self.selected_group = EntityHandle::from_id(1.into());
self.setup_words_query();
let mut groups = self.state.word_groups.write();
if let Err(err) = groups.delete(delete_group) {
return push_error_task!(err);
}
if let Err(err) = self.state.dictionary.write().reload() {
return push_error_task!(err);
}
self.groups_query.load(&groups);
}
self.selected_cell = None;
self.word_editor = None;
self.update_tags()
}
ChangeDirection => {
self.reverse_list = !self.reverse_list;
}
TrySave(mut handle) => {
let original = handle;
let now = Utc::now();
if !self.auto_save_queue.contains_key(&handle) {
return Task::none();
}
if self.auto_save_queue[&handle] <= now {
self.update_tags();
self.auto_save_queue.remove(&handle);
let mut dictionary = self.state.dictionary.write();
let reload_query = !handle.real();
let _ = dictionary.save(&mut handle);
if reload_query {
self.displayed_words_query.load(&dictionary);
if let Some(selected) = self.selected_cell
&& selected.0 == original
{
self.selected_cell = Some((handle, selected.1, selected.2));
if self.word_editor.is_some() {
self.word_editor = Some(self.editor_for_word(handle));
}
}
}
info!("Saved word");
return Task::none();
}
}
GotoImport => {}
SelectField(e, f, index) => {
info!("Editing word {}", e.id);
if self.word_editor.is_some()
&& let Some(selected) = &self.selected_cell
&& selected.0 != e
{
self.word_editor = Some(self.editor_for_word(e));
}
self.selected_cell = Some((e, f, index));
return focus(iced::widget::Id::new("editor"));
}
EditField(value) => {
if let Some(selected) = self.selected_cell.as_ref() {
{
let mut dictionary = self.state.dictionary.write();
let word = dictionary.get_mut_static(selected.0).unwrap();
match selected.1 {
Key => {
word.key = value;
}
Value => {
word.value = value;
}
Tags => {
word.tags = value;
}
_ => {}
}
}
return self.launch_auto_save_offset(selected.0);
}
}
ChangeSelectedField(change) => {
return if let Some(selected) = self.selected_cell {
let update_vertical = move |mut dir: bool| {
if self.reverse_list {
dir = !dir;
}
let new_index = if dir {
selected.2.saturating_add(1)
} else {
selected.2.saturating_sub(1)
};
if new_index < self.displayed_words_query.len() {
return SelectField(
self.displayed_words_query.get(new_index).unwrap(),
selected.1,
new_index,
);
}
SelectField(selected.0, selected.1, selected.2)
};
let update_horizontal = move |dir: bool| {
let current = selected.1;
let delta = if dir { 1 } else { -1 };
let mut index =
FIELDS.iter().position(|&f| f == current).unwrap() as i8 + delta;
if index < 0 {
index = (FIELDS.len() - 1) as i8;
}
if index >= FIELDS.len() as i8 {
index = 0;
}
SelectField(selected.0, FIELDS[index as usize], selected.2)
};
Task::done(RootMessage::Dictionary(match change {
Next => update_vertical(true),
Previous => update_vertical(false),
Forward => update_horizontal(true),
Backward => update_horizontal(false),
}))
} else {
Task::none()
};
}
DeleteWord(i) => {
let mut dictionary = self.state.dictionary.write();
if let Err(err) = dictionary.delete(i) {
return push_error_task!(err);
}
self.displayed_words_query.load(&dictionary);
self.selected_cell = None;
self.word_editor = None;
}
EditorAction(action) => {
let result = self.word_editor.as_mut().unwrap().update(action);
if let Some(result) = result {
match result {
DeleteWord(_) | ExtendedWordEdited => {
return Task::done(RootMessage::Dictionary(result));
}
_ => {}
}
}
}
ExtendedWordEdited => {
let handle = self.word_editor.as_ref().unwrap().word_handle;
return self.launch_auto_save_offset(handle);
}
}
Task::none()
}
fn view(&self) -> Element<'_, DictionaryMessage> {
back_overlay(
row![
column![
self.groups_panel(),
column![
row![horizontal().width(8), self.words_list(),],
self.selection_editor(),
],
row![
self.add_word_button(),
horizontal().width(Fill),
button("Импорт").style(button::text).on_press(GotoImport),
]
]
.spacing(5)
.width(FillPortion(4)),
self.right_panel(),
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
impl DictionaryState {
pub fn new(state: AppState) -> Self {
let words_query = state.dictionary.read().empty_query_par();
let groups_query = state.word_groups.read().query(Arc::new(|_| true));
let mut result = DictionaryState {
included: Default::default(),
selected_group: EntityHandle::new(Id(1), 0, true),
state,
tag_map: HashMap::new(),
words_tags: vec![],
reverse: false,
search: String::new(),
no_typing: true,
reverse_list: true,
auto_save_queue: HashMap::new(),
displayed_words_query: words_query,
groups_query,
selected_cell: None,
word_editor: None,
};
result.init();
result.update_tags();
result
}
fn editor_for_word(&self, handle: EntityHandle<WordData>) -> WordEditor<DictionaryMessage> {
WordEditor::new(handle, self.state.clone())
.on_delete(DeleteWord(handle))
.on_edit(ExtendedWordEdited)
}
fn init(&mut self) {
self.displayed_words_query = self
.state
.dictionary
.read()
.query(Arc::new(|word| word.group_id == Id(1)));
}
fn setup_words_query(&mut self) {
let id = self.selected_group.id;
self.displayed_words_query = self
.state
.dictionary
.read()
.query(Arc::new(move |word| word.group_id == id));
}
fn launch_auto_save_offset(&mut self, handle: EntityHandle<WordData>) -> Task<RootMessage> {
let save_time = Utc::now().add(TimeDelta::milliseconds(500));
self.auto_save_queue.insert(handle, save_time);
let message = RootMessage::Dictionary(TrySave(handle));
Task::perform(
async { tokio::time::sleep(Duration::from_millis(520)).await },
|_| message,
)
}
fn words_list(&self) -> Element<'_, DictionaryMessage> {
let time = Instant::now();
let mut col = Column::new().width(Fill);
let query = &self.displayed_words_query;
let dictionary = self.state.dictionary.read();
for access_index in 0..query.len() {
let mut i = access_index;
if self.reverse_list {
i = query.len() - access_index - 1;
}
let word_handle = &query[i];
let word = dictionary.get_ref_static(*word_handle).unwrap();
if !self.search.is_empty()
&& !word.key.contains(&self.search)
&& !word.value.contains(&self.search)
&& !word.tags.contains(&self.search)
{
continue;
}
let mut selection = None;
if let Some(cell) = self.selected_cell.as_ref()
&& cell.0 == *word_handle
{
selection = Some(cell.1);
}
let word_line_data = WordLineState {
is_included: self.included.contains(word_handle),
key: word.key.clone(),
value: word.value.clone(),
tags: word.tags.clone(),
handle: *word_handle,
selection,
number: i,
};
let lazy_line = lazy(word_line_data, Self::word_line);
col = col.push(lazy_line);
}
info!("Words rendering time: {:?}", time.elapsed());
scrollable(col).height(Fill).into()
}
fn word_line<'a>(data: &WordLineState) -> Element<'a, DictionaryMessage> {
let mut line = Row::new().width(Fill).align_y(Center);
let handle = data.handle;
line = line.push(
checkbox(data.is_included)
.label("")
.spacing(15)
.on_toggle(move |b| Include(handle, b)),
);
let field = |placeholder: &str, data: &str, selected: bool| {
text_input(placeholder, data)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.style(move |x, _| {
let mut default_style = default(x, Active);
default_style.border.radius = 0.0.into();
if selected {
default_style.border.color = x.extended_palette().secondary.base.color;
default_style.border.width = 3.0_f32;
}
default_style
})
};
line = line.push(
hard_mouse_area(field(
"Слово",
&data.key,
data.selection.is_some() && data.selection.unwrap() == Key,
))
.on_press(SelectField(data.handle, Key, data.number)),
);
line = line.push(
hard_mouse_area(field(
"Перевод",
&data.value,
data.selection.is_some() && data.selection.unwrap() == Value,
))
.on_press(SelectField(data.handle, Value, data.number)),
);
line = line.push(
hard_mouse_area(field(
"Теги",
&data.tags,
data.selection.is_some() && data.selection.unwrap() == Tags,
))
.on_press(SelectField(data.handle, Tags, data.number)),
);
let line_button = || {
if !data.handle.real() {
return button("-")
.on_press(DeleteWord(data.handle))
.style(|_x: &Theme, _status: button::Status| Style {
background: None,
text_color: Color::BLACK,
border: Border::default(),
shadow: Shadow::default(),
snap: false,
})
.width(15);
}
button("")
.on_press(ExtendedEdit(data.handle))
.style(button::primary)
.width(15)
};
let assembled = line.push(line_button()).push(space().width(10));
if let Some(selection) = data.selection
&& selection == TotalLine
{
return container(assembled.padding(Padding {
top: 2.0,
right: 0.0,
bottom: 2.0,
left: 0.0,
}))
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.border.color = theme.extended_palette().secondary.base.color;
style.border.width = 3.0_f32;
style
})
.into();
}
assembled.into()
}
fn right_panel(&self) -> Element<'_, DictionaryMessage> {
if let Some(editor) = &self.word_editor {
container(editor.view().map(EditorAction))
.width(FillPortion(3))
.into()
} else {
self.filters()
}
}
fn filters(&self) -> Element<'_, DictionaryMessage> {
let dict = &self.state.dictionary.read();
column![
jl_text_input!("Поиск", &self.search)
.on_input(Search)
.width(Fill),
text!("Всего слов: {}", dict.len()),
text!("Выбрано слов: {}", self.included.iter().count()),
self.tags_selector(),
toggler(self.no_typing)
.label("Без набора")
.on_toggle(SetTyping),
toggler(self.reverse)
.label("Обратный тест")
.on_toggle(SetReverse),
button(text!("Тест").center().width(Fill))
.style(cta_button)
.on_press(GotoTest)
.width(Fill),
]
.width(FillPortion(1))
.spacing(DEFAULT_SPACING)
.into()
}
fn tags_selector(&self) -> Element<'_, DictionaryMessage> {
let mut col = Column::new().width(Fill);
col = col.push(
button("Сбросить")
.on_press(ResetTags)
.style(|x: &Theme, _status| Style {
background: None,
text_color: x.palette().primary,
border: Border::default(),
shadow: Shadow::default(),
snap: false,
}),
);
let mut sorted_tags = self.tag_map.iter().collect::<Vec<_>>();
sorted_tags.sort();
for tag in sorted_tags {
col = col.push(
checkbox(*tag.1)
.label(tag.0)
.on_toggle(|x1| IncludeTag(tag.0.clone(), x1)),
)
}
container(scrollable(col)).height(Fill).into()
}
fn update_tags(&mut self) {
self.words_tags.clear();
let mut tags_list: HashSet<String> = HashSet::new();
let dict = &self.state.dictionary.write();
let mut words_query = dict.full_query();
let handles = words_query.handles().collect::<Vec<_>>();
words_query
.iter(dict)
.zip(handles)
.for_each(|(element, handle)| {
let tags = split_with_coma(element.tags.as_str());
tags.iter().for_each(|tag| {
tags_list.insert(tag.clone());
});
self.words_tags.push((handle, tags))
});
let current_tags = self
.tag_map
.keys()
.map(|k| k.clone().to_string())
.collect::<Vec<String>>();
current_tags
.iter()
.filter(|tag| !tags_list.contains(*tag))
.for_each(|tag| {
self.tag_map.remove(tag);
});
tags_list.iter().for_each(|tag| {
self.tag_map.insert(tag.clone(), false);
});
}
fn update_words_include(&mut self, group_id: Id) {
let include_tags = self
.tag_map
.iter()
.filter(|(_, value)| **value)
.map(|(t, _)| t.clone())
.collect::<Vec<String>>();
if include_tags.is_empty() {
self.included.clear();
return;
}
let dict = &self.state.dictionary.read();
let time = Instant::now();
self.included.clear();
for (handle, tags) in &mut self.words_tags {
if let Some(word) = dict.get_ref(handle) {
if word.group_id != group_id {
continue;
}
if !tags.is_empty() && tags.iter().all(|t| include_tags.contains(t)) {
self.included.insert(*handle);
}
} else {
continue;
}
}
info!("Time {}", time.elapsed().as_micros());
}
fn selection_editor(&self) -> Element<'_, DictionaryMessage> {
let mut display = String::new();
let mut editable = false;
if let Some(selected) = self.selected_cell.as_ref()
&& selected.1 != TotalLine
{
let mut dictionary = self.state.dictionary.write();
let word = dictionary.get_mut_static(selected.0).unwrap();
match selected.1 {
Key => {
display = word.key.clone();
}
Value => {
display = word.value.clone();
}
Tags => {
display = word.tags.clone();
}
_ => {}
}
editable = true;
}
let mut field = jl_text_input!("", display.as_str())
.width(Fill)
.id(iced::widget::Id::new("editor"))
.style(|theme, status| {
let mut default = default(theme, status);
default.border.radius = Radius::new(0);
default
});
if editable {
field = field.on_input(EditField)
}
field.into()
}
fn groups_panel(&self) -> Element<'_, DictionaryMessage> {
let mut row = Row::new();
row = row.push(button("+").style(button::text).on_press(CreateGroup));
let groups = &self.state.word_groups.read();
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))
.style(button::text)
.on_press(SelectGroup(group)),
);
}
let groups = self.state.word_groups.read();
let group = groups.get_static(self.selected_group).unwrap();
column![
scrollable(row).width(Fill).horizontal(),
row![
jl_button!("⇳").on_press(ChangeDirection),
jl_text_input!("Название группы слов", &group.name)
.on_input(EditGroup)
.width(250)
.on_submit(SaveGroup),
horizontal(),
self.group_delete_button(),
]
.spacing(DEFAULT_SPACING / 2.0)
]
.into()
}
fn group_delete_button(&self) -> Element<'_, DictionaryMessage> {
if self.selected_group.real() && self.selected_group.id != 1.into() {
button("Удалить").style(danger).on_press(DeleteGroup).into()
} else {
space().into()
}
}
fn add_word_button(&self) -> Element<'_, DictionaryMessage> {
let button = jl_button!("Добавить слово");
if self.selected_group.real() {
return button.on_press(NewWord).into();
}
button.into()
}
}
impl KeyPressedPage for DictionaryState {
fn press(&mut self, message: &Event) -> Task<RootMessage> {
if let Event::KeyPressed {
key: _,
modified_key: modifier,
physical_key: pk,
location: _,
modifiers: mods,
text: _,
repeat: _,
} = message
&& let Code(code) = pk
{
return match code {
keyboard::key::Code::ArrowDown => {
Task::done(RootMessage::Dictionary(ChangeSelectedField(Next)))
}
keyboard::key::Code::ArrowUp => {
Task::done(RootMessage::Dictionary(ChangeSelectedField(Previous)))
}
keyboard::key::Code::Tab => {
return if *mods == Modifiers::CTRL
&& let Some(selected) = self.selected_cell
{
Task::done(RootMessage::Dictionary(ExtendedEdit(selected.0)))
} else if let Named(keyboard::key::Named::Alt) = modifier {
Task::done(RootMessage::Dictionary(ChangeSelectedField(Backward)))
} else {
Task::done(RootMessage::Dictionary(ChangeSelectedField(Forward)))
};
}
keyboard::key::Code::Escape => {
if self.word_editor.is_some() {
self.word_editor = None;
if let Some(selected) = self.selected_cell
&& selected.1 == TotalLine
{
self.selected_cell = None;
}
} else if self.selected_cell.is_some() {
self.selected_cell = None;
}
Task::none()
}
_ => Task::none(),
};
}
Task::none()
}
}
pub fn split_with_coma(ts: &str) -> Vec<String> {
ts.split(',')
.map(|ts| ts.to_lowercase().trim().to_string())
.filter(|t| !t.is_empty())
.collect::<Vec<String>>()
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct WordLineState {
is_included: bool,
key: String,
value: String,
tags: String,
handle: EntityHandle<WordData>,
selection: Option<BasicWordFields>,
number: usize,
}
@@ -1,22 +1,25 @@
use crate::RootMessage; use crate::app::AppState;
use crate::dictionary::split_with_coma; use crate::data::entities::{EntityHandle, WordData};
use crate::dictionary_quiz::DictionaryQuizMessage::*; use crate::data::repositories::Repository;
use crate::lang::WordData;
use crate::navigation::Page::PreviousPage; use crate::navigation::Page::PreviousPage;
use crate::navigation::RootMessage;
use crate::navigation::*; use crate::navigation::*;
use crate::quiz::Score; use crate::pages::dictionary::split_with_coma;
use crate::pages::dictionary_quiz::DictionaryQuizMessage::*;
use crate::pages::quiz::Score;
use crate::styling::*; use crate::styling::*;
use iced::Background::Color; use crate::{jl_button, jl_text_input};
use iced::border::Radius; use iced::border::Radius;
use iced::widget::container::Style; use iced::widget::container::Style;
use iced::widget::{Row, button, container, row, space, text, text_input}; use iced::widget::{container, row, space, text, Row};
use iced::{Border, Element, Fill, Task, Theme, alignment}; use iced::Background::Color;
use iced::{alignment, Border, Element, Fill, Task, Theme};
use rand::prelude::SliceRandom; use rand::prelude::SliceRandom;
#[derive(Debug, Clone)]
pub struct DictionaryQuizState { pub struct DictionaryQuizState {
words: Vec<WordData>, state: AppState,
current_set: Vec<WordData>, words: Vec<EntityHandle<WordData>>,
current_set: Vec<EntityHandle<WordData>>,
answer: String, answer: String,
view: String, view: String,
correct: String, correct: String,
@@ -73,7 +76,7 @@ impl NavigatedPage<DictionaryQuizMessage> for DictionaryQuizState {
] ]
.align_x(alignment::Horizontal::Center) .align_x(alignment::Horizontal::Center)
.spacing(5), .spacing(5),
text_input("Перевод", &self.answer) jl_text_input!("Перевод", &self.answer)
.size(28) .size(28)
.width(250) .width(250)
.on_input(AnswerChanged) .on_input(AnswerChanged)
@@ -88,10 +91,7 @@ impl NavigatedPage<DictionaryQuizMessage> for DictionaryQuizState {
.size(25), .size(25),
] ]
.spacing(DEFAULT_SPACING), .spacing(DEFAULT_SPACING),
row![ row![jl_button!("Закончить").on_press(Back), self.appeal_button()]
button("Закончить").style(jl_button).on_press(Back),
self.appeal_button()
]
.spacing(DEFAULT_SPACING), .spacing(DEFAULT_SPACING),
] ]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
@@ -104,13 +104,19 @@ impl NavigatedPage<DictionaryQuizMessage> for DictionaryQuizState {
} }
impl DictionaryQuizState { impl DictionaryQuizState {
pub fn new(words: Vec<WordData>, reverse: bool, no_typing: bool) -> DictionaryQuizState { pub fn new(
DictionaryQuizState { words: Vec<EntityHandle<WordData>>,
reverse: bool,
no_typing: bool,
state: AppState,
) -> DictionaryQuizState {
Self {
state,
words, words,
current_set: Vec::new(), current_set: Vec::new(),
answer: "".to_string(), answer: String::new(),
view: "---".to_string(), view: "---".to_string(),
correct: "".to_string(), correct: String::new(),
score: Default::default(), score: Default::default(),
is_help: false, is_help: false,
reverse, reverse,
@@ -146,8 +152,9 @@ impl DictionaryQuizState {
if !self.is_help { if !self.is_help {
self.score.total += 1; self.score.total += 1;
} }
if self.answer == self.correct if self.answer.to_lowercase() == self.correct.to_lowercase()
|| split_with_coma(self.correct.as_str()).contains(&self.answer) || split_with_coma(self.correct.to_lowercase().as_str())
.contains(&self.answer.to_lowercase())
{ {
if !self.is_help { if !self.is_help {
self.score.correct += 1; self.score.correct += 1;
@@ -171,7 +178,7 @@ impl DictionaryQuizState {
self.is_help = false; self.is_help = false;
self.answer = String::new(); self.answer = String::new();
let next: WordData = match self.current_set.pop() { let next: EntityHandle<WordData> = match self.current_set.pop() {
Some(word) => word, Some(word) => word,
None => { None => {
self.update_set(); self.update_set();
@@ -179,12 +186,14 @@ impl DictionaryQuizState {
} }
}; };
let repo = self.state.dictionary.read();
let next = repo.get_static(next).unwrap();
if self.reverse { if self.reverse {
self.view = next.value.clone(); self.view = next.value;
self.correct = next.key.clone(); self.correct = next.key;
} else { } else {
self.view = next.key.clone(); self.view = next.key;
self.correct = next.value.clone(); self.correct = next.value;
} }
} }
@@ -210,7 +219,7 @@ impl DictionaryQuizState {
fn appeal_button(&self) -> Element<'_, DictionaryQuizMessage> { fn appeal_button(&self) -> Element<'_, DictionaryQuizMessage> {
if self.is_help && !self.no_typing { if self.is_help && !self.no_typing {
return button("Апелляция").style(jl_button).on_press(Appeal).into(); return jl_button!("Апелляция").on_press(Appeal).into();
} }
space().into() space().into()
} }
+253
View File
@@ -0,0 +1,253 @@
use crate::app::AppState;
use crate::data::entities::{CustomField, DeckSettings, EntityHandle, ExamEntry, WordData};
use crate::data::repositories::Repository;
use crate::data::Id;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
use crate::pages::exam::ExamMessage::*;
use crate::pages::repetition::play_sound;
use crate::styling::*;
use crate::{push_error, push_error_task};
use iced::widget::space::horizontal;
use iced::widget::{button, column, container, progress_bar, row, text, tooltip};
use iced::{Element, Task};
use iced_core::keyboard::key::Physical::Code;
use iced_core::keyboard::Event;
use iced_core::widget::text::Style;
use iced_core::Alignment::Center;
use iced_core::Length::Fill;
use iced_core::{keyboard, Theme};
use rand::prelude::SliceRandom;
use rand::rng;
use rodio::MixerDeviceSink;
use std::sync::Arc;
pub struct ExamState {
id: Id,
state: AppState,
entries: Vec<ExamEntry>,
total_count: u32,
open: bool,
custom_fields_cache: Vec<CustomField>,
current_word: WordData,
current_exam_entry_handle: EntityHandle<ExamEntry>,
deck: DeckSettings,
can_play: bool,
sink: Arc<Option<MixerDeviceSink>>,
}
#[derive(Clone)]
pub enum ExamMessage {
Back,
Open,
Answer(bool),
Play,
PlayFinished,
}
impl NavigatedPage<ExamMessage> for ExamState {
fn navigate(&mut self, message: &ExamMessage) -> Option<Page> {
if let Back = message {
return Some(PreviousPage);
}
None
}
fn navigated(&mut self) {}
fn update(&mut self, message: ExamMessage) -> Task<RootMessage> {
match message {
Back => {}
Open => {
if self.open {
return Task::none();
}
self.open = true;
}
Answer(answer) => {
if !self.open {
return Task::none();
}
let id = self.current_exam_entry_handle.id;
let mut repo = self.state.exam_entries.write();
repo.set(
&mut self.current_exam_entry_handle,
ExamEntry {
id,
exam_id: self.id,
word_id: self.current_word.id,
status: Some(answer),
},
);
if let Err(err) = repo.save(&mut self.current_exam_entry_handle) {
return push_error_task!(err);
}
drop(repo);
self.open = false;
return self.next();
}
Play => {
if !self.can_play {
return Task::none();
}
self.can_play = false;
let value = self.current_word.key.clone();
if self.deck.require_speech() {
return Task::perform(play_sound(self.sink.clone(), value), |r| match r {
Ok(_) => RootMessage::Exam(PlayFinished),
Err(err) => push_error!(err),
});
}
}
PlayFinished => {
self.can_play = true;
}
}
Task::none()
}
fn view(&self) -> Element<'_, ExamMessage> {
back_overlay(
column![
column![
progress_bar(
0.0..=(self.total_count as f32),
(self.total_count - self.entries.len() as u32) as f32
),
row![
text!("Решено: {}", self.total_count - self.entries.len() as u32),
horizontal().width(Fill),
text!("Осталось: {}", self.entries.len())
],
],
crate::widgets::card_display::word_display(
&self.deck,
&self.current_word,
self.open,
Play,
self.custom_fields_cache.as_slice()
),
container(self.answer_bar())
.width(Fill)
.align_x(Center)
.height(80),
]
.into(),
Back,
)
}
}
impl ExamState {
fn answer_bar(&self) -> Element<'_, ExamMessage> {
if !self.open {
return text!("Нажмите Пробел для открытия карточки")
.style(|theme: &Theme| Style {
color: Some(theme.palette().text.scale_alpha(0.3)),
})
.into();
}
row![
tooltip(
button("Не знаю").on_press(Answer(false)),
"Клавиша 1",
tooltip::Position::Top
),
tooltip(
button("Знаю").on_press(Answer(true)),
"Клавиша 2",
tooltip::Position::Top
),
]
.spacing(QUARTER_SPACING)
.into()
}
}
impl ExamState {
pub fn new(exam_id: Id, deck: DeckSettings, state: &AppState) -> Self {
let custom_fields_cache = state.custom_fields.read().iter().cloned().collect();
let sink_handle = if deck.require_speech() {
rodio::DeviceSinkBuilder::open_default_sink().ok()
} else {
None
};
let mut entries: Vec<_> = state
.exam_entries
.read()
.iter()
.filter(|e| e.exam_id == exam_id)
.cloned()
.collect();
let total_count = entries.len() as u32;
entries.retain(|e| e.status.is_none());
entries.shuffle(&mut rng());
let word = state
.dictionary
.read()
.get_static(EntityHandle::from_id(entries.last().unwrap().word_id))
.unwrap();
Self {
id: exam_id,
state: state.clone(),
current_word: word,
current_exam_entry_handle: EntityHandle::from_id(entries.last().unwrap().id),
entries,
total_count,
open: false,
custom_fields_cache,
deck,
can_play: true,
sink: Arc::new(sink_handle),
}
}
fn next(&mut self) -> Task<RootMessage> {
self.entries.pop().unwrap();
if self.entries.is_empty() {
return Task::done(RootMessage::Exam(Back));
}
let entry = self.entries.last().unwrap();
let handle: EntityHandle<ExamEntry> = EntityHandle::from_id(entry.id);
let word = self
.state
.dictionary
.read()
.get_static(EntityHandle::from_id(entry.word_id))
.unwrap();
self.current_word = word;
self.current_exam_entry_handle = handle;
Task::none()
}
}
impl KeyPressedPage for ExamState {
fn press(&mut self, message: &Event) -> Task<RootMessage> {
if let Event::KeyPressed {
key: _,
modified_key: _,
physical_key: pk,
location: _,
modifiers: _,
text: _,
repeat: _,
} = message
&& let Code(code) = pk
{
return match code {
keyboard::key::Code::Space => Task::done(RootMessage::Exam(Open)),
keyboard::key::Code::Digit1 => Task::done(RootMessage::Exam(Answer(false))),
keyboard::key::Code::Digit2 => Task::done(RootMessage::Exam(Answer(true))),
_ => Task::none(),
};
}
Task::none()
}
}
+440
View File
@@ -0,0 +1,440 @@
use crate::app::AppState;
use crate::data::entities::{DeckSettings, EntityHandle, Exam, ExamEntry, WordData};
use crate::data::repositories::{BatchedRepository, Repository};
use crate::data::Id;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::MaybeError;
use crate::pages::exam::ExamState;
use crate::pages::exams::ExamsMessage::*;
use crate::pages::repetitions::Style;
use crate::push_error;
use crate::styling::{
back_overlay, cta_button, BUTTON_RADIUS, DEFAULT_SPACING, HALF_SPACING, QUARTER_SPACING,
};
use crate::{jl_button, push_error_task};
use chrono::{Local, Utc};
use iced::widget::button::Status;
use iced::widget::container::bordered_box;
use iced::widget::space::horizontal;
use iced::widget::{
button, center_x, column, container, row, rule, scrollable, space, text, tooltip,
Column,
};
use iced::{Element, Task};
use iced_core::Alignment::*;
use iced_core::Length::{Fill, FillPortion};
use iced_core::{Background, Border, Color, Shadow, Theme};
use std::collections::BTreeMap;
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
pub struct ExamsState {
state: AppState,
deck_id: EntityHandle<DeckSettings>,
deck: DeckSettings,
selected_exam_id: Option<EntityHandle<Exam>>,
exams: BTreeMap<EntityHandle<Exam>, ExamExtended>,
}
#[derive(Clone, Eq, PartialEq)]
pub enum ExamsMessage {
Back,
SelectExam(EntityHandle<Exam>),
CreateExam,
GotoExam,
}
impl NavigatedPage<ExamsMessage> for ExamsState {
fn navigate(&mut self, message: &ExamsMessage) -> Option<Page> {
if let Back = message {
return Some(PreviousPage);
}
if let Some(selected_exam_id) = self.selected_exam_id
&& GotoExam == *message
{
return Some(Page::Exam(ExamState::new(
selected_exam_id.id,
self.deck.clone(),
&self.state,
)));
}
None
}
fn navigated(&mut self) {
self.exams = Self::get_exams_list(&self.state, self.deck_id.id);
}
fn update(&mut self, message: ExamsMessage) -> Task<RootMessage> {
match message {
Back => {}
GotoExam => {}
SelectExam(id) => {
self.selected_exam_id = Some(id);
}
CreateExam => {
let exam = Exam {
id: Default::default(),
timestamp: Utc::now(),
deck_id: self.deck_id.id,
};
let mut exam_repo = self.state.exam.write();
let handle = exam_repo.create_now(exam);
if let Ok(handle) = handle {
drop(exam_repo);
if let Err(err) = self.append_exam_entries(handle) {
return push_error_task!(err);
}
self.exams = Self::get_exams_list(&self.state, self.deck_id.id);
self.selected_exam_id = Some(handle);
} else {
return push_error_task!(handle.err().unwrap());
}
}
}
Task::none()
}
fn view(&self) -> Element<'_, ExamsMessage> {
back_overlay(
column![
text(format!("Экзамены для колоды {}", self.deck.name)),
row![
column![
scrollable(self.exams_list()).height(Fill),
jl_button!("Добавить").width(Fill).on_press(CreateExam),
]
.spacing(DEFAULT_SPACING)
.width(FillPortion(1)),
self.selected_exam_view(),
].spacing(DEFAULT_SPACING)
]
.into(),
Back,
)
}
}
impl ExamsState {
pub fn new(deck_id: EntityHandle<DeckSettings>, state: AppState) -> Self {
let deck = state.decks.read().get_static(deck_id).unwrap();
Self {
exams: Self::get_exams_list(&state, deck_id.id),
state,
deck_id,
deck,
selected_exam_id: None,
}
}
fn selected_exam(&self) -> Option<&ExamExtended> {
self.exams.get(&self.selected_exam_id?)
}
fn append_exam_entries(&mut self, handle: EntityHandle<Exam>) -> MaybeError {
let deck_id = self.deck_id.id;
let words: Vec<_> = self
.state
.card_states
.read()
.iter()
.filter(|state| state.deck_id == deck_id)
.map(|state| state.word_id)
.collect();
let mut entries = words
.iter()
.map(|word_id| ExamEntry {
id: Default::default(),
exam_id: handle.id,
word_id: *word_id,
status: None,
})
.collect::<Vec<_>>();
self.state
.exam_entries
.write()
.create_now_many(entries.as_mut_slice())?;
Ok(())
}
fn get_exams_list(state: &AppState, deck_id: Id) -> BTreeMap<EntityHandle<Exam>, ExamExtended> {
let mut query = state
.exam
.read()
.query(Arc::new(move |exam| exam.deck_id == deck_id));
let handles: Vec<_> = query.handles().collect();
query
.iter(&state.exam.read())
.cloned()
.zip(handles)
.map(|(e, h)| (h, ExamExtended::from(e, state)))
.collect()
}
}
impl ExamsState {
pub fn exams_list(&self) -> Column<'_, ExamsMessage> {
let mut column = Column::new();
for (handle, deck) in self.exams.iter() {
column = column.push(
jl_button!(text!(
"{}",
&deck
.timestamp
.with_timezone(&Local)
.format("%d/%m/%Y %H:%M")
))
.on_press_with(move || SelectExam(*handle))
.style(move |_x: &Theme, status: Status| Style {
background: if status == Status::Hovered {
Some(Background::Color(Color::WHITE.scale_alpha(0.2)))
} else {
None
},
text_color: if deck.finished() {
_x.palette().success
} else {
_x.palette().primary
},
border: Border {
color: Default::default(),
width: 0.0,
radius: BUTTON_RADIUS.into(),
},
shadow: Shadow::default(),
snap: false,
}),
);
}
column
}
pub fn selected_exam_view(&self) -> Element<'_, ExamsMessage> {
if let Some(exam) = self.selected_exam() {
return column![
center_x(text!(
"Экзамен от {}",
exam.timestamp
.with_timezone(&Local)
.format("%d/%m/%Y %H:%M")
)),
row![text!("{} карточек", exam.total), Self::exam_bar(exam)]
.height(30)
.align_y(Center)
.width(Fill)
.spacing(HALF_SPACING),
rule::horizontal(2),
self.exam_variable_view(exam),
]
.spacing(DEFAULT_SPACING)
.width(FillPortion(2))
.into();
}
space().width(FillPortion(3)).into()
}
fn exam_bar(exam: &ExamExtended) -> Element<'_, ExamsMessage> {
let total = 1000.0;
let answered = (exam.answered as f64 / exam.total as f64 * total).round();
let unknown = (exam.unknown as f64 / exam.total as f64 * total).round();
let remaining = (exam.remaining() as f64 / exam.total as f64 * total).round();
let mut row = row![];
if answered > 0.0 {
row = row.push(tooltip(
container(space())
.height(Fill)
.width(FillPortion(answered as u16))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().success)),
text_color: Default::default(),
border: Default::default(),
shadow: Default::default(),
snap: false,
}),
text!("Правильно: {}% ({})", answered / 10.0, exam.answered),
tooltip::Position::Bottom,
));
}
if unknown > 0.0 {
row = row.push(tooltip(
container(space())
.height(Fill)
.width(FillPortion(unknown as u16))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().danger)),
text_color: Default::default(),
border: Default::default(),
shadow: Default::default(),
snap: false,
}),
text!("Не получилось: {}% ({})", unknown / 10.0, exam.unknown),
tooltip::Position::Bottom,
));
}
if remaining > 0.0 {
row = row.push(tooltip(
container(space())
.height(Fill)
.width(FillPortion(remaining as u16))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().primary)),
text_color: Default::default(),
border: Default::default(),
shadow: Default::default(),
snap: false,
}),
text!("Осталось {}% ({})", remaining / 10.0, exam.remaining()),
tooltip::Position::Bottom,
));
}
row.width(Fill).height(Fill).into()
}
fn exam_variable_view(&self, exam: &ExamExtended) -> Element<'_, ExamsMessage> {
if exam.finished() {
self.exam_finished_view(exam)
} else {
self.exam_ongoing_view(exam)
}
}
fn exam_finished_view(&self, exam: &ExamExtended) -> Element<'_, ExamsMessage> {
let result = (exam.answered as f64 / exam.total as f64 * 1000.0).round() / 10.0;
let words_repo = self.state.dictionary.read();
column![
center_x(text!("Итоговый результат: {}%", result).size(28)),
row![
Self::words_list(
"Правильно",
exam.answered_list
.iter()
.map(|handle| words_repo.get_ref_static(*handle).unwrap())
.collect::<Vec<_>>()
.as_slice()
)
.width(Fill),
Self::words_list(
"Не получилось",
exam.unknows_list
.iter()
.map(|handle| words_repo.get_ref_static(*handle).unwrap())
.collect::<Vec<_>>()
.as_slice()
)
.width(Fill)
]
.width(Fill)
.spacing(QUARTER_SPACING)
]
.spacing(DEFAULT_SPACING)
.into()
}
fn exam_ongoing_view(&self, _: &ExamExtended) -> Element<'_, ExamsMessage> {
center_x(
button("Продолжить экзамен")
.style(cta_button)
.on_press(GotoExam),
)
.into()
}
fn words_list<'a>(name: &str, words: &[&WordData]) -> Column<'a, ExamsMessage> {
let mut column = column![];
for word in words {
let container = container(
column![text!("{}", word.key), text!("{}", word.value)].padding(QUARTER_SPACING),
)
.width(Fill)
.style(bordered_box);
column = column.push(container);
}
column![
text!("{}", name),
scrollable(row![
column.spacing(QUARTER_SPACING),
horizontal().width(DEFAULT_SPACING)
])
]
.align_x(Center)
}
}
struct ExamExtended {
exam: Exam,
total: u32,
answered: u32,
unknown: u32,
answered_list: Vec<EntityHandle<WordData>>,
unknows_list: Vec<EntityHandle<WordData>>,
}
impl ExamExtended {
fn from(exam: Exam, state: &AppState) -> Self {
let entries = state.exam_entries.read();
let mut total = 0;
let mut answered = 0;
let mut unknown = 0;
let mut answered_list = Vec::with_capacity(128);
let mut unknows_list = Vec::with_capacity(128);
entries
.iter()
.filter(|e| e.exam_id == exam.id)
.for_each(|e| {
total += 1;
match e.status {
None => {}
Some(s) => {
if s {
answered += 1;
answered_list.push(EntityHandle::from_id(e.word_id));
} else {
unknown += 1;
unknows_list.push(EntityHandle::from_id(e.word_id));
}
}
}
});
Self {
exam,
total,
answered,
unknown,
answered_list,
unknows_list,
}
}
fn solved(&self) -> u32 {
self.answered + self.unknown
}
fn finished(&self) -> bool {
self.solved() == self.total
}
fn remaining(&self) -> u32 {
self.total - self.solved()
}
}
impl Deref for ExamExtended {
type Target = Exam;
fn deref(&self) -> &Self::Target {
&self.exam
}
}
impl DerefMut for ExamExtended {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.exam
}
}
+34 -39
View File
@@ -1,21 +1,18 @@
use crate::data_provider::history::{get_history_of_set, HistoryItem}; use crate::app::AppState;
use crate::lang::{WordData, INVALID_ID}; use crate::data::entities::{HistoryNote, WordData};
use crate::navigation::{NavigatedPage, Page}; use crate::data::repositories::Repository;
use crate::navigation::*;
use crate::styling::*; use crate::styling::*;
use crate::{AppState, RootMessage}; use HistoryMessage::Back;
use chrono::Local; use chrono::Local;
use iced::alignment::Horizontal::Center; use iced::alignment::Horizontal::Center;
use iced::widget::space::horizontal; use iced::widget::space::horizontal;
use iced::widget::*; use iced::widget::*;
use iced::{Element, Fill, FillPortion, Task}; use iced::{Element, Fill, FillPortion, Task};
use parking_lot::Mutex;
use std::sync::Arc; use std::sync::Arc;
use HistoryMessage::Back;
#[derive(Clone)]
pub struct HistoryState { pub struct HistoryState {
list: Vec<HistoryItem>, list: Vec<(WordData, HistoryNote)>,
words: Vec<WordData>,
} }
impl NavigatedPage<HistoryMessage> for HistoryState { impl NavigatedPage<HistoryMessage> for HistoryState {
@@ -47,51 +44,49 @@ impl NavigatedPage<HistoryMessage> for HistoryState {
} }
impl HistoryState { impl HistoryState {
pub fn new(id: crate::lang::Id, state: Arc<Mutex<AppState>>) -> Self { pub fn new(id: crate::data::Id, state: AppState) -> Self {
let state = state.lock(); let dict = &state.dictionary.read();
let history = get_history_of_set(id); let history_repo = &mut state.history.write();
let words = history history_repo.init().expect("TODO: panic message");
.iter() let mut history = history_repo.query(Arc::new(move |h| h.deck_id == id));
.map( let mut words: Vec<_> = history
|item| match state.dictionary.iter().find(|w| w.id == item.word_id) { .iter(history_repo)
None => WordData { .map(|item| {
id, 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(), key: "Удаленное слово".to_string(),
value: "Удаленное слово".to_string(), value: String::new(),
tags: "Удаленное слово".to_string(), tags: String::new(),
additional: Default::default(), additional: Default::default(),
group_id: INVALID_ID, });
}, (word, item.clone())
Some(word) => word.clone(), })
},
)
.collect(); .collect();
words.reverse();
Self { Self { list: words }
list: history,
words,
}
} }
fn history_lines(&self) -> Column<'_, HistoryMessage> { fn history_lines(&self) -> Column<'_, HistoryMessage> {
let mut column = Column::new(); 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( column = column.push(
row![ row![
iced::widget::column![ iced::widget::column![
text!("{}", self.words[index].key) text!("{}", index.key).width(Fill).align_x(Center),
.width(Fill) text!("{}", index.value).width(Fill).align_x(Center),
.align_x(Center),
text!("{}", self.words[index].value)
.width(Fill)
.align_x(Center),
] ]
.align_x(Center), .align_x(Center),
iced::widget::column![ iced::widget::column![
text!("{} ➞ {}", item.before, item.after), text!("{} ➞ {}", activity.before, activity.after),
text!( text!(
"{}", "{}",
item.timestamp.with_timezone(&Local).format("%d.%m %H:%M") activity
.timestamp
.with_timezone(&Local)
.format("%d.%m %H:%M")
) )
] ]
.spacing(5) .spacing(5)
+110 -58
View File
@@ -1,36 +1,44 @@
use crate::AppState; use crate::app::AppState;
use crate::data::Id;
use crate::data::entities::WordGroup;
use crate::data::entities::{CustomField, WordData};
use crate::data::repositories::{BatchedRepository, Repository};
use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups}; 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::*;
use crate::dictionary::app_data_dir;
use crate::import::ImportMessage::*;
use crate::lang::{WordData, WordGroup};
use crate::navigation::{NavigatedPage, Page, RootMessage}; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::NotificationData::{CompressError, OpenFileError};
use crate::notification::{MaybeError, NotificationData};
use crate::pages::import::ImportMessage::*;
use crate::styling::*; use crate::styling::*;
use crate::{jl_button, push_error_task};
use crate::{jl_text_input, push_error};
use iced::widget::button::danger; use iced::widget::button::danger;
use iced::widget::container::success; use iced::widget::container::success;
use iced::widget::{ use iced::widget::{
Row, button, checkbox, column, container, progress_bar, row, rule, text, text_input, Row, button, checkbox, column, container, progress_bar, row, rule, text,
}; };
use iced::widget::{scrollable, space}; use iced::widget::{scrollable, space};
use iced::{Element, Task}; use iced::{Element, Task};
use iced_core::Alignment::Center; use iced_core::Alignment::Center;
use iced_core::Length::Fill; use iced_core::Length::Fill;
use iced_core::Padding; use iced_core::Padding;
use log::info;
use parking_lot::Mutex; use parking_lot::Mutex;
use rfd::AsyncFileDialog; use rfd::AsyncFileDialog;
use rusqlite::Connection; use rusqlite::Connection;
use std::collections::HashMap;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::BufReader; use std::io::BufReader;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::task::spawn_blocking; use tokio::task::spawn_blocking;
use zip::ZipArchive; use zip::ZipArchive;
use zstd::Decoder;
const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"]; const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"];
#[derive(Clone)]
pub struct ImportState { pub struct ImportState {
state: Arc<Mutex<AppState>>, state: AppState,
path: Option<PathBuf>, path: Option<PathBuf>,
import_data: Option<ImportData>, import_data: Option<ImportData>,
selected_index: usize, selected_index: usize,
@@ -149,7 +157,7 @@ impl NavigatedPage<ImportMessage> for ImportState {
scrollable( scrollable(
column![ column![
row![ row![
button("Выбрать файл").style(jl_button).on_press(SelectFile), jl_button!("Выбрать файл").on_press(SelectFile),
text!("{}", { text!("{}", {
if let Some(path) = &self.path { if let Some(path) = &self.path {
path.to_string_lossy().to_string() path.to_string_lossy().to_string()
@@ -261,7 +269,7 @@ impl ImportState {
self.available_properties(), self.available_properties(),
button("Добавить напрямую").on_press(SetupDirect), button("Добавить напрямую").on_press(SetupDirect),
row![ row![
text_input("Пользовательское поле", &self.custom_property_name) jl_text_input!("Пользовательское поле", &self.custom_property_name)
.on_input(EditCustomProperty), .on_input(EditCustomProperty),
button("Установить привязку").on_press(SetupCustomProperty) button("Установить привязку").on_press(SetupCustomProperty)
] ]
@@ -275,9 +283,6 @@ impl ImportState {
let mut row = Row::new(); let mut row = Row::new();
for default in DEFAULT_FIELDS { for default in DEFAULT_FIELDS {
// if group.mapping.values().any(|name| name == default) {
// continue;
// }
row = row.push( row = row.push(
button(text!("{default}")).on_press(SetupDefaultProperty(default.to_string())), button(text!("{default}")).on_press(SetupDefaultProperty(default.to_string())),
); );
@@ -292,7 +297,7 @@ impl ImportState {
.on_toggle(SwitchSkipEmpty), .on_toggle(SwitchSkipEmpty),
column![ column![
text!("Разделитель при множественном объединении"), text!("Разделитель при множественном объединении"),
text_input("", &self.separator).on_input(EditSeparator) jl_text_input!("", &self.separator).on_input(EditSeparator)
] ]
] ]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
@@ -317,14 +322,14 @@ impl ImportState {
} }
impl ImportState { impl ImportState {
pub fn new(state: Arc<Mutex<AppState>>) -> ImportState { pub fn new(state: AppState) -> ImportState {
ImportState { ImportState {
state, state,
path: None, path: None,
import_data: None, import_data: None,
selected_index: 0, selected_index: 0,
selected_property: None, selected_property: None,
custom_property_name: "".to_string(), custom_property_name: String::new(),
skip_empty: true, skip_empty: true,
separator: ", ".to_string(), separator: ", ".to_string(),
progress: None, progress: None,
@@ -358,48 +363,49 @@ impl ImportState {
Task::perform( Task::perform(
async { async {
spawn_blocking(move || { spawn_blocking(move || {
Self::extract_import_file(path)?; let schema = Self::extract_import_file(path)?;
let data = Self::read_import_file()?; let data = Self::read_import_file(schema)?;
Ok(data) Ok(data)
}) })
.await .await
.unwrap() .unwrap()
}, },
|result: Result<ImportData, ()>| { |result: Result<ImportData, NotificationData>| match result {
if let Ok(import) = result { Ok(import) => RootMessage::Import(UpdateImport(import)),
RootMessage::Import(UpdateImport(import)) Err(err) => push_error!(err),
} else {
RootMessage::None
}
}, },
) )
} }
fn extract_import_file(path: PathBuf) -> Result<(), ()> { fn extract_import_file(path: PathBuf) -> Result<AnkiSchema, NotificationData> {
let file = File::open(path).map_err(|_| ())?; let file = File::open(path).map_err(|_| OpenFileError)?;
let reader = BufReader::new(file); 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 temp_file_path = temp_import_path();
let mut temp_file = OpenOptions::new() let mut temp_file = OpenOptions::new()
.write(true) .write(true)
.create(true) .create(true)
.truncate(true) .truncate(true)
.open(&temp_file_path) .open(&temp_file_path)
.map_err(|_| ())?; .map_err(|_| OpenFileError)?;
if let Ok(mut file) = archive.by_name("collection.anki21") { if let Ok(mut file) = archive.by_name("collection.anki21b") {
std::io::copy(&mut file, &mut temp_file).map_err(|_| ())?; let mut decoder = Decoder::new(&mut file).map_err(|_| CompressError)?;
Ok(()) std::io::copy(&mut decoder, &mut temp_file).map_err(|_| OpenFileError)?;
Ok(AnkiSchema::Beta)
} else if let Ok(mut file) = archive.by_name("collection.anki21") {
std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?;
Ok(AnkiSchema::Old)
} else if let Ok(mut file) = archive.by_name("collection.anki2") { } 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(()) Ok(AnkiSchema::Old)
} else { } else {
Err(()) Err(OpenFileError)
} }
} }
fn read_import_file() -> Result<ImportData, ()> { fn read_import_file(schema: AnkiSchema) -> Result<ImportData, NotificationData> {
let temp_file_path = app_data_dir().join("import"); let temp_file_path = temp_import_path();
let connection = Connection::open(&temp_file_path).map_err(|_| ())?; let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?;
let data = load_groups(&connection); let data = load_groups(&connection, schema)?;
Ok(data) Ok(data)
} }
fn setup_mapping(&mut self, property_name: String) { fn setup_mapping(&mut self, property_name: String) {
@@ -429,27 +435,40 @@ impl ImportState {
let separator = self.separator.clone(); let separator = self.separator.clone();
let skip_empty = self.skip_empty; let skip_empty = self.skip_empty;
let group = self.import_data.as_ref().unwrap().0[self.selected_index].clone(); let group = self.import_data.as_ref().unwrap().0[self.selected_index].clone();
let map_indices = Self::get_mapping_indices(&group);
let progress = self.progress.clone().unwrap(); let progress = self.progress.clone().unwrap();
println!("{}", group.name); if let Err(err) = Self::ensure_fields(&state, &map_indices) {
return push_error_task!(err);
}
let fields: HashMap<String, Id> = HashMap::from_iter(
state
.custom_fields
.read()
.iter()
.map(|f| (f.identifier.clone(), f.id)),
);
info!("{}", group.name);
Task::perform( Task::perform(
async move { async move {
spawn_blocking(move || { spawn_blocking(move || {
let temp_file_path = app_data_dir().join("import"); let temp_file_path = temp_import_path();
let connection = Connection::open(&temp_file_path).map_err(|_| ())?; let connection =
let import_list = get_words_of_group(&connection, group.id); 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 mut words_list = Vec::with_capacity(import_list.len());
let map_indices = Self::get_mapping_indices(&group); let group_handle;
let mut group_entity = WordGroup { {
let group_entity = WordGroup {
id: 0.into(), id: 0.into(),
name: group.name.clone(), name: group.name.clone(),
}; };
{ let mut groups_repo = state.word_groups.write();
let state = state.lock(); group_handle = groups_repo.create_now(group_entity)?;
add_group(&mut group_entity, &state.connection);
} }
let group_id = group_entity.id; let group_id = group_handle.id;
for import in import_list { for import in import_list {
let mut word = WordData::new(); let mut word = WordData::new();
@@ -473,7 +492,7 @@ impl ImportState {
word.tags = collected_string; word.tags = collected_string;
} }
&_ => { &_ => {
word.additional.insert((*dest).clone(), collected_string); word.additional.insert(fields[dest], collected_string);
} }
} }
} }
@@ -481,26 +500,25 @@ impl ImportState {
words_list.push(word); words_list.push(word);
} }
let mut state = state.lock(); let mut words_repo = state.dictionary.write();
let connection = &mut state.connection;
let mut index = 0; let mut index = 0;
let total_len = words_list.len() as f32 / 1024.0; let total_len = words_list.len() as f32 / 1024.0;
for word in &mut words_list.chunks_mut(1024) { for words in &mut words_list.chunks_mut(1024) {
add_words(word, connection); words_repo.create_now_many(words)?;
index += 1; index += 1;
let mut progress = progress.lock(); let mut progress = progress.lock();
*progress = (index as f32 / total_len).min(1.0); *progress = (index as f32 / total_len).min(1.0);
} }
state.dictionary.append(&mut words_list);
state.word_groups.push(group_entity);
Ok(()) Ok(())
}) })
.await .await
.unwrap() .unwrap()
}, },
|_: Result<(), ()>| RootMessage::Import(ImportFinished), |r: Result<(), NotificationData>| match r {
Ok(_) => RootMessage::Import(ImportFinished),
Err(err) => push_error!(err),
},
) )
} }
@@ -554,4 +572,38 @@ impl ImportState {
|_| RootMessage::Import(NextProgress), |_| RootMessage::Import(NextProgress),
) )
} }
fn ensure_fields(
state: &AppState,
import_group: &Vec<(String, Vec<usize>)>,
) -> Result<(), NotificationData> {
let mut fields_repo = state.custom_fields.write();
let mut current_fields: Vec<String> = fields_repo
.iter()
.map(|field| field.identifier.clone())
.collect();
current_fields.append(&mut vec![
"key".to_string(),
"value".to_string(),
"tags".to_string(),
]);
for (field, _) in import_group {
if current_fields.contains(field) {
continue;
}
fields_repo.create_now(CustomField {
id: Default::default(),
name: field.clone(),
identifier: field.clone(),
})?;
}
Ok(())
}
}
pub enum AnkiSchema {
Old,
Beta,
} }
+8 -8
View File
@@ -1,14 +1,14 @@
use crate::lang::KanaSet; use crate::app::USER_FONT;
use crate::core::kana_set::KanaSet;
use crate::navigation::Page::PreviousPage; use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page}; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::quiz::QuizMessage::*; use crate::pages::quiz::QuizMessage::*;
use crate::styling::*; use crate::styling::*;
use crate::{RootMessage, USER_FONT}; use crate::{jl_button, jl_text_input};
use iced::widget::*; use iced::widget::*;
use iced::{Element, Fill, Task, alignment}; use iced::{Element, Fill, Task, alignment};
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
#[derive(Clone, Debug)]
pub struct QuizState { pub struct QuizState {
kana: String, kana: String,
current_roman: String, current_roman: String,
@@ -77,12 +77,12 @@ impl NavigatedPage<QuizMessage> for QuizState {
] ]
.align_y(alignment::Vertical::Center) .align_y(alignment::Vertical::Center)
.spacing(20), .spacing(20),
text_input("Романдзи", &self.current_roman) jl_text_input!("Романдзи", &self.current_roman)
.size(28) .size(28)
.width(150) .width(150)
.on_input(ContentChanged), .on_input(ContentChanged),
score_display(&self.score), score_display(&self.score),
button("Закончить").style(jl_button).on_press(Back), jl_button!("Закончить").on_press(Back),
] ]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
.align_x(alignment::Horizontal::Center), .align_x(alignment::Horizontal::Center),
@@ -113,7 +113,7 @@ impl QuizState {
kana: "ぁ".to_string(), kana: "ぁ".to_string(),
correct_roman: "a".to_string(), correct_roman: "a".to_string(),
is_help: false, is_help: false,
current_roman: "".to_string(), current_roman: String::new(),
set: KanaSet::hiragana(), set: KanaSet::hiragana(),
queue: Vec::new(), queue: Vec::new(),
score: Score { score: Score {
@@ -1,12 +1,12 @@
use crate::RootMessage; use crate::jl_button;
use crate::navigation::RootMessage;
use crate::navigation::{NavigatedPage, Page}; use crate::navigation::{NavigatedPage, Page};
use crate::randomizer::RandomizerMessage::{Back, Edit, Start}; use crate::pages::randomizer::RandomizerMessage::{Back, Edit, Start};
use crate::styling::{HALF_SPACING, back_overlay, jl_button}; use crate::styling::{HALF_SPACING, back_overlay};
use iced::Task; use iced::Task;
use iced::widget::{button, text_editor}; use iced::widget::text_editor;
use rand::prelude::SliceRandom; use rand::prelude::SliceRandom;
#[derive(Clone, Debug)]
pub struct RandomizerState { pub struct RandomizerState {
text: text_editor::Content, text: text_editor::Content,
list: Vec<String>, list: Vec<String>,
@@ -56,7 +56,7 @@ impl NavigatedPage<RandomizerMessage> for RandomizerState {
.width(400) .width(400)
.height(400) .height(400)
.placeholder("Каждый элемент с новой строки"), .placeholder("Каждый элемент с новой строки"),
button("Перемешать").style(jl_button).on_press(Start), jl_button!("Перемешать").on_press(Start),
] ]
.spacing(HALF_SPACING) .spacing(HALF_SPACING)
.into(), .into(),
+51 -131
View File
@@ -1,32 +1,38 @@
use crate::app::AppState;
use crate::core::deck::DeckData;
use crate::core::srs::WordOpenMode;
use crate::data::entities::{CardState, CustomField, WordData};
use crate::data::repositories::Repository;
use crate::data::Id;
use crate::data_provider::voice::get_voice; 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::Page::PreviousPage;
use crate::navigation::{KeyPressedPage, NavigatedPage, Page}; use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
use crate::notification::{MaybeError, NotificationData};
use crate::pages::repetition::RepetitionMessage::Play;
use crate::pages::repetitions::DeckRunData;
use crate::styling::*; use crate::styling::*;
use crate::{AppState, RootMessage}; use crate::{push_error, push_error_task};
use chrono::Local;
use iced::alignment::Horizontal::Center; use iced::alignment::Horizontal::Center;
use iced::keyboard::key::Physical::Code; use iced::keyboard::key::Physical::Code;
use iced::widget::text::Style; use iced::widget::text::Style;
use iced::widget::{button, column, container, row, rule, space, text, tooltip, Column}; use iced::widget::{button, column, container, row, text, tooltip};
use iced::{alignment, keyboard, Element, Fill, Task, Theme}; use iced::{keyboard, Element, Fill, Task, Theme};
use parking_lot::Mutex;
use rodio::MixerDeviceSink; use rodio::MixerDeviceSink;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use tokio::task::spawn_blocking; use tokio::task::spawn_blocking;
pub struct RepetitionState { pub struct RepetitionState {
state: Arc<Mutex<AppState>>, state: AppState,
pub settings: DeckSettings, pub settings: DeckRunData,
pub set: DeckData, pub set: DeckData,
current_word: WordData, current_word: WordData,
current_statistic: CardStatistics, current_statistic: CardState,
open: bool, open: bool,
can_play: bool, can_play: bool,
sink: Arc<Option<MixerDeviceSink>>, sink: Arc<Option<MixerDeviceSink>>,
opened: HashSet<Id>, opened: HashSet<Id>,
custom_fields_cache: Vec<CustomField>,
} }
impl NavigatedPage<RepetitionMessage> for RepetitionState { impl NavigatedPage<RepetitionMessage> for RepetitionState {
@@ -44,7 +50,7 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
RepetitionMessage::Back => {} RepetitionMessage::Back => {}
RepetitionMessage::Next => return self.next(), RepetitionMessage::Next => return self.next(),
RepetitionMessage::Answer(m) => return self.answer(m), RepetitionMessage::Answer(m) => return self.answer(m),
RepetitionMessage::Play => { Play => {
if !self.can_play { if !self.can_play {
return Task::none(); return Task::none();
} }
@@ -52,8 +58,9 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
self.can_play = false; self.can_play = false;
let value = self.current_word.key.clone(); let value = self.current_word.key.clone();
if self.settings.require_speech() { if self.settings.require_speech() {
return Task::perform(play_sound(self.sink.clone(), value), |_| { return Task::perform(play_sound(self.sink.clone(), value), |r| match r {
RootMessage::Repetition(RepetitionMessage::PlayFinished) Ok(_) => RootMessage::Repetition(RepetitionMessage::PlayFinished),
Err(err) => push_error!(err),
}); });
} }
} }
@@ -67,17 +74,7 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
fn view(&self) -> Element<'_, RepetitionMessage> { fn view(&self) -> Element<'_, RepetitionMessage> {
back_overlay( back_overlay(
column![ column![
container(self.draw_forward()) crate::widgets::card_display::word_display(&self.settings.general_settings, &self.current_word, self.open, Play, self.custom_fields_cache.as_slice()),
.width(Fill)
.height(Fill)
.align_x(Center)
.align_y(alignment::Vertical::Center),
rule::horizontal(2),
container(self.draw_backward())
.width(Fill)
.height(Fill)
.align_x(Center)
.align_y(alignment::Vertical::Center),
container(self.answer_bar()) container(self.answer_bar())
.width(Fill) .width(Fill)
.align_x(Center) .align_x(Center)
@@ -86,7 +83,7 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
"Затронуто слов {}, {}%", "Затронуто слов {}, {}%",
self.opened.len(), self.opened.len(),
(self.opened.len() as f32 / self.set.len() as f32 * 10000.0).round() / 100.0 (self.opened.len() as f32 / self.set.len() as f32 * 10000.0).round() / 100.0
) ),
] ]
.height(Fill) .height(Fill)
.width(Fill) .width(Fill)
@@ -97,17 +94,23 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
} }
impl RepetitionState { impl RepetitionState {
pub(crate) fn new(set: DeckSettings, state: Arc<Mutex<AppState>>) -> RepetitionState { pub(crate) fn new(
let mut card_set = DeckData::new(&set, state.clone()); set: DeckRunData,
state: AppState,
) -> Result<RepetitionState, NotificationData> {
let mut card_set = DeckData::new(&set, set.open_mode, state.clone());
let (word, stat) = card_set.next(); let (word, stat) = card_set.next();
let word = state.dictionary.read().get_static(word).unwrap();
let stat = state.card_states.read().get_static(stat).unwrap();
let sink_handle = if set.forward.contains("speech") || set.backward.contains("speech") { let sink_handle = if set.forward.contains("speech") || set.backward.contains("speech") {
rodio::DeviceSinkBuilder::open_default_sink().ok() rodio::DeviceSinkBuilder::open_default_sink().ok()
} else { } else {
None None
}; };
RepetitionState { let custom_fields_cache = state.custom_fields.read().iter().cloned().collect();
Ok(RepetitionState {
state, state,
settings: set, settings: set,
set: card_set, set: card_set,
@@ -117,7 +120,8 @@ impl RepetitionState {
can_play: true, can_play: true,
sink: Arc::new(sink_handle), sink: Arc::new(sink_handle),
opened: HashSet::new(), opened: HashSet::new(),
} custom_fields_cache,
})
} }
} }
@@ -136,80 +140,29 @@ impl RepetitionState {
return Task::none(); return Task::none();
} }
self.set.open(mode); if let Err(err) = self.set.open(mode) {
return push_error_task!(err);
}
self.open = false; self.open = false;
self.opened.insert(self.current_word.id); self.opened.insert(self.current_word.id);
let next = self.set.next(); let (word, state) = self.set.next();
self.current_word = next.0; let word = self.state.dictionary.read().get_static(word).unwrap();
self.current_statistic = next.1; let stat = self.state.card_states.read().get_static(state).unwrap();
self.current_word = word;
self.current_statistic = stat;
if self.settings.require_speech() && self.sink.is_some() { if self.settings.require_speech() && self.sink.is_some() {
let sink = self.sink.clone(); let sink = self.sink.clone();
let task = play_sound(sink, self.current_word.key.clone()); let task = play_sound(sink, self.current_word.key.clone());
return Task::perform(task, |_| { return Task::perform(task, |r| match r {
RootMessage::Repetition(RepetitionMessage::PlayFinished) Ok(_) => RootMessage::Repetition(RepetitionMessage::PlayFinished),
Err(err) => push_error!(err),
}); });
} }
self.increment_counter();
Task::none() Task::none()
} }
fn increment_counter(&mut self) {
let mut state = self.state.lock();
let id = self.settings.id;
let activity = state.activity.get_mut(&id);
let now = Local::now().date_naive();
if let Some(activity) = activity {
let (last_day, counter) = activity.last_mut().unwrap();
if *last_day == now {
*counter += 1;
} else {
activity.push((now, 1));
}
} else {
state.activity.insert(id, vec![(now, 1)]);
}
}
fn draw_forward(&self) -> Element<'_, RepetitionMessage> {
self.draw_card_view(self.settings.forward.as_str())
}
fn draw_backward(&self) -> Element<'_, RepetitionMessage> {
if !self.open {
return container(text!("?").size(72))
.width(Fill)
.height(Fill)
.align_y(alignment::Vertical::Center)
.align_x(Center)
.into();
}
self.draw_card_view(self.settings.backward.as_str())
}
fn draw_card_view(&self, properties: &str) -> Element<'_, RepetitionMessage> {
let word = &self.current_word;
let mut col = Column::new();
for view_type in properties.split(" ") {
col = col.push(match view_type {
"key" => self.draw_key(word),
"value" => self.draw_value(word),
"speech" => self.draw_voice(),
"reading" => self.draw_reading(word),
"context" => self.draw_context(word),
"description" => self.draw_description(word),
_ => space().into(),
})
}
col.spacing(DEFAULT_SPACING).align_x(Center).into()
}
fn answer_bar(&self) -> Element<'_, RepetitionMessage> { fn answer_bar(&self) -> Element<'_, RepetitionMessage> {
if !self.open { if !self.open {
return text!("Нажмите Пробел для открытия карточки") return text!("Нажмите Пробел для открытия карточки")
@@ -251,39 +204,6 @@ impl RepetitionState {
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
.into() .into()
} }
fn draw_key(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
text!("{}", word.key).size(36).into()
}
fn draw_value(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
text!("{}", word.value).size(24).into()
}
fn draw_voice(&self) -> Element<'_, RepetitionMessage> {
button("Воспроизвести")
.style(jl_button)
.on_press(RepetitionMessage::Play)
.into()
}
fn draw_reading(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
match word.additional.get("reading") {
None => space().into(),
Some(reading) => text!("{}", reading).size(24).into(),
}
}
fn draw_context(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
match word.additional.get("context") {
None => space().into(),
Some(context) => text!("{}", context).size(24).into(),
}
}
fn draw_description(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
match word.additional.get("description") {
None => space().into(),
Some(context) => text!("{}", context).size(24).into(),
}
}
} }
impl KeyPressedPage for RepetitionState { impl KeyPressedPage for RepetitionState {
@@ -322,8 +242,8 @@ pub enum RepetitionMessage {
PlayFinished, PlayFinished,
} }
async fn play_sound(sink: Arc<Option<MixerDeviceSink>>, text: String) { pub async fn play_sound(sink: Arc<Option<MixerDeviceSink>>, text: String) -> MaybeError {
if let Ok(data) = get_voice(text.as_str()).await { let data = get_voice(text.as_str()).await?;
spawn_blocking(move || { spawn_blocking(move || {
if let Some(sink) = sink.as_ref() if let Some(sink) = sink.as_ref()
&& let Ok(play) = rodio::play(sink.mixer(), data) && let Ok(play) = rodio::play(sink.mixer(), data)
@@ -332,6 +252,6 @@ async fn play_sound(sink: Arc<Option<MixerDeviceSink>>, text: String) {
} }
}) })
.await .await
.unwrap(); .expect("Playing sound failed");
} Ok(())
} }
+258
View File
@@ -0,0 +1,258 @@
use crate::app::AppState;
use crate::core::deck::DeckData;
use crate::data::entities::{DeckSettings, EntityHandle};
use crate::data::repositories::{BatchedRepository, Repository};
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::MaybeError;
use crate::pages::repetition_settings::RepetitionSettingsMessage::*;
use crate::styling::*;
use crate::{jl_button, jl_text_input};
use crate::{push_error, push_error_task};
use iced::widget::button::danger;
use iced::widget::space::horizontal;
use iced::widget::text_input::Catalog;
use iced::widget::{button, column, row, scrollable, space, text};
use iced::{Element, Task};
use iced_core::Length::Fill;
use iced_core::{color, Padding, Theme};
use std::sync::Arc;
use std::time::Duration;
pub struct RepetitionSettingsState {
settings_handle: EntityHandle<DeckSettings>,
settings_data: DeckSettings,
state: AppState,
real_delete: bool,
real_delete_states: bool,
correct_filter: bool,
filter_changed: bool,
filtered_words: Option<u32>,
}
impl RepetitionSettingsState {
pub(crate) fn new(
settings: EntityHandle<DeckSettings>,
state: AppState,
) -> RepetitionSettingsState {
let data = state.decks.read().get_static(settings).unwrap();
RepetitionSettingsState {
settings_data: data,
settings_handle: settings,
state,
real_delete: false,
real_delete_states: false,
correct_filter: true,
filter_changed: false,
filtered_words: None,
}
}
}
impl NavigatedPage<RepetitionSettingsMessage> for RepetitionSettingsState {
fn navigate(&mut self, message: &RepetitionSettingsMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: RepetitionSettingsMessage) -> Task<RootMessage> {
match message {
Back => {}
Save => {
let mut decks = self.state.decks.write();
decks.set(&mut self.settings_handle, self.settings_data.clone());
if let Err(e) = decks.save(&mut self.settings_handle) {
return push_error_task!(e);
}
drop(decks);
if self.filter_changed
&& let Err(e) = update_deck_stats(&self.settings_data, &self.state)
{
return push_error_task!(e);
}
}
SetName(new) => {
self.settings_data.name = new;
}
SetForward(new) => {
self.settings_data.forward = new;
}
SetBackward(backward) => {
self.settings_data.backward = backward;
}
SetFilter(new) => {
self.settings_data.filter = new;
self.correct_filter = self.settings_data.check_filter();
self.filter_changed = true;
}
TryFilter => {
let list = DeckData::list(&self.settings_data, &self.state);
self.filtered_words = Some(list.len() as u32);
}
DeleteSet => {
if !self.real_delete {
self.real_delete = true;
return Task::future(async {
tokio::time::sleep(Duration::from_millis(3000)).await;
RootMessage::RepetitionSettings(RevertDeleteSet)
});
}
let mut decks = self.state.decks.write();
if let Err(e) = decks.delete(self.settings_handle) {
return push_error_task!(e);
}
return Task::done(RootMessage::RepetitionSettings(Back));
}
RevertDeleteSet => {
self.real_delete = false;
self.real_delete_states = true;
}
DeleteStates => {
if !self.real_delete_states {
self.real_delete_states = true;
return Task::future(async {
tokio::time::sleep(Duration::from_millis(3000)).await;
RootMessage::RepetitionSettings(RevertDeleteSet)
});
}
let mut states = self.state.card_states.write();
let deck_id = self.settings_handle.id;
let mut query = states
.query(Arc::new(move |state| state.deck_id == deck_id))
.handles()
.collect::<Vec<_>>();
if let Err(e) = states.delete_many(query.as_mut_slice()) {
return push_error_task!(e);
}
self.real_delete_states = false;
}
}
Task::none()
}
fn view(&self) -> Element<'_, RepetitionSettingsMessage> {
back_overlay(
iced::widget::column![
scrollable(
column![
column![
text!("Название набора"),
jl_text_input!("", &self.settings_data.name).on_input(SetName),
]
.spacing(QUARTER_SPACING),
column![
text!("Передняя сторона"),
jl_text_input!("", &self.settings_data.forward).on_input(SetForward),
]
.spacing(QUARTER_SPACING),
column![
text!("Задняя сторона"),
jl_text_input!("", &self.settings_data.backward).on_input(SetBackward),
]
.spacing(QUARTER_SPACING),
column![
text!("Фильтр"),
jl_text_input!("", &self.settings_data.filter)
.style(|x, status| {
let mut primary = Theme::default()(x, status);
if !self.correct_filter {
primary.border.color = color!(255, 255, 0);
}
primary
})
.on_input(SetFilter),
jl_button!("Проверить фильтр").on_press(TryFilter),
self.count_view(),
]
.spacing(QUARTER_SPACING),
]
.spacing(DEFAULT_SPACING)
.padding(Padding {
top: 0.0,
right: DEFAULT_SPACING,
bottom: 0.0,
left: 0.0,
})
)
.height(Fill),
row![
jl_button!("Сохранить").on_press(Save),
horizontal(),
button(if self.real_delete_states {
"Вы уверены?"
} else {
"Удалить прогресс?"
})
.style(danger)
.on_press(DeleteStates),
button(if self.real_delete {
"Вы уверены?"
} else {
"Удалить"
})
.style(danger)
.on_press(DeleteSet),
]
.spacing(DEFAULT_SPACING),
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
fn update_deck_stats(deck: &DeckSettings, state: &AppState) -> MaybeError {
let states_repo = &mut state.card_states.write();
let deck_id = deck.id;
let mut db_stats = states_repo
.query_par(Arc::new(move |s| s.deck_id == deck_id))
.iter(states_repo)
.collect::<Vec<_>>();
let filtered_words = DeckData::list(deck, state)
.iter()
.map(|w| w.id)
.collect::<Vec<_>>();
filtered_words.iter().for_each(|word_id| {
if let Some(index) = db_stats.iter().position(|stat_id| stat_id.word_id == *word_id) {
db_stats.remove(index);
}
});
let mut delete_operation = db_stats
.iter()
.map(|state| EntityHandle::new(state.id, state.id.0 as usize, true))
.collect::<Vec<_>>();
states_repo.delete_many(delete_operation.as_mut_slice())?;
Ok(())
}
impl RepetitionSettingsState {
fn count_view(&self) -> Element<'_, RepetitionSettingsMessage> {
if let Some(count) = self.filtered_words {
return text!("Количество слов: {}", count).into();
}
space().into()
}
}
#[derive(Clone)]
pub enum RepetitionSettingsMessage {
Back,
Save,
SetName(String),
SetForward(String),
SetBackward(String),
SetFilter(String),
TryFilter,
DeleteSet,
DeleteStates,
RevertDeleteSet,
}
+200 -207
View File
@@ -1,37 +1,40 @@
use crate::data_provider::card_sets::update_deck; use crate::app::AppState;
use crate::data_provider::card_stats::{add_stat_list, load_stats_of_deck}; use crate::core::deck::DeckData;
use crate::history::HistoryState; use crate::core::srs::{AppendMode, OrderMode};
use crate::lang::{AppendMode, CardStatistics, DeckSettings, Id, OrderMode}; use crate::data::entities::{CardState, DeckSettings, EntityHandle, WordData};
use crate::navigation::Page::{History, PreviousPage, Repetition, RepetitionSettings}; use crate::data::repositories::{BatchedRepository, Repository};
use crate::navigation::{NavigatedPage, Page}; use crate::navigation::Page::{Exams, History, PreviousPage, Repetition, RepetitionSettings};
use crate::repetition::RepetitionState; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::repetition_settings::RepetitionSettingsState; use crate::notification::MaybeError;
use crate::repetitions::RepetitionsMessage::*; use crate::pages::exams::ExamsState;
use crate::pages::history::HistoryState;
use crate::pages::repetition::RepetitionState;
use crate::pages::repetition_settings::RepetitionSettingsState;
use crate::pages::repetitions::RepetitionsMessage::*;
use crate::push_error;
use crate::push_error_task;
use crate::styling::*; use crate::styling::*;
use crate::{AppState, RootMessage}; use crate::{jl_button, jl_text_input};
use chrono::{Days, Local, Utc}; use chrono::{Days, Local, Utc};
use hashbrown::{HashMap, HashSet}; use hashbrown::HashSet;
use iced::widget::button::{danger, Status};
pub use iced::widget::button::{Catalog, Style}; pub use iced::widget::button::{Catalog, Style};
use iced::widget::button::{Status, danger};
use iced::widget::tooltip::Position::Top; use iced::widget::tooltip::Position::Top;
use iced::widget::{ use iced::widget::{
Column, Row, button, column, container, lazy, radio, row, scrollable, space, svg, text, button, column, container, lazy, radio, row, scrollable, space, svg, text, tooltip, Column, Row,
text_input, tooltip,
}; };
use iced::{Background, Border, Center, Color, Element, Fill, Length, Shadow, Task, Theme}; use iced::{Background, Border, Center, Color, Element, Fill, Length, Shadow, Task, Theme};
use iced_core::Padding;
use iced_core::border::Radius; use iced_core::border::Radius;
use iced_core::svg::Handle; use iced_core::svg::Handle;
use parking_lot::Mutex; use iced_core::Padding;
use std::collections::BTreeMap;
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
#[derive(Clone)]
pub struct RepetitionsState { pub struct RepetitionsState {
word_id_index_map: HashMap<Id, usize>, selected_deck_handle: Option<EntityHandle<DeckSettings>>,
selected_deck_index: Option<usize>, decks: BTreeMap<EntityHandle<DeckSettings>, DeckRunData>,
decks: Vec<DeckViewData>, state: AppState,
state: Arc<Mutex<AppState>>,
} }
impl NavigatedPage<RepetitionsMessage> for RepetitionsState { impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
@@ -44,60 +47,56 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
self.state.clone(), self.state.clone(),
))) )))
} else if let GoToRepetition = message { } else if let GoToRepetition = message {
let clone = self.state.clone(); let mut state_clone = self.state.clone();
let selected_set = self.selected_deck_mut().unwrap(); let selected_set = self.selected_deck_mut().unwrap();
if selected_set.append_mode == AppendMode::Full { if selected_set.append_mode == AppendMode::Full {
Self::append_all_words(&mut clone.lock(), selected_set); if let Err(_err) = Self::append_all_words(&mut state_clone, selected_set) {
return None;
}
selected_set.existing_words_indices = selected_set.available_words_indices.clone(); selected_set.existing_words_indices = selected_set.available_words_indices.clone();
} else { } else {
if selected_set if selected_set.available_words_indices.is_empty()
.available_words_indices || selected_set.existing_words_indices.is_empty()
.as_ref()
.unwrap()
.is_empty()
|| selected_set
.existing_words_indices
.as_ref()
.unwrap()
.is_empty()
{ {
return None; return None;
} }
} }
Some(Repetition(RepetitionState::new( if let Ok(repetition_state) =
selected_set.general_settings.clone(), RepetitionState::new(selected_set.clone(), self.state.clone())
clone, {
))) return Some(Repetition(repetition_state));
}
None
} else if let GoToSettings = message { } else if let GoToSettings = message {
Some(RepetitionSettings(RepetitionSettingsState::new( Some(RepetitionSettings(RepetitionSettingsState::new(
self.selected_deck().unwrap().index.unwrap(), self.selected_deck_handle.unwrap(),
self.state.clone(), self.state.clone(),
))) )))
} else { } else if let GoToExams = message {
None Some(Exams(ExamsState::new(self.selected_deck_handle.unwrap(), self.state.clone())))
} } else { None }
} }
fn navigated(&mut self) { fn navigated(&mut self) {
let index = self.selected_deck_index.unwrap(); if let Some(index) = self.selected_deck_handle {
let state = self.state.lock(); let decks = self.state.decks.read();
if let Some(global_deck) = state.decks.get(index) { if let Some(deck) = decks.get_static(index) {
if global_deck.id != self.selected_deck().unwrap().id { {
drop(state); let stored = self.decks.get_mut(&index).unwrap();
self.decks.remove(index); stored.general_settings = deck;
self.clear_selection(); }
drop(decks);
self.fill_deck_word_indices();
} else {
self.decks.remove(&index);
drop(decks);
self.clear_selection();
}
} else { } else {
self.decks[self.selected_deck_index.unwrap()].general_settings =
state.decks[index].clone();
drop(state);
self.fill_deck_word_indices(); self.fill_deck_word_indices();
} }
} else { _ = self.state.history.write().reload()
drop(state);
self.decks.remove(index);
self.clear_selection()
}
} }
fn update(&mut self, message: RepetitionsMessage) -> Task<RootMessage> { fn update(&mut self, message: RepetitionsMessage) -> Task<RootMessage> {
match message { match message {
@@ -109,30 +108,32 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
CreateSet => { CreateSet => {
let index = self.decks.len() + 1; let index = self.decks.len() + 1;
self.decks.push(DeckViewData { let general = DeckSettings::with_name(format!("Колода #{}", index));
general_settings: DeckSettings::with_name(format!("Колода #{}", index)), let handle = self.state.decks.write().create(general.clone());
existing_words_indices: None, self.decks.insert(
available_words_indices: None, handle,
DeckRunData {
settings_handle: handle,
general_settings: general,
existing_words_indices: Default::default(),
available_words_indices: Default::default(),
indices_ready: false,
append_mode: Default::default(), append_mode: Default::default(),
index: None, count: None,
}); open_mode: OrderMode::Default,
},
);
} }
DeleteSet => { DeleteSet => {
let index = self.selected_deck_index.unwrap(); let index = self.selected_deck_handle.unwrap();
self.clear_selection(); self.clear_selection();
let deck = self.decks.remove(index); self.decks.remove(&index);
if deck.id.is_valid() {
let mut state = self.state.lock(); if index.real() {
state.decks.remove(index); let mut repo = self.state.decks.write();
self.decks = self if let Err(err) = repo.delete(index) {
.decks return push_error_task!(err);
.clone() }
.into_iter()
.map(|mut deck| {
deck.index = state.decks.iter().position(|d| d.id == deck.id);
deck
})
.collect();
} }
} }
SetName(value) => { SetName(value) => {
@@ -142,23 +143,24 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
SelectSet(index) => { SelectSet(index) => {
self.select_deck(index); self.select_deck(index);
let deck = self.selected_deck().unwrap(); let deck = self.selected_deck().unwrap();
if deck.existing_words_indices.is_none() { if !deck.indices_ready {
self.fill_deck_word_indices(); self.fill_deck_word_indices()
} }
} }
Save => { Save => {
let mut deck = self.selected_deck().unwrap().clone(); let deck = self.selected_deck().unwrap().clone();
let mut state = self.state.lock(); let handle = self.selected_deck_handle.as_mut().unwrap();
{ let mut repo = self.state.decks.write();
update_deck(&mut deck.general_settings, &state.connection);
if let Some(index) = deck.index { repo.set(handle, deck.general_settings.clone());
state.decks[index] = deck.general_settings.clone(); let old = *handle;
} else { if let Err(err) = repo.save(handle) {
state.decks.push(deck.general_settings.clone()); return push_error_task!(err);
deck.index = Some(state.decks.len() - 1);
self.decks[self.selected_deck_index.unwrap()] = deck.clone();
}
} }
let mut deck = self.decks.remove(&old).unwrap();
deck.settings_handle = *handle;
self.decks.insert(*handle, deck);
} }
SetForward(value) => { SetForward(value) => {
let deck = self.selected_deck_mut().unwrap(); let deck = self.selected_deck_mut().unwrap();
@@ -173,35 +175,31 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
deck.general_settings.filter = value; deck.general_settings.filter = value;
} }
TryFilter => { TryFilter => {
let state = self.state.lock();
let deck = self.selected_deck().unwrap(); let deck = self.selected_deck().unwrap();
let count = deck.get_word_list(&state).len(); let count = DeckData::list(deck, &self.state).len();
drop(state);
let deck = self.selected_deck_mut().unwrap(); let deck = self.selected_deck_mut().unwrap();
deck.general_settings.count = Some(count); deck.count = Some(count);
} }
SetOpenMode(mode) => { SetOpenMode(mode) => {
let deck = self.selected_deck_mut().unwrap(); let deck = self.selected_deck_mut().unwrap();
deck.general_settings.open_mode = mode; deck.open_mode = mode;
} }
SetAppendMode(mode) => { SetAppendMode(mode) => {
let deck = self.selected_deck_mut().unwrap(); let deck = self.selected_deck_mut().unwrap();
deck.append_mode = mode; deck.append_mode = mode;
} }
AppendWords(count) => { AppendWords(count) => {
let adding: Vec<usize>; let adding: Vec<EntityHandle<WordData>>;
{ {
{ {
let deck = self.selected_deck().unwrap(); let deck = self.selected_deck().unwrap();
let existing_words_indices = deck.existing_words_indices.as_ref().unwrap(); let existing_words_indices = &deck.existing_words_indices;
let mut created_set = HashSet::with_capacity(existing_words_indices.len()); let mut created_set = HashSet::with_capacity(existing_words_indices.len());
existing_words_indices.iter().for_each(|c| { existing_words_indices.iter().for_each(|c| {
created_set.insert(c); created_set.insert(c);
}); });
adding = deck adding = deck
.available_words_indices .available_words_indices
.as_ref()
.unwrap()
.iter() .iter()
.filter(|c| !created_set.contains(c)) .filter(|c| !created_set.contains(c))
.take(count) .take(count)
@@ -210,17 +208,19 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
} }
let deck = self.selected_deck_mut().unwrap(); let deck = self.selected_deck_mut().unwrap();
deck.existing_words_indices deck.existing_words_indices.append(&mut adding.clone());
.as_mut()
.unwrap()
.append(&mut adding.clone());
} }
let deck = self.selected_deck().unwrap(); let deck = self.selected_deck().unwrap().clone();
let mut state = self.state.lock(); let state = &mut self.state;
Self::append_words(&mut state, &deck.general_settings, adding.into_iter()); if let Err(err) =
Self::append_words(state, &deck.general_settings, adding.into_iter())
{
return push_error_task!(err);
} }
} }
GoToExams => {}
}
Task::none() Task::none()
} }
@@ -229,10 +229,7 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
row![ row![
column![ column![
scrollable(self.decks_list()).height(Fill), scrollable(self.decks_list()).height(Fill),
button("Добавить") jl_button!("Добавить").width(Fill).on_press(CreateSet),
.style(jl_button)
.width(Fill)
.on_press(CreateSet),
] ]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
.width(Length::FillPortion(1)), .width(Length::FillPortion(1)),
@@ -249,50 +246,44 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
} }
impl RepetitionsState { impl RepetitionsState {
pub(crate) fn new(state: Arc<Mutex<AppState>>) -> RepetitionsState { pub(crate) fn new(state: AppState) -> RepetitionsState {
let state_ = state.lock(); let decks = Self::get_decks_from_state(&state);
let mut map = HashMap::with_capacity(state_.dictionary.len());
state_
.dictionary
.iter()
.enumerate()
.for_each(|(index, word)| {
map.insert(word.id, index);
});
let decks = Self::get_decks_from_state(&state_);
drop(state_);
RepetitionsState { RepetitionsState {
word_id_index_map: map, selected_deck_handle: None,
selected_deck_index: None,
decks, decks,
state, state,
} }
} }
fn get_decks_from_state(state: &AppState) -> Vec<DeckViewData> { fn get_decks_from_state(state: &AppState) -> BTreeMap<EntityHandle<DeckSettings>, DeckRunData> {
state state
.decks .decks
.read()
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, deck)| DeckViewData { .map(|(index, deck)| {
(
EntityHandle::new(deck.id, index, true),
DeckRunData {
settings_handle: EntityHandle::new(deck.id, index, true),
general_settings: deck.clone(), general_settings: deck.clone(),
existing_words_indices: None, existing_words_indices: Default::default(),
available_words_indices: None, available_words_indices: Default::default(),
indices_ready: false,
append_mode: Default::default(), append_mode: Default::default(),
index: Some(index), count: None,
open_mode: OrderMode::Default,
},
)
}) })
.collect() .collect::<BTreeMap<_, _>>()
} }
} }
impl RepetitionsState { impl RepetitionsState {
fn launch_delete_button(&self, deck: &DeckViewData) -> Element<'_, RepetitionsMessage> { fn launch_delete_button(&self, deck: &DeckRunData) -> Element<'_, RepetitionsMessage> {
if deck.id.is_valid() { if deck.id.is_valid() {
if deck.append_mode == AppendMode::Manual if deck.append_mode == AppendMode::Manual && deck.existing_words_indices.is_empty() {
&& deck.existing_words_indices.as_ref().unwrap().is_empty()
{
return text!("Добавьте карточки или измените фильтр").into(); return text!("Добавьте карточки или измените фильтр").into();
} }
return row![ return row![
@@ -313,7 +304,7 @@ impl RepetitionsState {
column![ column![
text!("Название набора"), text!("Название набора"),
row![ row![
text_input("", &deck.name).on_input(SetName), jl_text_input!("", &deck.name).on_input(SetName),
button(svg(Handle::from_memory(SETTINGS_ICON))) button(svg(Handle::from_memory(SETTINGS_ICON)))
.width(43) .width(43)
.on_press(GoToSettings), .on_press(GoToSettings),
@@ -322,24 +313,22 @@ impl RepetitionsState {
] ]
.spacing(QUARTER_SPACING), .spacing(QUARTER_SPACING),
{ {
if !deck.id.is_valid() { if !deck.settings_handle.real() {
column![ column![
column![ column![
text!("Передняя сторона"), text!("Передняя сторона"),
text_input("", &deck.forward).on_input(SetForward), jl_text_input!("", &deck.forward).on_input(SetForward),
] ]
.spacing(QUARTER_SPACING), .spacing(QUARTER_SPACING),
column![ column![
text!("Задняя сторона"), text!("Задняя сторона"),
text_input("", &deck.backward).on_input(SetBackward), jl_text_input!("", &deck.backward).on_input(SetBackward),
] ]
.spacing(QUARTER_SPACING), .spacing(QUARTER_SPACING),
column![ column![
text!("Фильтр"), text!("Фильтр"),
text_input("", &deck.filter).on_input(SetFilter), jl_text_input!("", &deck.filter).on_input(SetFilter),
button("Проверить фильтр") jl_button!("Проверить фильтр").on_press(TryFilter),
.style(jl_button)
.on_press(TryFilter),
self.count_view(deck), self.count_view(deck),
] ]
.spacing(QUARTER_SPACING), .spacing(QUARTER_SPACING),
@@ -367,7 +356,10 @@ impl RepetitionsState {
Some(deck.open_mode), Some(deck.open_mode),
SetOpenMode SetOpenMode
), ),
button("История").style(jl_button).on_press(GoToHistory), row![
jl_button!("История").on_press(GoToHistory),
jl_button!("Экзамены").on_press(GoToExams),
].spacing(DEFAULT_SPACING),
] ]
.spacing(HALF_SPACING) .spacing(HALF_SPACING)
} }
@@ -384,7 +376,7 @@ impl RepetitionsState {
) )
.height(Fill), .height(Fill),
row![ row![
button("Сохранить").style(jl_button).on_press(Save), jl_button!("Сохранить").on_press(Save),
self.launch_delete_button(deck) self.launch_delete_button(deck)
] ]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
@@ -405,22 +397,20 @@ impl RepetitionsState {
fn activity_bar(&self, deck: &DeckSettings) -> Element<'_, RepetitionsMessage> { fn activity_bar(&self, deck: &DeckSettings) -> Element<'_, RepetitionsMessage> {
const MAX_DAY_COUNT: f32 = 128.0; const MAX_DAY_COUNT: f32 = 128.0;
let state = self.state.lock(); let history = self.state.history.read();
let history = state.activity.get(&deck.id); let history = history.activity_of_deck(deck.id);
let mut counts: Vec<u32> = vec![0; 30 * 7]; let mut counts: Vec<u32> = vec![0; 30 * 7];
let now = Local::now().date_naive(); let now = Local::now().date_naive();
if let Some(history) = history {
for (date, activity) in history { for (date, activity) in history {
let distance = now - *date; let distance = now - date;
let index = counts.len() as i64 - distance.num_days() - 1; let index = counts.len() as i64 - distance.num_days() - 1;
if index < 0 { if index < 0 {
continue; continue;
} }
counts[index as usize] = *activity; counts[index as usize] = activity;
}
} }
lazy((counts, now), |(counts, now)| { lazy((counts, now), |(counts, now)| {
@@ -443,7 +433,7 @@ impl RepetitionsState {
let date = (*now) let date = (*now)
.checked_sub_days(Days::new(30 * 7 - i * 7 - j - 1)) .checked_sub_days(Days::new(30 * 7 - i * 7 - j - 1))
.unwrap(); .unwrap_or_default();
column = column.push(tooltip( column = column.push(tooltip(
iced::widget::container(space().height(15).width(15)).style( iced::widget::container(space().height(15).width(15)).style(
@@ -476,7 +466,7 @@ impl RepetitionsState {
}) })
.into() .into()
} }
fn word_append_panel(&self, deck: &DeckViewData) -> Element<'_, RepetitionsMessage> { fn word_append_panel(&self, deck: &DeckRunData) -> Element<'_, RepetitionsMessage> {
column![ column![
text!("Режим добавления карточек"), text!("Режим добавления карточек"),
row![ row![
@@ -499,7 +489,7 @@ impl RepetitionsState {
.spacing(HALF_SPACING) .spacing(HALF_SPACING)
.into() .into()
} }
fn adder_panel(&self, deck: &DeckViewData) -> Element<'_, RepetitionsMessage> { fn adder_panel(&self, deck: &DeckRunData) -> Element<'_, RepetitionsMessage> {
match deck.append_mode { match deck.append_mode {
AppendMode::Full => space().into(), AppendMode::Full => space().into(),
AppendMode::Manual => row![ AppendMode::Manual => row![
@@ -515,15 +505,11 @@ impl RepetitionsState {
fn count_comparator_view(&self) -> Element<'_, RepetitionsMessage> { fn count_comparator_view(&self) -> Element<'_, RepetitionsMessage> {
let selected_deck = self.selected_deck().unwrap(); let selected_deck = self.selected_deck().unwrap();
let now = selected_deck.existing_words_indices.as_ref().unwrap().len(); let now = selected_deck.existing_words_indices.len();
let available = selected_deck let available = selected_deck.available_words_indices.len();
.available_words_indices
.as_ref()
.unwrap()
.len();
text!("{} слова добавлено из {}", now, available).into() text!("{} слова добавлено из {}", now, available).into()
} }
fn count_view(&self, deck: &DeckSettings) -> Element<'_, RepetitionsMessage> { fn count_view(&self, deck: &DeckRunData) -> Element<'_, RepetitionsMessage> {
if let Some(count) = deck.count { if let Some(count) = deck.count {
return text!("Количество слов: {}", count).into(); return text!("Количество слов: {}", count).into();
} }
@@ -532,10 +518,10 @@ impl RepetitionsState {
fn decks_list(&self) -> Column<'_, RepetitionsMessage> { fn decks_list(&self) -> Column<'_, RepetitionsMessage> {
let mut column = Column::new(); let mut column = Column::new();
for (i, deck) in self.decks.iter().enumerate() { for (handle, deck) in self.decks.iter() {
column = column.push( column = column.push(
button(text!("{}", &deck.name)) button(text!("{}", &deck.name))
.on_press_with(move || SelectSet(i)) .on_press_with(move || SelectSet(*handle))
.style(move |_x: &Theme, status: Status| Style { .style(move |_x: &Theme, status: Status| Style {
background: if status == Status::Hovered { background: if status == Status::Hovered {
Some(Background::Color(Color::WHITE.scale_alpha(0.2))) Some(Background::Color(Color::WHITE.scale_alpha(0.2)))
@@ -559,73 +545,76 @@ impl RepetitionsState {
} }
impl RepetitionsState { impl RepetitionsState {
fn append_all_words(state: &mut AppState, deck: &DeckViewData) { fn append_all_words(state: &mut AppState, deck: &DeckRunData) -> MaybeError {
let existing = deck.existing_words_indices.as_ref().unwrap(); let existing = &deck.existing_words_indices;
let mut created_set = HashSet::with_capacity(existing.len()); let mut created_set = HashSet::with_capacity(existing.len());
existing.iter().for_each(|c| { existing.iter().for_each(|c| {
created_set.insert(c); created_set.insert(c);
}); });
let required = deck let required = deck
.available_words_indices .available_words_indices
.as_ref()
.unwrap()
.iter() .iter()
.filter(|i| !created_set.contains(i)) .filter(|i| !created_set.contains(i))
.cloned(); .cloned();
Self::append_words(state, deck, required); Self::append_words(state, deck, required)?;
Ok(())
} }
fn append_words( fn append_words(
state: &mut AppState, state: &mut AppState,
deck: &DeckSettings, deck: &DeckSettings,
indices: impl Iterator<Item = usize>, indices: impl Iterator<Item = EntityHandle<WordData>>,
) { ) -> MaybeError {
let words = &state.dictionary;
let stats = &mut indices let stats = &mut indices
.map(|i| { .map(|i| CardState {
let word = &words[i];
CardStatistics {
id: 0.into(), id: 0.into(),
word_id: word.id, word_id: i.id,
last_open: Utc::now(), last_open: Utc::now(),
score: 1, score: 1,
set_id: deck.id, fsrs_opened: Utc::now().date_naive(),
} fsrs_memory: None,
deck_id: deck.id,
fsrs_open_day: Utc::now().date_naive(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !stats.is_empty() { if !stats.is_empty() {
add_stat_list(stats, &mut state.connection); let mut repo = state.card_states.write();
repo.create_now_many(stats.as_mut_slice())?;
}
Ok(())
}
fn select_deck(&mut self, index: EntityHandle<DeckSettings>) {
if self.decks.contains_key(&index) {
self.selected_deck_handle = Some(index);
} }
} }
fn select_deck(&mut self, index: usize) { fn selected_deck(&self) -> Option<&DeckRunData> {
if self.decks.get(index).is_some() { self.decks.get(&self.selected_deck_handle?)
self.selected_deck_index = Some(index);
} }
} fn selected_deck_mut(&mut self) -> Option<&mut DeckRunData> {
self.decks.get_mut(&self.selected_deck_handle?)
fn selected_deck(&self) -> Option<&DeckViewData> {
self.decks.get(self.selected_deck_index?)
}
fn selected_deck_mut(&mut self) -> Option<&mut DeckViewData> {
self.decks.get_mut(self.selected_deck_index?)
} }
fn clear_selection(&mut self) { fn clear_selection(&mut self) {
self.selected_deck_index = None; self.selected_deck_handle = None;
} }
fn fill_deck_word_indices(&mut self) { fn fill_deck_word_indices(&mut self) {
let state = &self.state.lock(); let card_states = self.state.card_states.read();
let deck_id = self.selected_deck().unwrap().id;
let added = card_states
.query_par(Arc::new(move |state| state.deck_id == deck_id))
.iter(&card_states)
.map(|state| EntityHandle::from_id(state.word_id))
.collect::<Vec<_>>();
let mut deck = self.selected_deck().unwrap().clone(); let mut deck = self.selected_deck().unwrap().clone();
let added: Vec<_> = load_stats_of_deck(&deck, &state.connection) let total = DeckData::list(&deck, &self.state);
.iter() deck.existing_words_indices = added;
.map(|c| self.word_id_index_map[&c.word_id]) deck.available_words_indices = total;
.collect(); deck.indices_ready = true;
let total = deck.get_word_list(state); self.decks.insert(self.selected_deck_handle.unwrap(), deck);
deck.existing_words_indices = Some(added);
deck.available_words_indices = Some(total);
self.decks[self.selected_deck_index.unwrap()] = deck;
} }
} }
#[derive(Clone)] #[derive(Clone)]
@@ -636,7 +625,7 @@ pub enum RepetitionsMessage {
CreateSet, CreateSet,
DeleteSet, DeleteSet,
SetName(String), SetName(String),
SelectSet(usize), SelectSet(EntityHandle<DeckSettings>),
Save, Save,
SetForward(String), SetForward(String),
SetBackward(String), SetBackward(String),
@@ -645,20 +634,24 @@ pub enum RepetitionsMessage {
SetOpenMode(OrderMode), SetOpenMode(OrderMode),
SetAppendMode(AppendMode), SetAppendMode(AppendMode),
GoToHistory, GoToHistory,
GoToExams,
GoToSettings, GoToSettings,
AppendWords(usize), AppendWords(usize),
} }
#[derive(Clone)] #[derive(Clone)]
struct DeckViewData { pub struct DeckRunData {
general_settings: DeckSettings, pub settings_handle: EntityHandle<DeckSettings>,
existing_words_indices: Option<Vec<usize>>, pub general_settings: DeckSettings,
available_words_indices: Option<Vec<usize>>, existing_words_indices: Vec<EntityHandle<WordData>>,
append_mode: AppendMode, available_words_indices: Vec<EntityHandle<WordData>>,
index: Option<usize>, indices_ready: bool,
pub append_mode: AppendMode,
count: Option<usize>,
pub open_mode: OrderMode,
} }
impl Deref for DeckViewData { impl Deref for DeckRunData {
type Target = DeckSettings; type Target = DeckSettings;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
+14 -16
View File
@@ -1,27 +1,27 @@
use crate::dictionary::DictionaryState; use crate::app::AppState;
use crate::lang::{KanaSet, KanaType}; use crate::core::kana_set::{KanaSet, KanaType};
use crate::jl_button;
use crate::navigation::Page::*; use crate::navigation::Page::*;
use crate::navigation::{NavigatedPage, Page}; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::randomizer::RandomizerState; use crate::pages::dictionary::DictionaryState;
use crate::repetitions::RepetitionsState; use crate::pages::quiz::QuizState;
use crate::selector::SelectorMessage::ChangeMode; use crate::pages::randomizer::RandomizerState;
use crate::pages::repetitions::RepetitionsState;
use crate::pages::selector::SelectorMessage::ChangeMode;
use crate::pages::sync::SyncState;
use crate::pages::writing::WritingState;
use crate::styling::*; 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::button::{Status, Style};
use iced::widget::*; use iced::widget::*;
use iced::{Element, Task, alignment}; use iced::{Element, Task, alignment};
use iced_core::Alignment::Center; use iced_core::Alignment::Center;
use iced_core::Background; use iced_core::Background;
use iced_core::Color; use iced_core::Color;
use parking_lot::Mutex;
use std::sync::Arc;
pub struct SelectorState { pub struct SelectorState {
pub set: KanaSet, pub set: KanaSet,
is_writing: bool, is_writing: bool,
state: Arc<Mutex<AppState>>, state: AppState,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -80,9 +80,7 @@ impl NavigatedPage<SelectorMessage> for SelectorState {
container( container(
iced::widget::column![ iced::widget::column![
row![ row![
button("あ ↔ ア") jl_button!("あ ↔ ア").on_press(SelectorMessage::Change),
.on_press(SelectorMessage::Change)
.style(jl_button),
button("Словарь") button("Словарь")
.on_press(SelectorMessage::ToDictionary) .on_press(SelectorMessage::ToDictionary)
.style(Self::nav_style), .style(Self::nav_style),
@@ -113,7 +111,7 @@ impl NavigatedPage<SelectorMessage> for SelectorState {
} }
impl SelectorState { impl SelectorState {
pub fn new(state: Arc<Mutex<AppState>>) -> Self { pub fn new(state: AppState) -> Self {
Self { Self {
set: Default::default(), set: Default::default(),
is_writing: false, is_writing: false,
+114 -56
View File
@@ -1,17 +1,21 @@
use crate::app::AppState;
use crate::data::repositories::Repository;
use crate::data_provider::settings::{delete_settings, set_setting}; use crate::data_provider::settings::{delete_settings, set_setting};
use crate::data_provider::web_api::*; use crate::data_provider::web_api::*;
use crate::jl_button;
use crate::navigation::Page::PreviousPage; use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page}; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::{NotificationData, NotificationRequest};
use crate::pages::sync::SyncMessage::*;
use crate::styling::*; use crate::styling::*;
use crate::sync::SyncMessage::*; use crate::{push_error, push_error_task};
use crate::{fill_state, AppState, RootMessage};
use iced::widget::button::danger; use iced::widget::button::danger;
use iced::widget::container::rounded_box; use iced::widget::container::rounded_box;
use iced::widget::{button, column, container, progress_bar, row, space, text, toggler}; use iced::widget::{button, column, container, progress_bar, row, space, text, toggler};
use iced::{Center, Element, Fill, Font, Length, Task}; use iced::{Center, Element, Fill, Font, Length, Task};
use parking_lot::Mutex; use log::error;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::notification::NotificationData::LocalVersionChanged;
#[derive(Clone)] #[derive(Clone)]
pub enum SyncMessage { pub enum SyncMessage {
@@ -28,12 +32,13 @@ pub enum SyncMessage {
Disable, Disable,
DisableSync, DisableSync,
SwitchAutoSync, SwitchAutoSync,
ResetRealLoad,
} }
#[derive(Clone)]
pub struct SyncState { pub struct SyncState {
state: Arc<Mutex<AppState>>, state: AppState,
auto_fetch: bool, auto_fetch: bool,
frozen: bool, frozen: bool,
real_load: bool,
progress: f32, progress: f32,
} }
@@ -53,17 +58,14 @@ impl NavigatedPage<SyncMessage> for SyncState {
match message { match message {
Back => {} Back => {}
CopyKey => { CopyKey => {
let state = self.state.lock(); let key = self.state.sync_data.read().key.clone().unwrap();
let key = state.sync_data.key.clone().unwrap();
return iced::clipboard::write(key) return iced::clipboard::write(key)
.map(|_val: String| RootMessage::Sync(KeyCopied)); .map(|_val: String| RootMessage::Sync(KeyCopied));
} }
InitSync => { InitSync => {
return Task::perform(first_sync(), |id| { return Task::perform(first_sync(), |id| match id {
match id {
Ok(id) => RootMessage::Sync(IdReceived(id)), Ok(id) => RootMessage::Sync(IdReceived(id)),
Err(_) => RootMessage::None Err(err) => push_error!(err),
}
}); });
} }
GetKey => { GetKey => {
@@ -75,14 +77,20 @@ impl NavigatedPage<SyncMessage> for SyncState {
if !validate_id(&new_id) { if !validate_id(&new_id) {
return Task::none(); return Task::none();
} }
let mut state = self.state.lock(); let mut settings = self.state.sync_data.write();
state.sync_data.key = Some(new_id.clone()); settings.key = Some(new_id.clone());
set_setting("SYNC_KEY".to_string(), new_id, &state.connection); if let Err(err) = set_setting(
"SYNC_KEY".to_string(),
new_id,
&self.state.connection.lock(),
) {
return push_error_task!(err);
}
} }
Send => { Send => {
self.prepare_to_db_interaction(); self.prepare_to_db_interaction();
let id = self.state.lock().sync_data.key.clone().unwrap(); let id = self.state.sync_data.read().key.clone().unwrap();
let tasks = Task::batch([ let tasks = Task::batch([
Task::perform( Task::perform(
async { tokio::time::sleep(Duration::from_millis(200)).await }, async { tokio::time::sleep(Duration::from_millis(200)).await },
@@ -94,13 +102,29 @@ impl NavigatedPage<SyncMessage> for SyncState {
return tasks; return tasks;
} }
GetLast => { GetLast => {
let id = self.state.lock().sync_data.key.clone().unwrap(); if !self.real_load {
self.real_load = true;
return Task::batch([
Task::future(async {
tokio::time::sleep(Duration::from_millis(3000)).await;
RootMessage::Sync(ResetRealLoad)
}),
Task::done(RootMessage::PushNotification(NotificationRequest::Warning(LocalVersionChanged)))
]);
}
self.real_load = false;
let id = self.state.sync_data.read().key.clone().unwrap();
self.state.connection.lock().finish();
let tasks = Task::batch([ let tasks = Task::batch([
Task::perform( Task::perform(
async { tokio::time::sleep(Duration::from_millis(200)).await }, async { tokio::time::sleep(Duration::from_millis(200)).await },
|_| RootMessage::Sync(NextAnimation), |_| RootMessage::Sync(NextAnimation),
), ),
Task::perform(load_data(id, false), |_| RootMessage::Sync(NetworkFinished)), Task::perform(load_data(id, false), |load_result| match load_result {
Ok(_) => RootMessage::Sync(NetworkFinished),
Err(error) => push_error!(error)
}),
]); ]);
return tasks; return tasks;
@@ -119,32 +143,50 @@ impl NavigatedPage<SyncMessage> for SyncState {
} }
NetworkFinished => { NetworkFinished => {
self.frozen = false; self.frozen = false;
let mut updated_state = AppState::new(); {
fill_state(&mut updated_state); let mut connection = self.state.connection.lock();
connection.restart();
}
let mut state = self.state.lock(); if let Err(err) = self.reload_data() {
state.sync_data = updated_state.sync_data; return push_error_task!(err);
state.connection = updated_state.connection; }
state.decks = updated_state.decks;
state.dictionary = updated_state.dictionary; return Task::done(RootMessage::PushNotification(NotificationRequest::Info(
state.word_groups = updated_state.word_groups; NotificationData::DataSynced,
)));
} }
Disable => {} Disable => {}
DisableSync => { DisableSync => {
let mut state = self.state.lock(); let mut sync_data = self.state.sync_data.write();
state.sync_data.key = None; let connection = self.state.connection.lock();
delete_settings("SYNC_KEY".to_string(), &state.connection); sync_data.key = None;
delete_settings("AUTO_WEB_FETCH".to_string(), &state.connection); let save_result = || {
delete_settings("SYNC_KEY".to_string(), &connection)?;
delete_settings("AUTO_WEB_FETCH".to_string(), &connection)?;
Ok::<(), NotificationData>(())
};
if let Err(error) = save_result() {
return push_error_task!(error);
}
} }
SwitchAutoSync => { SwitchAutoSync => {
self.auto_fetch = !self.auto_fetch; self.auto_fetch = !self.auto_fetch;
let mut state = self.state.lock(); let mut sync_data = self.state.sync_data.write();
state.sync_data.auto_web_fetch = self.auto_fetch; let connection = self.state.connection.lock();
set_setting(
sync_data.auto_web_fetch = self.auto_fetch;
if let Err(err) = set_setting(
"AUTO_WEB_FETCH".to_string(), "AUTO_WEB_FETCH".to_string(),
self.auto_fetch.to_string(), self.auto_fetch.to_string(),
&state.connection, &connection,
); ) {
return push_error_task!(err);
}
}
ResetRealLoad => {
self.real_load = false;
} }
} }
Task::none() Task::none()
@@ -162,19 +204,29 @@ impl NavigatedPage<SyncMessage> for SyncState {
} }
impl SyncState { impl SyncState {
pub fn new(state: Arc<Mutex<AppState>>) -> SyncState { pub fn new(state: AppState) -> SyncState {
let auto_fetch; let auto_fetch = state.sync_data.read().auto_web_fetch;
{
auto_fetch = state.lock().sync_data.auto_web_fetch;
}
Self { Self {
auto_fetch, auto_fetch,
state, state,
frozen: false, frozen: false,
real_load: false,
progress: 0.0, progress: 0.0,
} }
} }
fn reload_data(&mut self) -> Result<(), NotificationData> {
self.state.card_states.write().reload()?;
self.state.word_groups.write().reload()?;
self.state.decks.write().reload()?;
self.state.dictionary.write().reload()?;
self.state.history.write().reload()?;
self.state.exam.write().reload()?;
self.state.exam_entries.write().reload()?;
Ok(())
}
fn sync_column(&self) -> Element<'_, SyncMessage> { fn sync_column(&self) -> Element<'_, SyncMessage> {
let network_view: Element<'_, SyncMessage> = if self.frozen { let network_view: Element<'_, SyncMessage> = if self.frozen {
progress_bar(0.0..=5.0, self.progress).into() progress_bar(0.0..=5.0, self.progress).into()
@@ -182,7 +234,7 @@ impl SyncState {
space().into() space().into()
}; };
{ {
if let Some(key) = self.state.lock().sync_data.key.clone() { if let Some(key) = self.state.sync_data.read().key.as_ref() {
column![ column![
text!("Ваш ключ синхронизации"), text!("Ваш ключ синхронизации"),
container( container(
@@ -194,14 +246,13 @@ impl SyncState {
.padding(3) .padding(3)
) )
.style(rounded_box), .style(rounded_box),
button("Скопировать в буфер обмена") jl_button!("Скопировать в буфер обмена")
.style(jl_button)
.on_press(CopyKey) .on_press(CopyKey)
.width(Fill), .width(Fill),
row![ row![
button("↑ Отправить").style(jl_button).on_press(Send), jl_button!("↑ Отправить").on_press(Send),
space().width(Fill), space().width(Fill),
button("↓ Скачать").style(jl_button).on_press(GetLast) jl_button!("↓ Скачать").on_press(GetLast)
] ]
.spacing(DEFAULT_SPACING), .spacing(DEFAULT_SPACING),
toggler(self.auto_fetch) toggler(self.auto_fetch)
@@ -214,12 +265,8 @@ impl SyncState {
] ]
} else { } else {
column![ column![
button("Создать сохранение") jl_button!("Создать сохранение").on_press(InitSync),
.style(jl_button) jl_button!("Вставить ключ из буфера").on_press(GetKey),
.on_press(InitSync),
button("Вставить ключ из буфера")
.style(jl_button)
.on_press(GetKey),
] ]
} }
} }
@@ -239,13 +286,24 @@ impl SyncState {
} }
self.frozen = true; self.frozen = true;
self.progress = 0.0; self.progress = 0.0;
let state = self.state.lock(); let mut connection = self.state.connection.lock();
state
.connection connection
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
eprintln!("Ошибка при checkpoint WAL: {e}"); error!("Ошибка при checkpoint WAL: {e}");
}); });
connection.execute("VACUUM;", []).unwrap_or_else(|e| {
error!("Ошибка при checkpoint WAL: {e}");
0
});
connection
.execute("PRAGMA optimize;", [])
.unwrap_or_else(|e| {
error!("Ошибка при checkpoint WAL: {e}");
0
});
connection.finish();
} }
} }
+169
View File
@@ -0,0 +1,169 @@
use crate::app::AppState;
use crate::data::Id;
use crate::data::entities::{CustomField, EntityHandle, WordData};
use crate::data::repositories::Repository;
use crate::jl_text_input;
use crate::pages::word::WordMessage::*;
use crate::styling::*;
use iced::widget::button::danger;
use iced::widget::space::horizontal;
use iced::widget::{button, column, row, rule, scrollable, text};
use iced::{Element, Fill};
use iced_core::Padding;
pub struct WordEditor<Message : Clone> {
state: AppState,
pub word_handle: EntityHandle<WordData>,
on_delete: Option<Message>,
on_edit: Option<Message>,
fields: Vec<CustomField>,
}
impl<Message: std::clone::Clone> WordEditor<Message> {
pub(crate) fn update(&mut self, message: WordMessage) -> Option<Message> {
let dictionary = &mut self.state.dictionary.write();
let word = dictionary.get_mut(&mut self.word_handle).unwrap();
match message {
Delete => {
return self.on_delete.clone();
}
SetTags(n) => word.tags = n,
SetKey(n) => {
word.key = n;
}
SetValue(n) => {
word.value = n;
}
SetAdditional(key, value) => {
word.additional.insert(key, value.replace("", ""));
}
AddAdditional(key) => {
word.additional.insert(key, String::new());
}
RemoveAdditional(key) => {
word.additional.remove(&key);
}
}
self.on_edit.clone()
}
pub(crate) fn view(&self) -> Element<'_, WordMessage> {
let mut fast_add = row![];
let word = self
.state
.dictionary
.read()
.get_static(self.word_handle)
.unwrap();
for field in &self.fields {
if !word.additional.contains_key(&field.id) {
fast_add = fast_add.push(
button(field.name.as_str())
.style(button::text)
.on_press(AddAdditional(field.id)),
);
}
}
let mut col = iced::widget::column![
column![
text!("Ключ"),
jl_text_input!("key", &word.key).on_input(SetKey),
]
.spacing(QUARTER_SPACING),
column![
text!("Значение"),
jl_text_input!("value", &word.value).on_input(SetValue),
]
.spacing(QUARTER_SPACING),
column![
text!("Теги"),
jl_text_input!("tags", &word.tags).on_input(SetTags),
]
.spacing(QUARTER_SPACING),
rule::horizontal(2),
scrollable(fast_add.padding(Padding {
top: 0.0,
right: 0.0,
bottom: 4.0,
left: 0.0,
}))
.horizontal(),
];
for more in &word.additional {
col = col.push(self.get_view_for_more(more));
}
column![
col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill),
row![
horizontal().width(Fill),
button("Удалить").style(danger).on_press(Delete),
]
.spacing(DEFAULT_SPACING)
]
.spacing(DEFAULT_SPACING)
.into()
}
}
impl<Message: std::clone::Clone> WordEditor<Message> {
pub fn new(handle: EntityHandle<WordData>, state: AppState) -> Self {
let fields = state
.custom_fields
.read()
.iter()
.cloned()
.collect::<Vec<_>>();
Self {
state,
word_handle: handle,
on_delete: None,
on_edit: None,
fields,
}
}
pub fn on_delete(mut self, message: Message) -> Self {
self.on_delete = Some(message);
self
}
pub fn on_edit(mut self, message: Message) -> Self {
self.on_edit = Some(message);
self
}
}
impl<Message: std::clone::Clone> WordEditor<Message> {
fn get_view_for_more(&self, value: (&Id, &String)) -> Element<'_, WordMessage> {
let field = self.fields.iter().find(|f| f.id == *value.0).unwrap();
self.additional_field(value.1, field.name.as_str(), *value.0)
}
fn additional_field(&self, value: &str, name: &str, id: Id) -> Element<'_, WordMessage> {
column![
text!("{}", name),
row![
jl_text_input!(name, value)
.on_input(move |string| SetAdditional(id, string))
.width(Fill),
button("-").on_press(RemoveAdditional(id)).style(danger),
]
.spacing(QUARTER_SPACING),
]
.spacing(QUARTER_SPACING)
.into()
}
}
#[derive(Debug, Clone)]
pub enum WordMessage {
Delete,
SetTags(String),
SetKey(String),
SetValue(String),
AddAdditional(Id),
RemoveAdditional(Id),
SetAdditional(Id, String),
}
+11 -16
View File
@@ -1,13 +1,12 @@
use crate::RootMessage; use crate::core::kana_set::KanaSet;
use crate::lang::KanaSet; use crate::jl_button;
use crate::navigation::Page::PreviousPage; use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page}; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*; use crate::styling::*;
use iced::widget::*; use iced::widget::*;
use iced::{Element, Fill, Task, alignment}; use iced::{Element, Fill, Task, alignment};
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
#[derive(Clone, Debug)]
pub struct WritingState { pub struct WritingState {
kana: String, kana: String,
set: Vec<(String, String)>, set: Vec<(String, String)>,
@@ -46,12 +45,8 @@ impl NavigatedPage<WritingMessage> for WritingState {
text!("{}", self.kana).size(48), text!("{}", self.kana).size(48),
self.answers(), self.answers(),
row![ row![
button(text!("{}", self.next_text)) jl_button!(text!("{}", self.next_text)).on_press(WritingMessage::Next),
.style(jl_button) jl_button!("Закончить").on_press(WritingMessage::Back),
.on_press(WritingMessage::Next),
button("Закончить")
.style(jl_button)
.on_press(WritingMessage::Back),
] ]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
] ]
@@ -70,11 +65,11 @@ impl WritingState {
let mut list = set.list(); let mut list = set.list();
list.shuffle(&mut rand::rng()); list.shuffle(&mut rand::rng());
WritingState { WritingState {
kana: "".to_string(), kana: String::new(),
set: list.clone(), set: list.clone(),
original_set: list, original_set: list,
kana_total: "".to_string(), kana_total: String::new(),
roman_total: "".to_string(), roman_total: String::new(),
next_text: "Дальше".to_string(), next_text: "Дальше".to_string(),
show_all: false, show_all: false,
} }
@@ -84,9 +79,9 @@ impl WritingState {
impl WritingState { impl WritingState {
fn next(&mut self) { fn next(&mut self) {
if self.set.is_empty() { if self.set.is_empty() {
self.kana = "".to_string(); self.kana = String::new();
self.roman_total = "".to_string(); self.roman_total = String::new();
self.kana_total = "".to_string(); self.kana_total = String::new();
self.set = self.original_set.clone(); self.set = self.original_set.clone();
self.set.shuffle(&mut rand::rng()); self.set.shuffle(&mut rand::rng());
-206
View File
@@ -1,206 +0,0 @@
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;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::repetition_settings::RepetitionSettingsMessage::*;
use crate::styling::*;
use iced::widget::button::danger;
use iced::widget::text_input::Catalog;
use iced::widget::{button, column, row, scrollable, space, text, text_input};
use iced::{Element, Task};
use iced_core::Length::Fill;
use iced_core::{Padding, Theme, color};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
pub struct RepetitionSettingsState {
set: DeckSettings,
index: usize,
state: Arc<Mutex<AppState>>,
real_delete: bool,
correct_filter: bool,
filter_changed: bool,
}
impl RepetitionSettingsState {
pub(crate) fn new(index: usize, state: Arc<Mutex<AppState>>) -> RepetitionSettingsState {
let set;
{
let local_state = state.lock();
set = local_state.decks[index].clone();
}
let filter_state = set.check_filter();
RepetitionSettingsState {
index,
set,
state,
real_delete: false,
correct_filter: filter_state,
filter_changed: false,
}
}
}
impl NavigatedPage<RepetitionSettingsMessage> for RepetitionSettingsState {
fn navigate(&mut self, message: &RepetitionSettingsMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: RepetitionSettingsMessage) -> Task<RootMessage> {
match message {
Back => {}
Save => {
let mut state = self.state.lock();
update_deck(&mut self.set, &state.connection);
if self.filter_changed {
update_deck_stats(&self.set, &state);
}
state.decks[self.index] = self.set.clone();
}
SetName(new) => {
self.set.name = new;
}
SetForward(new) => {
self.set.forward = new;
}
SetBackward(backward) => {
self.set.backward = backward;
}
SetFilter(new) => {
self.set.filter = new;
self.correct_filter = self.set.check_filter();
self.filter_changed = true;
}
TryFilter => {
let state = self.state.lock();
let count = self.set.get_word_list(&state).len();
self.set.count = Some(count);
}
DeleteSet => {
if !self.real_delete {
self.real_delete = true;
return Task::future(async {
tokio::time::sleep(Duration::from_millis(3000)).await;
RootMessage::RepetitionSettings(RevertDeleteSet)
});
}
let mut state = self.state.lock();
delete_set(&self.set, &state.connection);
state.decks.remove(self.index);
return Task::done(RootMessage::RepetitionSettings(Back));
}
RevertDeleteSet => self.real_delete = false,
}
Task::none()
}
fn view(&self) -> Element<'_, RepetitionSettingsMessage> {
back_overlay(
iced::widget::column![
scrollable(
column![
column![
text!("Название набора"),
text_input("", &self.set.name).on_input(SetName),
]
.spacing(QUARTER_SPACING),
column![
text!("Передняя сторона"),
text_input("", &self.set.forward).on_input(SetForward),
]
.spacing(QUARTER_SPACING),
column![
text!("Задняя сторона"),
text_input("", &self.set.backward).on_input(SetBackward),
]
.spacing(QUARTER_SPACING),
column![
text!("Фильтр"),
text_input("", &self.set.filter)
.style(|x, status| {
let mut primary = Theme::default()(x, status);
if !self.correct_filter {
primary.border.color = color!(255, 255, 0);
}
primary
})
.on_input(SetFilter),
button("Проверить фильтр")
.style(jl_button)
.on_press(TryFilter),
self.count_view(&self.set),
]
.spacing(QUARTER_SPACING),
]
.spacing(DEFAULT_SPACING)
.padding(Padding {
top: 0.0,
right: DEFAULT_SPACING,
bottom: 0.0,
left: 0.0,
})
)
.height(Fill),
row![
button("Сохранить").style(jl_button).on_press(Save),
button(if self.real_delete {
"Вы уверены?"
} else {
"Удалить"
})
.style(danger)
.on_press(DeleteSet),
space().width(Fill),
]
.spacing(DEFAULT_SPACING),
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
fn update_deck_stats(deck: &DeckSettings, state: &AppState) {
let mut db_stats = load_stats_of_deck(deck, &state.connection);
let filtered_words = deck.get_word_list(state);
filtered_words.iter().for_each(|word| {
let word_id = state.dictionary[*word].id;
if let Some(index) = db_stats.iter().position(|stat| stat.word_id == word_id) {
db_stats.remove(index);
}
});
delete_stats(db_stats.as_slice(), &state.connection);
}
impl RepetitionSettingsState {
fn count_view(&self, set: &DeckSettings) -> Element<'_, RepetitionSettingsMessage> {
if let Some(count) = set.count {
return text!("Количество слов: {}", count).into();
}
space().into()
}
}
#[derive(Clone)]
pub enum RepetitionSettingsMessage {
Back,
Save,
SetName(String),
SetForward(String),
SetBackward(String),
SetFilter(String),
TryFilter,
DeleteSet,
RevertDeleteSet,
}
+26 -7
View File
@@ -1,8 +1,7 @@
use crate::repetitions::Style;
use iced::Element; use iced::Element;
use iced::border::Radius; use iced::border::Radius;
use iced::widget::button::*; use iced::widget::button::*;
use iced::widget::{button, container}; use iced::widget::container;
use iced_core::Length::Fill; use iced_core::Length::Fill;
use iced_core::alignment::Horizontal::Left; use iced_core::alignment::Horizontal::Left;
use iced_core::{Border, Theme}; use iced_core::{Border, Theme};
@@ -15,7 +14,7 @@ pub const BUTTON_RADIUS: f32 = 8.0;
pub const SETTINGS_ICON: &[u8] = include_bytes!("../settings_icon.svg"); pub const SETTINGS_ICON: &[u8] = include_bytes!("../settings_icon.svg");
pub fn jl_button(theme: &Theme, status: Status) -> Style { pub fn jl_button_style(theme: &Theme, status: Status) -> Style {
let mut style = primary(theme, status); let mut style = primary(theme, status);
style.border = Border { style.border = Border {
color: Default::default(), color: Default::default(),
@@ -24,6 +23,15 @@ pub fn jl_button(theme: &Theme, status: Status) -> Style {
}; };
style style
} }
pub fn jl_text_input_style(
theme: &Theme,
status: iced::widget::text_input::Status,
) -> iced::widget::text_input::Style {
let mut style = iced::widget::text_input::default(theme, status);
style.border.radius = Radius::new(BUTTON_RADIUS);
style
}
pub fn cta_button(theme: &Theme, status: Status) -> Style { pub fn cta_button(theme: &Theme, status: Status) -> Style {
let mut style = secondary(theme, status); let mut style = secondary(theme, status);
style.border.width = 1.0; style.border.width = 1.0;
@@ -31,12 +39,23 @@ pub fn cta_button(theme: &Theme, status: Status) -> Style {
style style
} }
#[macro_export]
macro_rules! jl_button {
($content:expr) => {
iced::widget::button($content).style($crate::styling::jl_button_style)
};
}
#[macro_export]
macro_rules! jl_text_input {
($placeholder:expr, $content:expr) => {
iced::widget::text_input($placeholder, $content).style($crate::styling::jl_text_input_style)
};
}
pub fn back_overlay<'a, T: Clone + 'a>(content: Element<'a, T>, back_message: T) -> Element<'a, T> { pub fn back_overlay<'a, T: Clone + 'a>(content: Element<'a, T>, back_message: T) -> Element<'a, T> {
container( container(
iced::widget::column![ iced::widget::column![jl_button!("Назад").on_press(back_message), content]
button("Назад").style(jl_button).on_press(back_message),
content
]
.spacing(DEFAULT_SPACING) .spacing(DEFAULT_SPACING)
.align_x(Left) .align_x(Left)
.width(Fill), .width(Fill),
+123
View File
@@ -0,0 +1,123 @@
#[cfg(test)]
mod mod_tests {
use crate::data::Id;
use crate::data::entities::{EntityData, EntityHandle};
use crate::data::repositories::fast_position_of;
struct ProtoEntity {
id: Id,
_data: i128,
}
impl EntityData for ProtoEntity {
fn id(&self) -> Id {
self.id
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
}
impl ProtoEntity {
fn new(n: i128) -> Self {
Self {
id: (n as u32).into(),
_data: n,
}
}
}
#[test]
fn search_direct() {
let mut entity = EntityHandle::new(8.into(), 3, true);
assert_eq!(
fast_position_of(&[1, 2, 3, 8, 9, 12].map(ProtoEntity::new), &mut entity),
Some(3)
);
}
#[test]
fn search_0() {
let mut entity = EntityHandle::new(8.into(), 4, true);
assert_eq!(
fast_position_of(&[1, 2, 3, 8, 9, 12].map(ProtoEntity::new), &mut entity),
Some(3)
);
}
#[test]
fn search_1() {
let mut entity = EntityHandle::new(9.into(), 5, true);
assert_eq!(
fast_position_of(&[1, 2, 3, 8, 9, 12].map(ProtoEntity::new), &mut entity),
Some(4)
);
}
#[test]
fn search_2() {
let mut entity = EntityHandle::new(106.into(), 25, true);
assert_eq!(
fast_position_of(
&[
1, 2, 3, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 20, 21, 22, 23, 105, 106, 107,
108, 109, 110
]
.map(ProtoEntity::new),
&mut entity
),
Some(18)
);
}
#[test]
fn id_0() {
let entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, true);
assert!(entity.real())
}
#[test]
fn id_1() {
let entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, false);
assert!(!entity.real())
}
#[test]
fn id_3() {
let entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, true);
assert_eq!(entity.index(), 25)
}
#[test]
fn id_4() {
let entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, false);
assert_eq!(entity.index(), 25)
}
#[test]
fn id_5() {
let mut entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, true);
entity.set_real(true);
assert!(entity.real())
}
#[test]
fn id_6() {
let mut entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, false);
entity.set_real(true);
assert!(entity.real())
}
#[test]
fn id_7() {
let mut entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, true);
entity.set_index(0);
assert_eq!(entity.index(), 0)
}
#[test]
fn id_8() {
let mut entity: EntityHandle<ProtoEntity> = EntityHandle::new(8.into(), 25, false);
entity.set_index(0);
assert_eq!(entity.index(), 0)
}
}
+107
View File
@@ -0,0 +1,107 @@
use crate::data::entities::{CustomField, DeckSettings, WordData};
use crate::data::Id;
use crate::styling::HALF_SPACING;
use iced::widget::{button, center, column, rule, text};
use iced_core::Alignment::Center;
use iced_core::Length::Fill;
pub fn word_display<'a, T: 'a + Clone>(
deck: &DeckSettings,
word: &WordData,
open: bool,
speech_action: T,
fields_set: &[CustomField],
) -> iced::Element<'a, T> {
column![
forward_side(deck, word, speech_action.clone(), fields_set),
rule::horizontal(2),
backward_side(deck, word, speech_action, fields_set, open)
]
.into()
}
fn forward_side<'a, T: 'a + Clone>(
deck: &DeckSettings,
word: &WordData,
speech_action: T,
fields_set: &[CustomField],
) -> iced::Element<'a, T> {
center(property_draw(
deck.forward.as_str(),
word,
speech_action,
fields_set,
))
.into()
}
fn backward_side<'a, T: 'a + Clone>(
deck: &DeckSettings,
word: &WordData,
speech_action: T,
fields_set: &[CustomField],
open: bool,
) -> iced::Element<'a, T> {
if open {
return center(property_draw(
deck.backward.as_str(),
word,
speech_action,
fields_set,
))
.into();
}
center(text!("?").size(72)).width(Fill).height(Fill).into()
}
fn property_draw<'a, T: 'a + Clone>(
formula: &str,
word: &WordData,
speech_action: T,
fields_set: &[CustomField],
) -> iced::Element<'a, T> {
let mut column = column![];
let parts = formula.split(" ");
println!("{:?}", formula);
for part in parts {
println!("displaying {}", part);
match part {
"key" => column = column.push(text!("{}", word.key).align_x(Center).size(48)),
"value" => column = column.push(text!("{}", word.value).align_x(Center).size(28)),
"tags" => column = column.push(text!("{}", word.tags).align_x(Center).size(28)),
"speech" => {
column = column.push(button("Воспроизвести").on_press(speech_action.clone()))
}
_ => {
if part == "reading"
&& let Some(reading) = word.additional.get(&Id(1))
{
column = column.push(text!("{}", reading).align_x(Center).size(24));
continue
}
if part == "description"
&& let Some(description) = word.additional.get(&Id(2))
{
column = column.push(text!("{}", description).align_x(Center).size(20));
continue
}
if part == "context"
&& let Some(context) = word.additional.get(&Id(3))
{
column = column.push(text!("{}", context).align_x(Center).size(24));
continue
}
let field_id = fields_set.iter().find(|f| f.identifier == part);
if let Some(id) = field_id
&& let Some(custom) = word.additional.get(&id.id)
{
column = column.push(text!("{}", custom).align_x(Center).size(24))
}
}
}
}
column.align_x(Center).spacing(HALF_SPACING).into()
}
+452
View File
@@ -0,0 +1,452 @@
use iced_core::widget::{tree, Operation, Tree};
use iced_core::{
layout, mouse, overlay, renderer, touch, Clipboard, Element, Event, Layout, Length, Point,
Rectangle, Shell, Size, Vector, Widget,
};
pub fn hard_mouse_area<'a, Message, Theme, Renderer>(
widget: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> HardMouseArea<'a, Message, Theme, Renderer>
where
Renderer: iced_core::Renderer,
{
HardMouseArea::new(widget)
}
pub struct HardMouseArea<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
content: Element<'a, Message, Theme, Renderer>,
on_press: Option<Message>,
on_release: Option<Message>,
on_double_click: Option<Message>,
on_right_press: Option<Message>,
on_right_release: Option<Message>,
on_middle_press: Option<Message>,
on_middle_release: Option<Message>,
on_scroll: Option<Box<dyn Fn(mouse::ScrollDelta) -> Message + 'a>>,
on_enter: Option<Message>,
on_move: Option<Box<dyn Fn(Point) -> Message + 'a>>,
on_exit: Option<Message>,
interaction: Option<mouse::Interaction>,
}
impl<'a, Message, Theme, Renderer> HardMouseArea<'a, Message, Theme, Renderer> {
/// The message to emit on a left button press.
#[must_use]
pub fn on_press(mut self, message: Message) -> Self {
self.on_press = Some(message);
self
}
/// The message to emit on a left button release.
#[must_use]
pub fn on_release(mut self, message: Message) -> Self {
self.on_release = Some(message);
self
}
/// The message to emit on a double click.
///
/// If you use this with [`on_press`]/[`on_release`], those
/// event will be emit as normal.
///
/// The events stream will be: on_press -> on_release -> on_press
/// -> on_double_click -> on_release -> on_press ...
///
/// [`on_press`]: Self::on_press
/// [`on_release`]: Self::on_release
#[must_use]
pub fn on_double_click(mut self, message: Message) -> Self {
self.on_double_click = Some(message);
self
}
/// The message to emit on a right button press.
#[must_use]
pub fn on_right_press(mut self, message: Message) -> Self {
self.on_right_press = Some(message);
self
}
/// The message to emit on a right button release.
#[must_use]
pub fn on_right_release(mut self, message: Message) -> Self {
self.on_right_release = Some(message);
self
}
/// The message to emit on a middle button press.
#[must_use]
pub fn on_middle_press(mut self, message: Message) -> Self {
self.on_middle_press = Some(message);
self
}
/// The message to emit on a middle button release.
#[must_use]
pub fn on_middle_release(mut self, message: Message) -> Self {
self.on_middle_release = Some(message);
self
}
/// The message to emit when scroll wheel is used
#[must_use]
pub fn on_scroll(mut self, on_scroll: impl Fn(mouse::ScrollDelta) -> Message + 'a) -> Self {
self.on_scroll = Some(Box::new(on_scroll));
self
}
/// The message to emit when the mouse enters the area.
#[must_use]
pub fn on_enter(mut self, message: Message) -> Self {
self.on_enter = Some(message);
self
}
/// The message to emit when the mouse moves in the area.
#[must_use]
pub fn on_move(mut self, on_move: impl Fn(Point) -> Message + 'a) -> Self {
self.on_move = Some(Box::new(on_move));
self
}
/// The message to emit when the mouse exits the area.
#[must_use]
pub fn on_exit(mut self, message: Message) -> Self {
self.on_exit = Some(message);
self
}
/// The [`mouse::Interaction`] to use when hovering the area.
#[must_use]
pub fn interaction(mut self, interaction: mouse::Interaction) -> Self {
self.interaction = Some(interaction);
self
}
}
/// Local state of the [`HardMouseArea`].
#[derive(Default)]
struct State {
is_hovered: bool,
bounds: Rectangle,
cursor_position: Option<Point>,
previous_click: Option<mouse::Click>,
}
impl<'a, Message, Theme, Renderer> HardMouseArea<'a, Message, Theme, Renderer> {
/// Creates a [`HardMouseArea`] with the given content.
pub fn new(content: impl Into<Element<'a, Message, Theme, Renderer>>) -> Self {
HardMouseArea {
content: content.into(),
on_press: None,
on_release: None,
on_double_click: None,
on_right_press: None,
on_right_release: None,
on_middle_press: None,
on_middle_release: None,
on_scroll: None,
on_enter: None,
on_move: None,
on_exit: None,
interaction: None,
}
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for HardMouseArea<'_, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Message: Clone,
{
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
renderer_style,
layout,
cursor,
viewport,
);
}
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if self.on_move.is_none()
&& let Event::Mouse(mouse::Event::CursorMoved { .. }) = event
{
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if self.on_enter.is_none()
&& let Event::Mouse(mouse::Event::CursorEntered) = event
{
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if self.on_exit.is_none()
&& let Event::Mouse(mouse::Event::CursorLeft) = event
{
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if shell.is_event_captured() {
return;
}
update(self, tree, event, layout, cursor, shell);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let content_interaction = self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
);
match (self.interaction, content_interaction) {
(Some(interaction), mouse::Interaction::None) if cursor.is_over(layout.bounds()) => {
interaction
}
_ => content_interaction,
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message, Theme, Renderer> From<HardMouseArea<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a + Clone,
Theme: 'a,
Renderer: 'a + renderer::Renderer,
{
fn from(
area: HardMouseArea<'a, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(area)
}
}
/// Processes the given [`Event`] and updates the [`State`] of an [`HardMouseArea`]
/// accordingly.
fn update<Message: Clone, Theme, Renderer>(
widget: &mut HardMouseArea<'_, Message, Theme, Renderer>,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
shell: &mut Shell<'_, Message>,
) {
let state: &mut State = tree.state.downcast_mut();
let cursor_position = cursor.position();
let bounds = layout.bounds();
if state.cursor_position != cursor_position || state.bounds != bounds {
let was_hovered = state.is_hovered;
state.is_hovered = cursor.is_over(layout.bounds());
state.cursor_position = cursor_position;
state.bounds = bounds;
match (
widget.on_enter.as_ref(),
widget.on_move.as_ref(),
widget.on_exit.as_ref(),
) {
(Some(on_enter), _, _) if state.is_hovered && !was_hovered => {
shell.publish(on_enter.clone());
}
(_, Some(on_move), _) if state.is_hovered => {
if let Some(position) = cursor.position_in(layout.bounds()) {
shell.publish(on_move(position));
}
}
(_, _, Some(on_exit)) if !state.is_hovered && was_hovered => {
shell.publish(on_exit.clone());
}
_ => {}
}
}
if !cursor.is_over(layout.bounds()) {
return;
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(touch::Event::FingerPressed { .. }) => {
if let Some(message) = widget.on_press.as_ref() {
shell.publish(message.clone());
shell.capture_event();
}
if let Some(position) = cursor_position
&& let Some(message) = widget.on_double_click.as_ref()
{
let new_click =
mouse::Click::new(position, mouse::Button::Left, state.previous_click);
if new_click.kind() == mouse::click::Kind::Double {
shell.publish(message.clone());
}
state.previous_click = Some(new_click);
// Even if this is not a double click, but the press is nevertheless
// processed by us and should not be popup to parent widgets.
shell.capture_event();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| Event::Touch(touch::Event::FingerLifted { .. }) => {
if let Some(message) = widget.on_release.as_ref() {
shell.publish(message.clone());
}
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
if let Some(message) = widget.on_right_press.as_ref() {
shell.publish(message.clone());
shell.capture_event();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => {
if let Some(message) = widget.on_right_release.as_ref() {
shell.publish(message.clone());
}
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
if let Some(message) = widget.on_middle_press.as_ref() {
shell.publish(message.clone());
shell.capture_event();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => {
if let Some(message) = widget.on_middle_release.as_ref() {
shell.publish(message.clone());
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
if let Some(on_scroll) = widget.on_scroll.as_ref() {
shell.publish(on_scroll(*delta));
shell.capture_event();
}
}
_ => {}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod hard_mouse_area;
pub mod card_display;
-190
View File
@@ -1,190 +0,0 @@
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::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;
#[derive(Clone)]
pub struct WordState {
state: Arc<Mutex<AppState>>,
index: usize,
word: WordData,
}
impl NavigatedPage<WordMessage> for WordState {
fn navigate(&mut self, message: &WordMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: WordMessage) -> Task<RootMessage> {
match message {
Back => {}
Save => {
let mut state = self.state.lock();
state.dictionary[self.index] = self.word.clone();
update_word(&mut self.word, &state.connection);
return Task::done(RootMessage::Word(Back));
}
Delete => {
let mut state = self.state.lock();
state.dictionary.remove(self.index);
delete_word(&self.word, &state.connection);
return Task::done(RootMessage::Word(Back));
}
SetTags(n) => self.word.tags = n,
SetKey(n) => {
self.word.key = n;
}
SetValue(n) => {
self.word.value = n;
}
SetAdditional(key, value) => {
self.word.additional.insert(key, value.clone());
}
AddAdditional(key) => {
self.word.additional.insert(key, "".to_string());
}
RemoveAdditional(key) => {
self.word.additional.remove(key.as_str());
}
}
Task::none()
}
fn view(&self) -> Element<'_, WordMessage> {
let mut fast_add = row![];
if !self.word.additional.contains_key("reading") {
fast_add = fast_add.push(
button("Чтение")
.style(button::text)
.on_press(AddAdditional("reading".to_string())),
);
}
if !self.word.additional.contains_key("description") {
fast_add = fast_add.push(
button("Описание")
.style(button::text)
.on_press(AddAdditional("description".to_string())),
);
}
if !self.word.additional.contains_key("context") {
fast_add = fast_add.push(
button("В контексте")
.style(button::text)
.on_press(AddAdditional("context".to_string())),
);
}
let mut col = iced::widget::column![
column![
text!("Ключ"),
text_input("key", &self.word.key).on_input(SetKey),
]
.spacing(QUARTER_SPACING),
column![
text!("Значение"),
text_input("value", &self.word.value).on_input(SetValue),
]
.spacing(QUARTER_SPACING),
column![
text!("Теги"),
text_input("tags", &self.word.tags).on_input(SetTags),
]
.spacing(QUARTER_SPACING),
rule::horizontal(2),
scrollable(fast_add),
];
for more in &self.word.additional {
col = col.push(self.get_view_for_more(more));
}
back_overlay(
column![
col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill),
row![
button("Сохранить").style(jl_button).on_press(Save),
button("Удалить").style(danger).on_press(Delete),
]
.spacing(DEFAULT_SPACING)
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
impl WordState {
pub(crate) fn new(word: WordData, index: usize, state: Arc<Mutex<AppState>>) -> WordState {
WordState { state, index, word }
}
}
impl WordState {
fn get_view_for_more(&self, value: (&String, &String)) -> Element<'_, WordMessage> {
match value.0.as_str() {
"reading" => self.reading_field(value.1),
"description" => self.description_field(value.1),
"context" => self.context_field(value.1),
_ => self.additional_field(value.1, value.0.clone(), value.0.clone()),
}
}
fn reading_field(&self, value: &str) -> Element<'_, WordMessage> {
self.additional_field(value, "Чтение слова".to_string(), "reading".to_string())
}
fn description_field(&self, value: &str) -> Element<'_, WordMessage> {
self.additional_field(value, "Описание".to_string(), "description".to_string())
}
fn context_field(&self, value: &str) -> Element<'_, WordMessage> {
self.additional_field(value, "В контексте".to_string(), "context".to_string())
}
fn additional_field(&self, value: &str, name: String, id: String) -> Element<'_, WordMessage> {
column![
text!("{}", name),
row![
text_input(id.clone().as_str(), value)
.on_input({
let value = id.clone();
move |string| SetAdditional(value.clone(), string)
})
.width(Fill),
button("-")
.on_press(RemoveAdditional(id.clone()))
.style(danger),
]
.spacing(QUARTER_SPACING),
]
.spacing(QUARTER_SPACING)
.into()
}
}
#[derive(Debug, Clone)]
pub enum WordMessage {
Save,
Back,
Delete,
SetTags(String),
SetKey(String),
SetValue(String),
AddAdditional(String),
RemoveAdditional(String),
SetAdditional(String, String),
}