commit 6cb70c88cfb1a502af9e358393183dc7489097c8 Author: Mikhail Mitrofanov Date: Sat Jun 13 12:58:12 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/db_schema.sql b/db_schema.sql new file mode 100644 index 0000000..27b23e5 --- /dev/null +++ b/db_schema.sql @@ -0,0 +1,52 @@ +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 +); + +create table settings +( + id text + primary key, + value text +); + +create table word_group +( + id INTEGER + primary key autoincrement, + name TEXT not null +); + +create table words +( + id INTEGER + primary key autoincrement, + key TEXT not null, + value TEXT not null, + tags TEXT not null, + more TEXT, + group_id integer default 1 not null + constraint words_word_group_id_fk + references word_group + on update cascade on delete cascade +); + +create table card_stats +( + id INTEGER + primary key autoincrement, + word_id INTEGER not null + references words + on delete cascade, + set_id TEXT not null + references card_set + on delete cascade, + score INTEGER default 1 not null, + last_opened integer not null +); + diff --git a/index.html b/index.html new file mode 100644 index 0000000..d1853be --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + + + + + + JapLearn + + +
+
+

JapLearn

+

Интервальные повторения японских слов

+
+ +
+ +
+ + +
+ + + \ No newline at end of file diff --git a/lang.rs b/lang.rs new file mode 100644 index 0000000..898df96 --- /dev/null +++ b/lang.rs @@ -0,0 +1,375 @@ +use crate::data_provider::card_stats::{ + add_stat, delete_stat, load_stats_of_set, update_stat_score, +}; + +use crate::AppState; +use chrono::{DateTime, Utc}; +use rand::distr::weighted::WeightedIndex; +use rand::distr::Distribution; +use rand::prelude::SliceRandom; +use rand::rng; +use rand::rngs::ThreadRng; +use serde::{Deserialize, Serialize}; +use std::cmp::min; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +const MAX_HISTORY_LEN: usize = 20; +const MAX_HISTORY_LEN_PART: f32 = 0.33; +const MAX_SCORE: i32 = 25; +const FADE_PER_DAY: f32 = 0.95; + + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WordData { + pub id: u32, + pub key: String, + pub value: String, + pub tags: String, + pub additional: HashMap, + pub group_id: u32, +} + +impl WordData { + pub fn new() -> Self { + Self { + id: 0, + key: String::new(), + value: String::new(), + tags: String::new(), + additional: Default::default(), + group_id: 1, + } + } +} + +#[derive(Clone)] +pub struct WordGroup { + pub id: u32, + pub name: String, +} + +#[derive(Clone, PartialEq)] +pub struct CardStatistics { + pub id: u32, + pub word_id: u32, + pub set_id: u32, + pub last_open: DateTime, + pub score: i32, +} + +impl CardStatistics { + pub fn update(&mut self, status: WordOpenMode) { + match status { + WordOpenMode::Easy => { + self.score = (self.calculated_score() + 5.0).round() as i32; + } + WordOpenMode::Ok => self.score = (self.calculated_score() + 2.0).round() as i32, + WordOpenMode::Hard => { + self.score = (self.calculated_score() - 1.0).round() as i32; + } + WordOpenMode::None => { + self.score = (self.calculated_score() * 0.5) as i32; + } + } + + if self.score < 1 { + self.score = 1 + } else if self.score > MAX_SCORE { + self.score = 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 + } +} + +#[derive(Clone, Copy)] +pub enum WordOpenMode { + Easy, + Ok, + Hard, + None, +} + +#[derive(Clone)] +pub struct CardSet { + words: Vec, + set: Vec, + current_word_index: Option, + state: Arc>, + order_module: OrderModule, +} + +#[derive(Clone)] +pub struct CardSetSettings { + pub id: u32, + pub name: String, + pub forward: String, + pub backward: String, + pub filter: String, + pub count: Option, + pub worst_words_list: Option>, + pub open_mode: SetOrderMode +} + +impl CardSet { + pub fn new(settings: &CardSetSettings, state: Arc>) -> Self { + let state_for = state.clone(); + let state_locked = state.lock().unwrap(); + + let mut current_set = load_stats_of_set(&settings, &state_locked.connection); + let last_list = settings.get_word_list(&state_locked); + let saved_ids = current_set.iter().map(|l| l.word_id).collect::>(); + let word_ids = last_list.iter().map(|l| l.id).collect::>(); + + last_list + .iter() + .filter(|word| !saved_ids.contains(&word.id)) + .map(|word| CardStatistics { + id: 0, + word_id: word.id.clone(), + last_open: Utc::now(), + score: 1, + set_id: settings.id.clone(), + }) + .for_each(|mut new_statistic| { + add_stat(&mut new_statistic, &state_locked.connection); + current_set.push(new_statistic); + }); + + let mut index = 0; + for stat in current_set.clone() { + if !word_ids.contains(&stat.word_id) { + delete_stat(&stat, &state_locked.connection); + current_set.remove(index); + } else { + index += 1; + } + } + + Self { + set: current_set, + words: last_list, + current_word_index: None, + state: state_for, + order_module: match settings.open_mode { + SetOrderMode::Default => OrderModule::SemiRandomSRS(SemiRandomSRSModule::new()), + SetOrderMode::TrainWorstFirst => { + OrderModule::WorstWordsSRS(WorstWordsSRSModule::new()) + } + SetOrderMode::FullRandom => OrderModule::RandomSRS(RandomSRSModule::new()), + }, + } + } + + pub fn next(&mut self) -> (WordData, CardStatistics) { + let index = match self.order_module.clone() { + OrderModule::SemiRandomSRS(mut module) => { + if module.initializated == false { + module.init(self) + } + let index = module.next(self); + self.order_module = OrderModule::SemiRandomSRS(module); + index + } + OrderModule::RandomSRS(mut module) => { + if module.initializated == false { + module.init(self) + } + let index = module.next(self); + self.order_module = OrderModule::RandomSRS(module); + index + } + OrderModule::WorstWordsSRS(mut module) => { + if module.initializated == false { + 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 let None = self.current_word_index { + return; + } + let index = self.current_word_index.unwrap(); + + let word = &mut self.set[index]; + word.update(status); + + 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().unwrap().connection) + } + + pub fn len(&self) -> usize { + self.set.len() + } +} + +#[derive(Clone, PartialEq, Copy, Eq)] +pub(crate) enum SetOrderMode { + Default, + TrainWorstFirst, + FullRandom, +} + +#[derive(Clone)] +enum OrderModule { + SemiRandomSRS(SemiRandomSRSModule), + RandomSRS(RandomSRSModule), + WorstWordsSRS(WorstWordsSRSModule), +} + +trait SRSModule { + fn next(&mut self, set: &mut CardSet) -> usize; + fn open(&mut self, status: WordOpenMode, index: usize, updated_word: CardStatistics); + fn init(&mut self, set: &mut CardSet); +} + +#[derive(Clone)] +struct RandomSRSModule { + backet: Vec, + initializated: bool, +} + +impl RandomSRSModule { + fn new() -> RandomSRSModule { + Self{ + backet: vec![], + initializated: false, + } + } +} + +impl SRSModule for RandomSRSModule { + fn next(&mut self, set: &mut CardSet) -> usize { + if self.backet.is_empty() { + self.backet = (0..set.words.len()).collect::>(); + self.backet.shuffle(&mut rand::rng()) + } + + self.backet.pop().unwrap() + } + + fn open(&mut self, _: WordOpenMode, _: usize, _: CardStatistics) {} + + fn init(&mut self, set: &mut CardSet) { + self.initializated = true; + self.backet = (0..set.words.len()).collect::>(); + self.backet.shuffle(&mut rand::rng()) + } +} + +#[derive(Clone)] +struct SemiRandomSRSModule { + history: Vec, + last_weights: WeightedIndex, + generator: ThreadRng, + initializated: bool, +} + +impl SemiRandomSRSModule { + fn new() -> SemiRandomSRSModule { + SemiRandomSRSModule { + history: vec![], + last_weights: WeightedIndex::new([1.0]).unwrap(), + generator: rng(), + initializated: false, + } + } +} + +impl SRSModule for SemiRandomSRSModule { + fn next(&mut self, set: &mut CardSet) -> usize { + let index = self.last_weights.sample(&mut self.generator); + + if self.history.contains(&index) { + 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, status: WordOpenMode, index: usize, word: CardStatistics) { + let new_weight = (100.0 / word.calculated_score()).powf(2.0); + self.last_weights + .update_weights(&[(index, &new_weight)]) + .unwrap(); + } + + fn init(&mut self, set: &mut CardSet) { + self.initializated = true; + let weights = set + .set + .iter() + .map(|s| (100.0 / s.calculated_score()).powf(2.0) * 2.0) + .collect::>(); + self.last_weights = WeightedIndex::new(weights).unwrap(); + } +} + +impl SemiRandomSRSModule { + fn history_len(&self, set: &CardSet) -> usize { + min( + MAX_HISTORY_LEN, + (set.len() as f32 * MAX_HISTORY_LEN_PART) as usize, + ) + } +} + +#[derive(Clone)] +struct WorstWordsSRSModule { + initializated: bool, +} + +impl SRSModule for WorstWordsSRSModule { + fn next(&mut self, set: &mut CardSet) -> usize { + todo!() + } + + fn open(&mut self, status: WordOpenMode, index: usize, updated_word: CardStatistics) { + todo!() + } + + fn init(&mut self, set: &mut CardSet) { + todo!() + } +} + +impl WorstWordsSRSModule { + fn new() -> WorstWordsSRSModule { + WorstWordsSRSModule { + initializated: false, + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c4e0a8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,902 @@ +{ + "name": "jap_web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jap_web", + "version": "0.0.0", + "dependencies": { + "@bokuweb/zstd-wasm": "^0.0.27", + "wa-sqlite": "^1.0.0" + }, + "devDependencies": { + "typescript": "~6.0.2", + "vite": "^8.0.12" + } + }, + "node_modules/@bokuweb/zstd-wasm": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/@bokuweb/zstd-wasm/-/zstd-wasm-0.0.27.tgz", + "integrity": "sha512-GDm2uOTK3ESjnYmSeLQifJnBsRCWajKLvN32D2ZcQaaCIJI/Hse9s74f7APXjHit95S10UImsRGkTsbwHmrtmg==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wa-sqlite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wa-sqlite/-/wa-sqlite-1.0.0.tgz", + "integrity": "sha512-Kyybo5/BaJp76z7gDWGk2J6Hthl4NIPsE+swgraEjy3IY6r5zIR02wAs1OJH4XtJp1y3puj3Onp5eMGS0z7nUA==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9820884 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "jap_web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~6.0.2", + "vite": "^8.0.12" + }, + "dependencies": { + "@bokuweb/zstd-wasm": "^0.0.27", + "wa-sqlite": "^1.0.0" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/algorithm.ts b/src/algorithm.ts new file mode 100644 index 0000000..f0658e3 --- /dev/null +++ b/src/algorithm.ts @@ -0,0 +1,308 @@ +// ──── Constants ──── +const MAX_HISTORY_LEN = 20; +const MAX_HISTORY_LEN_PART = 0.33; +const MAX_SCORE = 25; +const FADE_PER_DAY = 0.95; + +// ──── Data types ──── + +export interface WordData { + id: number; + key: string; + value: string; + tags: string; + additional: Record; + group_id: number; +} + +export interface WordGroup { + id: number; + name: string; +} + +export interface CardStatistics { + id: number; + word_id: number; + set_id: number; + last_open: number; // unix timestamp ms + score: number; +} + +export type WordOpenMode = 'easy' | 'ok' | 'hard' | 'none'; + +export const SetOrderMode = { + Default: 'Default', + TrainWorstFirst: 'TrainWorstFirst', + FullRandom: 'FullRandom', +} as const; + +export type SetOrderMode = (typeof SetOrderMode)[keyof typeof SetOrderMode]; + +export interface CardSetSettings { + id: number; + name: string; + forward: string; + backward: string; + filter: string; + count: number | null; + worst_words_list: WordData[] | null; + open_mode: SetOrderMode; +} + +// ──── CardStatistics helpers ──── + +export function calculatedScore(stat: CardStatistics): number { + const now = Date.now(); + const diffMs = now - stat.last_open; + const days = diffMs / (1000 * 60 * 60 * 24); + const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days)); + return stat.score * multiplier; +} + +export function updateStatScore(stat: CardStatistics, status: WordOpenMode): void { + const cs = calculatedScore(stat); + switch (status) { + case 'easy': + stat.score = Math.round(cs + 5); + break; + case 'ok': + stat.score = Math.round(cs + 2); + break; + case 'hard': + stat.score = Math.round(cs - 1); + break; + case 'none': + stat.score = Math.round(cs * 0.5); + break; + } + + if (stat.score < 1) stat.score = 1; + else if (stat.score > MAX_SCORE) stat.score = MAX_SCORE; + + stat.last_open = Date.now(); +} + +// ──── WeightedIndex (simple O(n) implementation) ──── + +class WeightedIndex { + private weights: number[]; + + constructor(weights: number[]) { + this.weights = [...weights]; + } + + sample(): number { + const total = this.weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (let i = 0; i < this.weights.length; i++) { + r -= this.weights[i]; + if (r <= 0) return i; + } + return this.weights.length - 1; + } + + updateWeight(index: number, weight: number): void { + this.weights[index] = weight; + } +} + +// ──── SRSModule interface ──── + +interface SRSModule { + next(set: { words: WordData[]; set: CardStatistics[] }): number; + open(status: WordOpenMode, index: number, updatedWord: CardStatistics): void; + init(set: { words: WordData[]; set: CardStatistics[] }): void; +} + +// ──── RandomSRSModule ──── + +class RandomSRSModule implements SRSModule { + private backet: number[] = []; + private initialized = false; + + next(set: { words: WordData[]; set: CardStatistics[] }): number { + if (this.backet.length === 0) { + this.backet = Array.from({ length: set.words.length }, (_, i) => i); + this.shuffle(this.backet); + } + return this.backet.pop()!; + } + + open(_status: WordOpenMode, _index: number, _updatedWord: CardStatistics): void { + // no-op + } + + init(set: { words: WordData[]; set: CardStatistics[] }): void { + this.initialized = true; + this.backet = Array.from({ length: set.words.length }, (_, i) => i); + this.shuffle(this.backet); + } + + private shuffle(arr: number[]): void { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + } +} + +// ──── SemiRandomSRSModule ──── + +class SemiRandomSRSModule implements SRSModule { + private history: number[] = []; + private lastWeights!: WeightedIndex; + private initialized = false; + + next(set: { words: WordData[]; set: CardStatistics[] }): number { + const index = this.lastWeights.sample(); + + if (this.history.includes(index)) { + return this.next(set); + } + + if (this.history.length >= this.historyLen(set.set.length)) { + this.history.shift(); + } + this.history.push(index); + + return index; + } + + open(_status: WordOpenMode, index: number, word: CardStatistics): void { + const newWeight = Math.pow(100.0 / Math.max(0.1, calculatedScore(word)), 2.0); + this.lastWeights.updateWeight(index, newWeight); + } + + init(set: { words: WordData[]; set: CardStatistics[] }): void { + this.initialized = true; + const weights = set.set.map( + (s) => Math.pow(100.0 / Math.max(0.1, calculatedScore(s)), 2.0) * 2.0, + ); + this.lastWeights = new WeightedIndex(weights); + } + + private historyLen(setLen: number): number { + return Math.min(MAX_HISTORY_LEN, Math.floor(setLen * MAX_HISTORY_LEN_PART)); + } +} + +// ──── WorstWordsSRSModule (stub) ──── + +class WorstWordsSRSModule implements SRSModule { + private initialized = false; + + next(_set: { words: WordData[]; set: CardStatistics[] }): number { + throw new Error('Not implemented'); + } + + open(_status: WordOpenMode, _index: number, _updatedWord: CardStatistics): void { + throw new Error('Not implemented'); + } + + init(_set: { words: WordData[]; set: CardStatistics[] }): void { + throw new Error('Not implemented'); + } +} + +// ──── OrderModule ──── + +type OrderModule = + | { type: 'SemiRandomSRS'; module: SemiRandomSRSModule } + | { type: 'RandomSRS'; module: RandomSRSModule } + | { type: 'WorstWordsSRS'; module: WorstWordsSRSModule }; + +// ──── CardSet ──── + +export class CardSet { + words: WordData[]; + set: CardStatistics[]; + currentWordIndex: number | null = null; + private orderModule: OrderModule; + + constructor(settings: CardSetSettings, words: WordData[], stats: CardStatistics[]) { + this.words = words; + this.set = stats; + + const openMode = settings.open_mode; + + this.orderModule = + openMode === SetOrderMode.FullRandom + ? { type: 'RandomSRS', module: new RandomSRSModule() } + : openMode === SetOrderMode.TrainWorstFirst + ? { type: 'WorstWordsSRS', module: new WorstWordsSRSModule() } + : { type: 'SemiRandomSRS', module: new SemiRandomSRSModule() }; + } + + private getOrderContext() { + return { words: this.words, set: this.set }; + } + + next(): { word: WordData; stat: CardStatistics } { + const ctx = this.getOrderContext(); + + let index: number; + const mod = this.orderModule; + + switch (mod.type) { + case 'SemiRandomSRS': { + if (!mod.module['initialized']) { + mod.module.init(ctx); + } + index = mod.module.next(ctx); + break; + } + case 'RandomSRS': { + if (!mod.module['initialized']) { + mod.module.init(ctx); + } + index = mod.module.next(ctx); + break; + } + case 'WorstWordsSRS': { + if (!mod.module['initialized']) { + mod.module.init(ctx); + } + index = mod.module.next(ctx); + break; + } + } + + this.currentWordIndex = index; + return { word: this.words[index], stat: this.set[index] }; + } + + open(status: WordOpenMode): void { + if (this.currentWordIndex === null) return; + const index = this.currentWordIndex; + + const word = this.set[index]; + updateStatScore(word, status); + + const mod = this.orderModule; + switch (mod.type) { + case 'SemiRandomSRS': + mod.module.open(status, index, word); + break; + case 'RandomSRS': + mod.module.open(status, index, word); + break; + case 'WorstWordsSRS': + mod.module.open(status, index, word); + break; + } + } + + get length(): number { + return this.set.length; + } +} + +// ──── Factory helper ──── + +export function createCardSet( + settings: CardSetSettings, + words: WordData[], + stats: CardStatistics[], +): CardSet { + return new CardSet(settings, words, stats); +} \ No newline at end of file diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/typescript.svg b/src/assets/typescript.svg new file mode 100644 index 0000000..6c9d69c --- /dev/null +++ b/src/assets/typescript.svg @@ -0,0 +1 @@ + diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/counter.ts b/src/counter.ts new file mode 100644 index 0000000..9f629e8 --- /dev/null +++ b/src/counter.ts @@ -0,0 +1,9 @@ +export function setupCounter(element: HTMLButtonElement) { + let counter = 0 + const setCounter = (count: number) => { + counter = count + element.innerHTML = `Count is ${counter}` + } + element.addEventListener('click', () => setCounter(counter + 1)) + setCounter(0) +} diff --git a/src/db-manager.ts b/src/db-manager.ts new file mode 100644 index 0000000..7ce1d9e --- /dev/null +++ b/src/db-manager.ts @@ -0,0 +1,57 @@ +import { execute } from './sqlite-manager.ts'; + +export interface Word { + id: number; + key: string; + value: string; + tags: string; + more: string | null; + group_id: number; +} + +export interface CardSetRecord { + id: number; + name: string; + forward: string; + backward: string; + filter: string; +} + +/** + * Получает все колоды (card_set). + */ +export async function getCardSets(): Promise { + const rows: any[] = await execute(` + SELECT id, name, forward, backward, filter + FROM card_set + ORDER BY id ASC + `); + + return rows.map((row: any[]) => ({ + id: row[0], + name: row[1], + forward: row[2], + backward: row[3], + filter: row[4], + })) as CardSetRecord[]; +} + +/** + * Получает все слова для повторения. + */ +export async function getWordsForReview(): Promise { + const rows: any[] = await execute(` + SELECT id, key, value, tags, more, group_id + FROM words + ORDER BY id ASC + `); + + return rows.map((row: any[]) => ({ + id: row[0], + key: row[1], + value: row[2], + tags: row[3], + more: row[4], + group_id: row[5], + })) as Word[]; +} diff --git a/src/db-worker.ts b/src/db-worker.ts new file mode 100644 index 0000000..057d0ae --- /dev/null +++ b/src/db-worker.ts @@ -0,0 +1,191 @@ +/** + * Web Worker для работы с wa-sqlite + OPFS. + * Воркер необходим, так как createSyncAccessHandle() доступен только в Worker'ах. + */ + +let sqlite: any = null; +let db: number | null = null; +let vfs: any = null; +let initialized = false; + +const DB_FILENAME = '/japanese_app.db'; +const SQLITE_OPEN_CREATE = 0x0004; +const SQLITE_OPEN_READWRITE = 0x0002; +const OPEN_FLAGS = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + +async function init() { + if (initialized) return; + + const { Factory } = await import('wa-sqlite/src/sqlite-api.js'); + const { OriginPrivateFileSystemVFS } = await import( + 'wa-sqlite/src/examples/OriginPrivateFileSystemVFS.js' + ); + const ModuleConstructor = (await import('wa-sqlite/dist/wa-sqlite-async.mjs')).default; + + const module = await ModuleConstructor(); + const api = Factory(module); + sqlite = api; + + const opfsVfs = new OriginPrivateFileSystemVFS(); + vfs = opfsVfs; + + const rc = api.vfs_register(opfsVfs, true); + if (rc) { + throw new Error(`Не удалось зарегистрировать OPFS VFS, код ${rc}`); + } + + const handle = await api.open_v2(DB_FILENAME, OPEN_FLAGS); + db = handle; + + initialized = true; + self.postMessage({ type: 'ready', ok: true }); +} + +async function execSchema() { + if (!sqlite || db === null) return; + + const schema = ` +create table if not exists card_set +( + id INTEGER primary key autoincrement, + name TEXT not null, + forward TEXT not null, + backward TEXT not null, + filter TEXT not null +); + +create table if not exists settings +( + id text primary key, + value text +); + +create table if not exists word_group +( + id INTEGER primary key autoincrement, + name TEXT not null +); + +create table if not exists words +( + id INTEGER primary key autoincrement, + key TEXT not null, + value TEXT not null, + tags TEXT not null, + more TEXT, + group_id integer default 1 not null + constraint words_word_group_id_fk references word_group on update cascade on delete cascade +); + +create table if not exists card_stats +( + id INTEGER primary key autoincrement, + word_id INTEGER not null references words on delete cascade, + set_id TEXT not null references card_set on delete cascade, + score INTEGER default 1 not null, + last_opened integer not null +); +`; + + const statements = schema + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + for (const stmt of statements) { + await sqlite.exec(db!, stmt + ';'); + } +} + +self.onmessage = async (e: MessageEvent) => { + const { type, id, sql, params } = e.data; + + try { + switch (type) { + case 'init': { + await init(); + await execSchema(); + self.postMessage({ type: 'result', id, ok: true, data: null }); + break; + } + + case 'execute': { + if (!sqlite || db === null) throw new Error('БД не инициализирована'); + const result = await sqlite.execWithParams(db, sql, params); + self.postMessage({ type: 'result', id, ok: true, data: result.rows }); + break; + } + + case 'executeVoid': { + if (!sqlite || db === null) throw new Error('БД не инициализирована'); + await sqlite.run(db, sql, params); + self.postMessage({ type: 'result', id, ok: true, data: null }); + break; + } + + case 'closeDb': { + if (sqlite && db !== null) { + await sqlite.close(db); + db = null; + } + if (vfs) { + await vfs.close(); + vfs = null; + } + initialized = false; + self.postMessage({ type: 'result', id, ok: true, data: null }); + break; + } + + case 'getFile': { + // Получаем файл из OPFS + const root = await navigator.storage.getDirectory(); + const fileHandle = await root.getFileHandle('japanese_app.db'); + const file = await fileHandle.getFile(); + const buffer = await file.arrayBuffer(); + // Передаём ArrayBuffer как Transferable + self.postMessage( + { type: 'result', id, ok: true, data: buffer }, + { transfer: [buffer] }, + ); + break; + } + + case 'replaceFile': { + // Закрываем текущее соединение + if (sqlite && db !== null) { + await sqlite.close(db); + db = null; + } + if (vfs) { + await vfs.close(); + vfs = null; + } + initialized = false; + + // Перезаписываем файл в OPFS + const root = await navigator.storage.getDirectory(); + const fileHandle = await root.getFileHandle('japanese_app.db', { create: true }); + const accessHandle = await (fileHandle as any).createSyncAccessHandle(); + try { + const data = new Uint8Array(params); + accessHandle.truncate(0); + accessHandle.write(data, { at: 0 }); + accessHandle.flush(); + } finally { + accessHandle.close(); + } + + // Переинициализируем + await init(); + self.postMessage({ type: 'result', id, ok: true, data: null }); + break; + } + + default: + throw new Error(`Неизвестный тип сообщения: ${type}`); + } + } catch (err: any) { + self.postMessage({ type: 'result', id, ok: false, error: err.message }); + } +}; \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..ae0d494 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,58 @@ +// ──── Database schema ──── +// Imported from db_schema.sql + +export function getSchema(): string { + return ` +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 +); + +create table settings +( + id text + primary key, + value text +); + +create table word_group +( + id INTEGER + primary key autoincrement, + name TEXT not null +); + +create table words +( + id INTEGER + primary key autoincrement, + key TEXT not null, + value TEXT not null, + tags TEXT not null, + more TEXT, + group_id integer default 1 not null + constraint words_word_group_id_fk + references word_group + on update cascade on delete cascade +); + +create table card_stats +( + id INTEGER + primary key autoincrement, + word_id INTEGER not null + references words + on delete cascade, + set_id TEXT not null + references card_set + on delete cascade, + score INTEGER default 1 not null, + last_opened integer not null +); +`.trim(); +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..13c882c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,287 @@ +import './style.css'; +import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts'; +import { uploadDatabase, downloadDatabase } from './sync.ts'; +import { getWordsForReview, getCardSets } from './db-manager.ts'; +import type { CardSetRecord } from './db-manager.ts'; +import type { Word } from './db-manager.ts'; +import { SetOrderMode } from './algorithm.ts'; + +// ----- Конфигурация ----- +const API_BASE_URL = 'https://learning.micialware.ru'; + +// ----- Элементы UI ----- +const container = document.getElementById('page-container') as HTMLElement; +const navBtns = document.querySelectorAll('.nav-btn'); +let currentPage: 'words' | 'sets' = 'words'; + +// ----- Вспомогательные функции ----- + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function setStatus(msg: string, isError = false) { + const statusEl = document.getElementById('status'); + if (statusEl) { + statusEl.textContent = msg; + statusEl.className = 'status-bar'; + if (isError) statusEl.classList.add('error'); + else statusEl.classList.add('success'); + } + console.log(`[App] ${msg}`); +} + +function getSyncId(): string { + const input = document.getElementById('syncId') as HTMLInputElement | null; + const id = input?.value.trim(); + if (!id) throw new Error('Введите ID синхронизации'); + return id; +} + +// ----- Рендеринг страниц ----- + +function renderWordsPage() { + container.innerHTML = ` +
+

📡 Синхронизация

+
+ +
+ + +
+
+
+ +
+

📚 Слова

+
    +
    + +
    Загрузка...
    + `; +} + +function renderWordsList(words: Word[]) { + const wordListEl = document.getElementById('word-list') as HTMLUListElement | null; + if (!wordListEl) return; + + wordListEl.innerHTML = ''; + + if (words.length === 0) { + wordListEl.innerHTML = '
  • Нет слов для повторения 🎉
  • '; + return; + } + + for (const word of words) { + const li = document.createElement('li'); + li.className = 'word-item'; + + let reading = word.key; + let translation = word.value; + + if (word.more) { + try { + const parsed = JSON.parse(word.more); + reading = parsed.reading || word.key; + translation = parsed.translation || word.value; + } catch { + // ignore + } + } + + li.innerHTML = ` + ${reading} + ${escapeHtml(translation)} + ${word.tags ? `${escapeHtml(word.tags)}` : ''} + `; + wordListEl.appendChild(li); + } +} + +function renderSetsPage() { + container.innerHTML = ` +
    +
    +
      +
      +
      +
      🗂️
      +
      Выберите колоду
      +
      +
      +
      +
      +
      Загрузка...
      + `; +} + +function renderSetsList(sets: CardSetRecord[]) { + const listEl = document.getElementById('sets-list') as HTMLUListElement | null; + if (!listEl) return; + + listEl.innerHTML = ''; + + if (sets.length === 0) { + listEl.innerHTML = '
    • Нет колод
    • '; + return; + } + + for (const set of sets) { + const li = document.createElement('li'); + li.textContent = set.name; + li.dataset.setId = String(set.id); + li.addEventListener('click', () => showSetDetail(set, li)); + listEl.appendChild(li); + } + + // Выделяем первую + const first = listEl.querySelector('li') as HTMLLIElement | null; + if (first) { + first.classList.add('selected'); + const id = Number(first.dataset.setId); + const set = sets.find(s => s.id === id); + if (set) showSetDetail(set, first); + } +} + +function showSetDetail(set: CardSetRecord, listItem: HTMLLIElement) { + // Обновляем выделение + document.querySelectorAll('#sets-list li').forEach(el => el.classList.remove('selected')); + listItem.classList.add('selected'); + + const detailEl = document.getElementById('sets-detail'); + if (!detailEl) return; + + detailEl.innerHTML = ` +

      ${escapeHtml(set.name)}

      +

      Прямой порядок: ${escapeHtml(set.forward)}

      +

      Обратный порядок: ${escapeHtml(set.backward)}

      +

      Фильтр: ${escapeHtml(set.filter)}

      + + + + + + `; +} + +// ----- Навигация ----- + +function switchPage(page: 'words' | 'sets') { + currentPage = page; + + // Обновляем активную кнопку + navBtns.forEach(btn => { + btn.classList.toggle('active', btn.dataset.page === page); + }); + + if (page === 'words') { + renderWordsPage(); + loadWords(); + } else { + renderSetsPage(); + loadSets(); + } +} + +async function loadWords() { + try { + const words = await getWordsForReview(); + renderWordsList(words); + const count = words.length; + setStatus(`Слов: ${count}`); + } catch (err) { + setStatus('Ошибка загрузки слов', true); + console.error(err); + } +} + +async function loadSets() { + try { + const sets = await getCardSets(); + renderSetsList(sets); + setStatus(`Колод: ${sets.length}`); + } catch (err) { + setStatus('Ошибка загрузки колод', true); + console.error(err); + } +} + +// ----- Обработчики синхронизации ----- + +async function onDownload() { + try { + const id = getSyncId(); + const btn = document.getElementById('btnDownload') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + + setStatus('Скачивание...'); + await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB); + + if (currentPage === 'words') await loadWords(); + setStatus('База данных скачана.'); + } catch { + // ошибка уже обработана в sync.ts + } finally { + const btn = document.getElementById('btnDownload') as HTMLButtonElement | null; + if (btn) btn.disabled = false; + } +} + +async function onUpload() { + try { + const id = getSyncId(); + const btn = document.getElementById('btnUpload') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + + setStatus('Загрузка...'); + await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile); + + await initDB(); + if (currentPage === 'words') await loadWords(); + setStatus('База данных загружена.'); + } catch { + // ошибка уже обработана в sync.ts + } finally { + const btn = document.getElementById('btnUpload') as HTMLButtonElement | null; + if (btn) btn.disabled = false; + } +} + +// ----- Инициализация ----- + +async function main() { + try { + await initDB(); + console.log('[App] База данных инициализирована.'); + + // Навигация + navBtns.forEach(btn => { + btn.addEventListener('click', () => { + switchPage(btn.dataset.page as 'words' | 'sets'); + }); + }); + + // Стартовая страница + switchPage('words'); + + // Глобальные обработчики кликов (для кнопок на динамических страницах) + container.addEventListener('click', async (e) => { + const target = e.target as HTMLElement; + if (target.id === 'btnDownload') await onDownload(); + if (target.id === 'btnUpload') await onUpload(); + }); + + } catch (err) { + console.error('[App] Ошибка инициализации:', err); + } +} + +main(); \ No newline at end of file diff --git a/src/sqlite-manager.ts b/src/sqlite-manager.ts new file mode 100644 index 0000000..6daa658 --- /dev/null +++ b/src/sqlite-manager.ts @@ -0,0 +1,120 @@ +import DbWorker from './db-worker.ts?worker'; + +// ----- Прокси к Worker ----- + +let worker: Worker | null = null; +let initPromise: Promise | null = null; +let requestId = 0; + +type WorkerRequest = { + resolve: (data: any) => void; + reject: (error: Error) => void; +}; + +const pending = new Map(); + +function sendToWorker(type: string, payload?: any): Promise { + return new Promise((resolve, reject) => { + if (!worker) { + reject(new Error('Worker не создан')); + return; + } + + const id = ++requestId; + pending.set(id, { resolve, reject }); + + worker.postMessage({ type, id, ...payload }); + }); +} + +function initWorker(): Promise { + if (initPromise) return initPromise; + + initPromise = new Promise((resolve, reject) => { + worker = new DbWorker(); + + worker.onmessage = (e: MessageEvent) => { + const msg = e.data; + + if (msg.type === 'ready') { + // Готов — запрос init уже обработан + return; + } + + if (msg.type === 'result') { + const pendingReq = pending.get(msg.id); + if (!pendingReq) return; + pending.delete(msg.id); + + if (msg.ok) { + pendingReq.resolve(msg.data); + } else { + pendingReq.reject(new Error(msg.error || 'Неизвестная ошибка Worker')); + } + } + }; + + worker.onerror = (err) => { + console.error('[SQLite Worker] Ошибка:', err); + reject(new Error('Ошибка Worker')); + }; + + // Отправляем init + const id = ++requestId; + pending.set(id, { resolve, reject }); + worker.postMessage({ type: 'init', id }); + }); + + return initPromise; +} + +// ----- Публичные функции ----- + +export async function initDB(): Promise { + await initWorker(); + console.log('[SQLite] База данных инициализирована.'); +} + +export async function execute(sql: string, params?: any[]): Promise { + const result = await sendToWorker('execute', { sql, params }); + return result; +} + +export async function executeVoid(sql: string, params?: any[]): Promise { + await sendToWorker('executeVoid', { sql, params }); +} + +export async function getDatabaseFile(): Promise { + const buffer = await sendToWorker('getFile'); + return buffer; +} + +/** + * Закрывает соединение с БД и освобождает блокировку файла OPFS. + */ +export async function closeDb(): Promise { + if (!worker) return; + await sendToWorker('closeDb'); + worker.terminate(); + worker = null; + initPromise = null; +} + +export async function replaceDatabaseFile(arrayBuffer: ArrayBuffer): Promise { + // ArrayBuffer передаётся через transferable + await sendToWorkerRaw('replaceFile', arrayBuffer); +} + +async function sendToWorkerRaw(type: string, buffer: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + if (!worker) { + reject(new Error('Worker не создан')); + return; + } + + const id = ++requestId; + pending.set(id, { resolve, reject }); + + worker.postMessage({ type, id, params: buffer }, [buffer]); + }); +} \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..bdefd2e --- /dev/null +++ b/src/style.css @@ -0,0 +1,404 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --card-bg: #f8f7f9; + --border: #e5e4e7; + --accent: #7c3aed; + --accent-light: rgba(124, 58, 237, 0.1); + --success: #16a34a; + --error: #dc2626; + --shadow: rgba(0, 0, 0, 0.08) 0 4px 12px; + + --sans: -apple-system, 'Segoe UI', system-ui, Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 16px/1.5 var(--sans); + color-scheme: light dark; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #0f0f13; + --card-bg: #1a1b23; + --border: #2e303a; + --accent: #a78bfa; + --accent-light: rgba(167, 139, 250, 0.15); + --shadow: rgba(0, 0, 0, 0.4) 0 4px 12px; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +#app { + flex: 1; + display: flex; + flex-direction: column; + max-width: 480px; + width: 100%; + margin: 0 auto; + padding: 0; +} + +.app-header { + padding: 1.5rem 1.25rem 0.5rem; + text-align: center; +} + +.app-header h1 { + font-family: var(--sans); + font-size: 1.75rem; + font-weight: 700; + color: var(--text-h); + margin: 0; + letter-spacing: -0.5px; +} + +.app-header p { + font-size: 0.9rem; + color: var(--text); + margin: 0.25rem 0 0; +} + +.app-content { + flex: 1; + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.25rem; + box-shadow: var(--shadow); +} + +.card-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 0.75rem; +} + +.sync-input-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sync-input-group input { + font-family: var(--mono); + font-size: 0.9rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text-h); + outline: none; + transition: border-color 0.2s; + width: 100%; +} + +.sync-input-group input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.sync-buttons { + display: flex; + gap: 0.5rem; +} + +.btn { + flex: 1; + font-family: var(--sans); + font-size: 0.9rem; + font-weight: 600; + padding: 0.65rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card-bg); + color: var(--text-h); + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} + +.btn:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.btn:active { + transform: scale(0.97); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-primary:hover { + filter: brightness(1.1); +} + +.status-bar { + text-align: center; + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + background: var(--accent-light); + color: var(--text); + margin-top: auto; +} + +.status-bar.error { + background: rgba(220, 38, 38, 0.1); + color: var(--error); +} + +.status-bar.success { + background: rgba(22, 163, 74, 0.1); + color: var(--success); +} + +.app-footer { + padding: 1rem 1.25rem; + text-align: center; + font-size: 0.75rem; + color: var(--text); + opacity: 0.6; +} + +.divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.25rem 0; +} + +.placeholder-area { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text); + opacity: 0.4; + font-size: 0.85rem; + gap: 0.5rem; + min-height: 100px; +} + +.word-list { + list-style: none; + padding: 0; + margin: 0; +} + +.word-item { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + box-shadow: var(--shadow); + transition: border-color 0.2s; +} + +.word-item:hover { + border-color: var(--accent); +} + +.word-item-empty { + align-items: center; + text-align: center; + color: var(--text); + opacity: 0.6; + font-size: 0.9rem; + padding: 24px 16px; +} + +.word-key { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-h); + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.word-value { + font-size: 0.95rem; + color: var(--text); + flex: 1; + text-align: right; +} + +.word-tags { + font-size: 0.7rem; + color: var(--accent); + background: var(--accent-light); + padding: 2px 8px; + border-radius: 4px; + font-weight: 500; + flex-basis: 100%; + order: 1; + align-self: flex-start; + margin-top: 2px; +} + +.placeholder-icon { + font-size: 2rem; + opacity: 0.6; +} + +/* ──── Навигация ──── */ + +.app-nav { + display: flex; + border-top: 1px solid var(--border); + background: var(--card-bg); + position: sticky; + bottom: 0; + z-index: 10; +} + +.nav-btn { + flex: 1; + font-family: var(--sans); + font-size: 0.85rem; + font-weight: 600; + padding: 0.75rem 0.5rem; + border: none; + background: transparent; + color: var(--text); + cursor: pointer; + transition: all 0.2s; +} + +.nav-btn.active { + color: var(--accent); + background: var(--accent-light); +} + +.nav-btn:hover { + color: var(--accent); +} + +/* ──── Страница колод ──── */ + +.sets-layout { + display: flex; + gap: 0; + flex: 1; + min-height: 0; +} + +.sets-list { + flex: 1; + list-style: none; + padding: 0; + margin: 0; + border-right: 1px solid var(--border); + overflow-y: auto; +} + +.sets-list li { + padding: 12px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.15s; + font-weight: 500; + color: var(--text-h); +} + +.sets-list li:last-child { + border-bottom: none; +} + +.sets-list li:hover { + background: var(--accent-light); +} + +.sets-list li.selected { + background: var(--accent-light); + color: var(--accent); +} + +.sets-detail { + flex: 1; + padding: 14px; + overflow-y: auto; +} + +.sets-detail h3 { + margin: 0 0 12px; + font-size: 1rem; + color: var(--text-h); +} + +.sets-detail p { + margin: 0 0 6px; + font-size: 0.85rem; + color: var(--text); +} + +.sets-detail .mode-select { + width: 100%; + margin: 10px 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text-h); + font-size: 0.9rem; +} + +.sets-detail .btn { + width: 100%; + margin-top: 8px; +} + +@media (max-width: 400px) { + .sets-layout { + flex-direction: column; + } + .sets-list { + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 200px; + } +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..cdcb9fe --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,96 @@ +import { init, compress, decompress } from '@bokuweb/zstd-wasm'; + +/** + * Загружает (upload) базу данных на сервер. + * + * @param id - Идентификатор для синхронизации. + * @param apiBaseUrl - Базовый URL API (без слеша на конце). + * @param closeDb - Функция для закрытия соединения с БД. + * @param getDbBuffer - Функция, возвращающая ArrayBuffer с бинарным файлом БД. + */ +export async function uploadDatabase( + id: string, + apiBaseUrl: string, + closeDb: () => Promise, + getDbBuffer: () => Promise, +): Promise { + try { + // 1. Закрываем соединение, чтобы снять блокировку файла OPFS + await closeDb(); + + // 2. Получаем бинарный файл БД + const dbBuffer = await getDbBuffer(); + const plainBytes = new Uint8Array(dbBuffer); + + // 3. Сжимаем zstd + await init(); + const compressed = compress(plainBytes); + + // 4. Создаём FormData и добавляем сжатый файл + const formData = new FormData(); + const blob = new Blob([compressed.buffer as ArrayBuffer], { type: 'application/octet-stream' }); + formData.append('db', blob, 'database.sqlite.zst'); + + // 5. POST-запрос + const url = `${apiBaseUrl.replace(/\/+$/, '')}/upload/${encodeURIComponent(id)}`; + const response = await fetch(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Ошибка загрузки: сервер ответил ${response.status} ${response.statusText}`); + } + + console.log('[Sync] База данных успешно загружена.'); + } catch (err) { + console.error('[Sync] Ошибка загрузки:', err); + alert(`Ошибка загрузки: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } +} + +/** + * Скачивает (download) базу данных с сервера. + * + * @param id - Идентификатор для синхронизации. + * @param apiBaseUrl - Базовый URL API (без слеша на конце). + * @param replaceDbBuffer - Функция, перезаписывающая файл БД новым ArrayBuffer. + * @param initDb - Функция для повторной инициализации соединения с БД. + */ +export async function downloadDatabase( + id: string, + apiBaseUrl: string, + replaceDbBuffer: (buffer: ArrayBuffer) => Promise, + initDb: () => Promise, +): Promise { + try { + // 1. GET-запрос + const url = `${apiBaseUrl.replace(/\/+$/, '')}/download/${encodeURIComponent(id)}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Ошибка скачивания: сервер ответил ${response.status} ${response.statusText}`); + } + + // 2. Получаем сжатые данные + const compressedBuffer = await response.arrayBuffer(); + const compressedBytes = new Uint8Array(compressedBuffer); + + // 3. Распаковываем zstd + await init(); + const decompressedBytes = decompress(compressedBytes); + + // 4. Перезаписываем файл в OPFS + await replaceDbBuffer(decompressedBytes.buffer as ArrayBuffer); + + // 5. Переинициализируем соединение с БД + await initDb(); + + console.log('[Sync] База данных успешно скачана и применена.'); + } catch (err) { + console.error('[Sync] Ошибка скачивания:', err); + alert(`Ошибка скачивания: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } +} \ No newline at end of file diff --git a/src/types/wa-sqlite.d.ts b/src/types/wa-sqlite.d.ts new file mode 100644 index 0000000..7f60647 --- /dev/null +++ b/src/types/wa-sqlite.d.ts @@ -0,0 +1,48 @@ +declare module 'wa-sqlite/dist/wa-sqlite-async.mjs' { + export default function ModuleConstructor(options?: any): Promise; +} + +declare module 'wa-sqlite/src/sqlite-api.js' { + export interface SQLiteAPI { + vfs_register(vfs: any, makeDefault: boolean): number; + open_v2( + filename: string, + flags: number, + vfs?: string, + ): Promise; + close(db: number): Promise; + exec( + db: number, + sql: string, + callback?: (row: any, columns: string[]) => void, + ): Promise; + execWithParams( + db: number, + sql: string, + params?: any[] | Record, + ): Promise<{ rows: any[]; columns: string[] }>; + run( + db: number, + sql: string, + params?: any[] | Record, + ): Promise; + } + + export function Factory(Module: any): SQLiteAPI; + + export class SQLiteError extends Error { + code: number; + } + + export const SQLITE_OPEN_READWRITE: number; + export const SQLITE_OPEN_CREATE: number; +} + +declare module 'wa-sqlite/src/examples/OriginPrivateFileSystemVFS.js' { + class OriginPrivateFileSystemVFS { + constructor(); + close(): Promise; + name: string; + } + export { OriginPrivateFileSystemVFS }; +} \ No newline at end of file diff --git a/src/types/worker-import.d.ts b/src/types/worker-import.d.ts new file mode 100644 index 0000000..bd8439a --- /dev/null +++ b/src/types/worker-import.d.ts @@ -0,0 +1,4 @@ +declare module '*?worker' { + const workerConstructor: { new (): Worker }; + export default workerConstructor; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ab38c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..cecf660 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + target: 'esnext', + }, + optimizeDeps: { + exclude: ['wa-sqlite'], + }, + assetsInclude: ['**/*.wasm'], + server: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + }, +}); \ No newline at end of file