Compare commits

...

28 commits

Author SHA1 Message Date
Michael Zhang 561cb49fbf review works! 2023-06-11 15:08:20 -05:00
Michael Zhang a1907591c6 commit reviews to database 2023-06-11 15:08:20 -05:00
Michael Zhang 6c0f35bf01 update 2023-06-11 15:08:19 -05:00
Michael Zhang 492215d17c add incorrect items to the end of the queue 2023-06-11 15:08:19 -05:00
Michael Zhang f87230596d wrong answer 2023-06-11 15:08:19 -05:00
Michael Zhang 7f53fb3d27 kana input 2023-06-11 15:08:19 -05:00
Michael Zhang 8a6dc9e339 remove modal 2023-06-11 15:08:18 -05:00
Michael Zhang 56146a7d99 dynamic imports 2023-06-11 15:08:18 -05:00
Michael Zhang 7ecabbe72f split into meanings / reading 2023-06-11 15:08:18 -05:00
Michael Zhang fb9d068bd0 more formatting on the review page 2023-06-11 15:08:18 -05:00
Michael Zhang b34bfee629 reviews ui 2023-06-11 15:08:18 -05:00
Michael Zhang 16dfeca43c infinite scroll 2023-06-11 15:08:18 -05:00
Michael Zhang cb5340da17 sigh 2023-06-11 15:08:17 -05:00
Michael Zhang 31728966c5 add stuff to srs 2023-06-11 15:08:17 -05:00
Michael Zhang 1fe57e2bb6 multiple meanings 2023-06-11 15:08:17 -05:00
Michael Zhang aae088ddb9 stats 2023-06-11 15:08:17 -05:00
Michael Zhang 3080602727 stats board 2023-06-11 15:08:16 -05:00
Michael Zhang afd50c1f0e tray 2023-06-11 15:08:16 -05:00
Michael Zhang ace93534ad update 2023-06-11 15:08:16 -05:00
Michael Zhang d369118da3 link 2023-06-11 15:08:16 -05:00
Michael Zhang 4e21a8ee92 fix 2023-06-11 15:08:16 -05:00
Michael Zhang 4ecf26f4b2 still doesn't work 2023-06-11 15:08:15 -05:00
Michael Zhang 15cbfeb0d6 upd 2023-06-11 15:08:15 -05:00
Michael Zhang 779fc69405 fetch 2023-06-11 15:08:15 -05:00
Michael Zhang a08636b3ee kradfile utf8 2023-06-11 15:08:15 -05:00
Michael Zhang 6efeb0498c upd 2023-06-11 15:08:15 -05:00
Michael Zhang defee22098 commit kanj database 2023-06-11 15:07:08 -05:00
Michael Zhang 439c0a4c31 db 2023-06-11 15:07:06 -05:00
59 changed files with 22187 additions and 233 deletions

4
.envrc Normal file
View file

@ -0,0 +1,4 @@
strict_env
export HOUHOU_SRC=$PWD
export DATABASE_URL=$HOUHOU_SRC/src-tauri/SrsDatabase.sqlite

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
houhou.db
src/data/kanadata.json

2
.prettierignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
src-tauri

7
.prettierrc.json5 Normal file
View file

@ -0,0 +1,7 @@
{
useTabs: false,
tabWidth: 2,
singleQuote: false,
trailingComma: "all",
printWidth: 100,
}

1
.tokeignore Normal file
View file

@ -0,0 +1 @@
package-lock.json

2
CREDITS.md Normal file
View file

@ -0,0 +1,2 @@
- [Houhou SRS](http://houhou-srs.com) ([CCPL](https://github.com/Doublevil/Houhou-SRS/blob/master/LICENSE.md))
- [KRADFILE](https://www.edrdg.org/krad/kradinf.html) ([EDRDG](http://www.edrdg.org/edrdg/licence.html))

5140
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "houhou",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,20 +10,38 @@
"tauri": "tauri"
},
"dependencies": {
"@chakra-ui/icons": "^2.0.19",
"@chakra-ui/react": "^2.7.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@tauri-apps/api": "^1.3.0",
"classnames": "^2.3.2",
"date-fns": "^2.30.0",
"flowbite": "^1.6.5",
"framer-motion": "^10.12.16",
"lodash-es": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.11.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.11.2",
"react-timeago": "^7.1.0",
"swr": "^2.1.5"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.7.10",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/react-timeago": "^4.1.3",
"@vitejs/plugin-react": "^3.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"prettier": "^2.8.8",
"sass": "^1.62.1",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.2.1"
}
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

4
rustfmt.toml Normal file
View file

@ -0,0 +1,4 @@
max_width = 80
tab_spaces = 2
wrap_comments = true
fn_single_line = true

View file

@ -2,3 +2,7 @@
# will have compiled files and executables
/target/
*-shm
*-wal
SrsDatabase.sqlite

1249
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,28 @@
[package]
name = "houhou"
version = "0.0.0"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace]
members = ["database-maker"]
[build-dependencies]
tauri-build = { version = "1.3", features = [] }
[dependencies]
tauri = { version = "1.3", features = ["shell-open"] }
tauri = { version = "1.3", features = ["notification-all", "shell-open", "system-tray"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "5.0.1"
anyhow = "1.0.71"
clap = { version = "4.3.2", features = ["derive"] }
sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "sqlite"] }
tokio = { version = "1.28.2", features = ["full"] }
derivative = "2.2.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem

Binary file not shown.

6462
src-tauri/data/kradfile Normal file

File diff suppressed because it is too large Load diff

6455
src-tauri/data/kradfile.utf8 Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
[package]
name = "database-maker"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.71"
clap = { version = "4.3.2", features = ["derive"] }
sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "sqlite"] }
tokio = { version = "1.28.2", features = ["full"] }

View file

@ -0,0 +1,44 @@
use std::path::Path;
use anyhow::Result;
use sqlx::SqlitePool;
use tokio::{
fs::File,
io::{AsyncBufReadExt, BufReader},
};
const SEPARATOR: char = ':';
pub async fn process_kradfile(
pool: &SqlitePool,
path: impl AsRef<Path>,
) -> Result<()> {
let file = File::open(path.as_ref()).await?;
let file_reader = BufReader::new(file);
let mut lines = file_reader.lines();
loop {
let line = match lines.next_line().await? {
Some(v) => v,
None => break,
};
// Skip comments
if line.starts_with('#') {
continue;
}
let parts = line.split(SEPARATOR).collect::<Vec<_>>();
let (kanji, radicals) = match &parts[..] {
&[kanji, radicals] => {
let kanji = kanji.trim();
let radicals = radicals.trim().split_whitespace().collect::<Vec<_>>();
(kanji, radicals)
}
_ => continue,
};
}
Ok(())
}

View file

@ -0,0 +1,35 @@
pub mod kradfile;
use std::{path::PathBuf, str::FromStr};
use anyhow::Result;
use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
#[derive(Debug, Parser)]
struct Opt {
in_dir: PathBuf,
out_file: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
let opt = Opt::parse();
// Open sqlite db
let uri = format!("sqlite:{}", opt.out_file.display());
println!("Opening {}...", uri);
let options = SqliteConnectOptions::from_str(&uri)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await?;
// Migrate that shit
sqlx::migrate!().run(&pool).await?;
// Kradfile
kradfile::process_kradfile(&pool, opt.in_dir.join("kradfile.utf8")).await?;
Ok(())
}

View file

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS [SrsEntrySet] (
[ID] integer NOT NULL PRIMARY KEY AUTOINCREMENT,
[CreationDate] bigint NOT NULL DEFAULT CURRENT_TIMESTAMP,
[NextAnswerDate] bigint,
[Meanings] [nvarchar(300)] NOT NULL,
[Readings] [nvarchar(100)] NOT NULL,
[CurrentGrade] smallint NOT NULL DEFAULT 0,
[FailureCount] integer NOT NULL DEFAULT 0,
[SuccessCount] integer NOT NULL DEFAULT 0,
[AssociatedVocab] [nvarchar(100)],
[AssociatedKanji] [nvarchar(10)],
[MeaningNote] [nvarchar(1000)],
[ReadingNote] [nvarchar(1000)],
[SuspensionDate] bigint,
[Tags] [nvarchar(300)],
[LastUpdateDate] BIGINT,
[IsDeleted] BOOLEAN NOT NULL DEFAULT false,
[ServerId] integer
);

212
src-tauri/src/kanji.rs Normal file
View file

@ -0,0 +1,212 @@
use std::collections::HashMap;
use sqlx::{sqlite::SqliteRow, Encode, Row, SqlitePool, Type};
use tauri::State;
use crate::{
srs::SrsDb,
utils::{EpochMs, Ticks},
};
pub struct KanjiDb(pub SqlitePool);
#[derive(Debug, Derivative, Serialize, Deserialize)]
#[derivative(Default)]
pub struct GetKanjiOptions {
#[serde(default)]
character: Option<String>,
#[serde(default = "default_skip")]
#[derivative(Default(value = "0"))]
skip: u32,
#[serde(default = "default_how_many")]
#[derivative(Default(value = "40"))]
how_many: u32,
#[serde(default)]
include_srs_info: bool,
}
fn default_skip() -> u32 {
0
}
fn default_how_many() -> u32 {
40
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kanji {
character: String,
most_used_rank: u32,
meanings: Vec<KanjiMeaning>,
srs_info: Option<KanjiSrsInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KanjiMeaning {
id: u32,
meaning: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetKanjiResult {
count: u32,
kanji: Vec<Kanji>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KanjiSrsInfo {
id: u32,
current_grade: u32,
next_answer_date: EpochMs,
associated_kanji: String,
}
#[tauri::command]
pub async fn get_kanji(
kanji_db: State<'_, KanjiDb>,
srs_db: State<'_, SrsDb>,
options: Option<GetKanjiOptions>,
) -> Result<GetKanjiResult, String> {
let opts = options.unwrap_or_default();
let looking_for_character_clause = match opts.character {
None => String::new(),
Some(_) => format!("AND KanjiSet.Character = ?"),
};
let query_string = format!(
r#"
SELECT
Character,
KanjiMeaningSet.Meaning,
MostUsedRank,
KanjiMeaningSet.ID as KanjiMeaningID
FROM (
SELECT *
FROM KanjiSet
WHERE MostUsedRank IS NOT NULL
{looking_for_character_clause}
ORDER BY MostUsedRank LIMIT ?, ?
) as Kanji
JOIN KanjiMeaningSet ON Kanji.ID = KanjiMeaningSet.Kanji_ID
ORDER BY MostUsedRank, KanjiMeaningSet.ID
"#,
);
let mut query = sqlx::query(&query_string);
// Do all the binds
if let Some(character) = &opts.character {
query = query.bind(character.clone());
}
query = query.bind(opts.skip);
query = query.bind(opts.how_many);
let result = query
.fetch_all(&kanji_db.0)
.await
.map_err(|err| err.to_string())?;
// Look for SRS info
let mut srs_info_map = HashMap::new();
if opts.include_srs_info {
let looking_for_character_clause = match opts.character {
None => String::new(),
Some(_) => format!("AND AssociatedKanji = ?"),
};
let query_string = format!(
r#"
SELECT ID, NextAnswerDate, AssociatedKanji, CurrentGrade FROM SrsEntrySet
WHERE 1=1
{}
"#,
looking_for_character_clause
);
let mut query = sqlx::query(&query_string);
if let Some(character) = &opts.character {
query = query.bind(character.clone());
}
let result = query
.fetch_all(&srs_db.0)
.await
.map_err(|err| err.to_string())?;
for row in result {
let associated_kanji: String = match row.get("AssociatedKanji") {
Some(v) => v,
None => continue,
};
let id = row.get("ID");
let current_grade = row.get("CurrentGrade");
let next_answer_date: i64 = row.get("NextAnswerDate");
let next_answer_date = Ticks(next_answer_date).epoch_ms();
srs_info_map.insert(
associated_kanji.clone(),
KanjiSrsInfo {
id,
current_grade,
next_answer_date,
associated_kanji,
},
);
}
};
// Put it all together
let kanji = {
let mut new_vec: Vec<Kanji> = Vec::with_capacity(result.len());
let mut last_character: Option<String> = None;
for row in result {
let character: String = row.get("Character");
let most_used_rank = row.get("MostUsedRank");
let meaning = KanjiMeaning {
id: row.get("KanjiMeaningID"),
meaning: row.get("Meaning"),
};
let same_as = match last_character {
Some(ref last_character) if character == *last_character => {
Some(new_vec.last_mut().unwrap())
}
Some(_) => None,
None => None,
};
last_character = Some(character.clone());
if let Some(kanji) = same_as {
kanji.meanings.push(meaning);
} else {
let srs_info = srs_info_map.remove(&character);
new_vec.push(Kanji {
character,
most_used_rank,
meanings: vec![meaning],
srs_info,
});
}
}
new_vec
};
let count = sqlx::query("SELECT COUNT(*) FROM KanjiSet")
.fetch_one(&kanji_db.0)
.await
.map_err(|err| err.to_string())?;
let count = count.try_get(0).map_err(|err| err.to_string())?;
Ok(GetKanjiResult { kanji, count })
}

View file

@ -1,15 +1,90 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[macro_use]
extern crate derivative;
#[macro_use]
extern crate serde;
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
pub mod kanji;
pub mod srs;
pub mod utils;
use std::process;
use std::str::FromStr;
use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tauri::{
CustomMenuItem, SystemTray, SystemTrayEvent, SystemTrayMenu,
SystemTrayMenuItem, WindowEvent,
};
use tokio::fs;
use crate::kanji::KanjiDb;
use crate::srs::SrsDb;
#[tokio::main]
async fn main() -> Result<()> {
let app_dir = dirs::config_dir().unwrap().join("houhou");
fs::create_dir_all(&app_dir).await?;
// Open kanji db
let kanji_db_options =
SqliteConnectOptions::from_str("./KanjiDatabase.sqlite")?.read_only(true);
let kanji_pool = SqlitePoolOptions::new()
.connect_with(kanji_db_options)
.await?;
// Open SRS db
let srs_db_path = app_dir.join("srs_db.sqlite");
let srs_db_options =
SqliteConnectOptions::from_str(&srs_db_path.display().to_string())?
.create_if_missing(true);
println!("Opening srs database at {} ...", srs_db_path.display());
let srs_pool = SqlitePoolOptions::new()
.connect_with(srs_db_options)
.await?;
println!("Running migrations...");
sqlx::migrate!().run(&srs_pool).await?;
// System tray
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let tray_menu = SystemTrayMenu::new().add_item(quit);
let tray = SystemTray::new()
.with_tooltip("Houhou")
.with_menu(tray_menu);
// Build tauri
tauri::Builder::default()
.manage(KanjiDb(kanji_pool))
.manage(SrsDb(srs_pool))
.system_tray(tray)
.invoke_handler(tauri::generate_handler![
srs::get_srs_stats,
srs::add_srs_item,
srs::generate_review_batch,
srs::update_srs_item,
kanji::get_kanji,
])
.on_window_event(|event| match event.event() {
WindowEvent::CloseRequested { api, .. } => {
event.window().hide().unwrap();
api.prevent_close();
}
_ => {}
})
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"quit" => {
process::exit(0);
}
_ => {}
},
_ => {}
})
.run(tauri::generate_context!())
.context("error while running tauri application")?;
Ok(())
}

255
src-tauri/src/srs.rs Normal file
View file

@ -0,0 +1,255 @@
use std::time::Duration;
use sqlx::{Row, SqlitePool};
use tauri::State;
use crate::{
kanji::KanjiDb,
utils::{Ticks, TICK_MULTIPLIER},
};
pub struct SrsDb(pub SqlitePool);
#[derive(Debug, Serialize, Deserialize)]
pub struct SrsStats {
reviews_available: u32,
reviews_today: u32,
total_items: u32,
total_reviews: u32,
/// Used to calculate average success
num_success: u32,
num_failure: u32,
}
#[tauri::command]
pub async fn get_srs_stats(db: State<'_, SrsDb>) -> Result<SrsStats, String> {
// counts query
let row = sqlx::query(
r#"
SELECT
COUNT(*) AS total_items,
SUM(SuccessCount) AS num_success,
SUM(FailureCount) AS num_failure
FROM SrsEntrySet
"#,
)
.fetch_one(&db.0)
.await
.map_err(|err| err.to_string())?;
// reviews query
let utc_now = Ticks::now().map_err(|err| err.to_string())?;
let utc_tomorrow = utc_now + Duration::from_secs(60 * 60 * 24);
let row2 = sqlx::query(
r#"
SELECT COUNT(*) AS reviews
FROM SrsEntrySet
WHERE
SuspensionDate IS NULL
AND NextAnswerDate <= ?
UNION ALL
SELECT COUNT(*) AS reviews
FROM SrsEntrySet
WHERE
SuspensionDate IS NULL
AND NextAnswerDate <= ?
"#,
)
.bind(&utc_now)
.bind(&utc_tomorrow)
.fetch_all(&db.0)
.await
.map_err(|err| err.to_string())?;
Ok(SrsStats {
reviews_available: row2[0].get("reviews"),
reviews_today: row2[1].get("reviews"),
total_items: row.try_get("total_items").unwrap_or(0),
total_reviews: 0,
num_success: row.try_get("num_success").unwrap_or(0),
num_failure: row.try_get("num_failure").unwrap_or(0),
})
}
#[derive(Debug, Derivative, Serialize, Deserialize)]
#[derivative(Default)]
pub struct AddSrsItemOptions {
character: String,
}
#[tauri::command]
pub async fn add_srs_item(
kanji_db: State<'_, KanjiDb>,
srs_db: State<'_, SrsDb>,
options: AddSrsItemOptions,
) -> Result<(), String> {
// Fetch meanings
let rows = sqlx::query(
r#"
SELECT Meaning
FROM KanjiMeaningSet
JOIN KanjiSet ON KanjiMeaningSet.Kanji_ID = KanjiSet.ID
WHERE KanjiSet.Character = ?
"#,
)
.bind(&options.character)
.fetch_all(&kanji_db.0)
.await
.map_err(|err| err.to_string())?;
let meanings = rows
.into_iter()
.map(|row| row.get::<String, _>("Meaning").to_owned())
.collect::<Vec<_>>();
let meanings = meanings.join(",");
println!("meanings: {:?}", meanings);
// Fetch readings
let row = sqlx::query(
"SELECT OnYomi, KunYomi, Nanori FROM KanjiSet WHERE Character = ?",
)
.bind(&options.character)
.fetch_one(&kanji_db.0)
.await
.map_err(|err| err.to_string())?;
let onyomi_reading: String = row.get("OnYomi");
let kunyomi_reading: String = row.get("KunYomi");
let nanori_reading: String = row.get("Nanori");
let readings = onyomi_reading
.split(",")
.chain(kunyomi_reading.split(","))
.chain(nanori_reading.split(","))
.collect::<Vec<_>>();
let readings = readings.join(",");
println!("readings: {:?}", readings);
let query_string = format!(
r#"
INSERT INTO SrsEntrySet
(CreationDate, AssociatedKanji, NextAnswerDate,
Meanings, Readings)
VALUES
(?, ?, ?, ?, ?)
"#,
);
let utc_now = Ticks::now().map_err(|err| err.to_string())?;
let query = sqlx::query(&query_string)
.bind(&utc_now)
.bind(&options.character)
.bind(&utc_now)
.bind(&meanings)
.bind(&readings);
query
.execute(&srs_db.0)
.await
.map_err(|err| err.to_string())?;
Ok(())
}
#[derive(Debug, Derivative, Serialize, Deserialize)]
#[derivative(Default)]
pub struct GenerateReviewBatchOptions {
#[serde(default = "default_batch_size")]
#[derivative(Default(value = "10"))]
batch_size: u32,
}
fn default_batch_size() -> u32 {
10
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SrsEntry {
id: u32,
current_grade: u32,
meanings: Vec<String>,
readings: Vec<String>,
associated_kanji: String,
}
#[tauri::command]
pub async fn generate_review_batch(
srs_db: State<'_, SrsDb>,
options: Option<GenerateReviewBatchOptions>,
) -> Result<Vec<SrsEntry>, String> {
let opts = options.unwrap_or_default();
let result = sqlx::query(
r#"
SELECT * FROM SrsEntrySet
WHERE AssociatedKanji IS NOT NULL
AND CurrentGrade < 8
ORDER BY RANDOM()
LIMIT ?
"#,
)
.bind(opts.batch_size)
.fetch_all(&srs_db.0)
.await
.map_err(|err| err.to_string())?;
let result = result
.into_iter()
.map(|row| {
let id = row.get("ID");
let meanings: String = row.get("Meanings");
let meanings = meanings.split(",").map(|s| s.to_owned()).collect();
let readings: String = row.get("Readings");
let readings = readings.split(",").map(|s| s.to_owned()).collect();
SrsEntry {
id,
current_grade: row.get("CurrentGrade"),
meanings,
readings,
associated_kanji: row.get("AssociatedKanji"),
}
})
.collect();
Ok(result)
}
#[tauri::command]
pub async fn update_srs_item(
srs_db: State<'_, SrsDb>,
item_id: u32,
delay: i64,
new_grade: u32,
correct: bool,
) -> Result<(), String> {
let (success, failure) = match correct {
true => (1, 0),
false => (0, 1),
};
// Kanji.Interface/ViewModels/Partial/Srs/SrsReviewViewModel.cs:600
sqlx::query(
r#"
UPDATE SrsEntrySet
SET
SuccessCount = SuccessCount + ?,
FailureCount = FailureCount + ?,
NextAnswerDate = NextAnswerDate + ?,
CurrentGrade = ?
WHERE ID = ?
"#,
)
.bind(success)
.bind(failure)
.bind(delay * TICK_MULTIPLIER)
.bind(new_grade)
.bind(item_id)
.execute(&srs_db.0)
.await
.map_err(|err| err.to_string())?;
Ok(())
}

68
src-tauri/src/utils.rs Normal file
View file

@ -0,0 +1,68 @@
use std::{
ops::Add,
time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH},
};
use sqlx::{
database::{HasArguments, HasValueRef},
encode::IsNull,
error::BoxDynError,
Decode, Encode, Sqlite, Type,
};
pub const TICK_MULTIPLIER: i64 = 1_000_000_000;
#[derive(Clone, Copy)]
pub struct Ticks(pub i64);
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EpochMs(pub u64);
impl Ticks {
pub fn now() -> Result<Self, SystemTimeError> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.into())
}
#[inline]
pub fn epoch_ms(&self) -> EpochMs {
let millis = Duration::from_nanos(self.0 as u64).as_millis() as u64;
EpochMs(millis)
}
}
impl Into<Duration> for Ticks {
fn into(self) -> Duration {
Duration::from_nanos(self.0 as u64)
}
}
impl From<Duration> for Ticks {
fn from(value: Duration) -> Self {
Ticks(value.as_secs() as i64 * TICK_MULTIPLIER)
}
}
impl<'q> Encode<'q, Sqlite> for Ticks {
fn encode_by_ref(
&self,
buf: &mut <Sqlite as HasArguments<'q>>::ArgumentBuffer,
) -> IsNull {
self.0.encode_by_ref(buf)
}
}
impl Type<Sqlite> for Ticks {
fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
i64::type_info()
}
}
impl Add<Duration> for Ticks {
type Output = Ticks;
fn add(self, rhs: Duration) -> Self::Output {
let rhs_ticks = Ticks::from(rhs);
Ticks(self.0 + rhs_ticks.0)
}
}

View file

@ -8,7 +8,7 @@
},
"package": {
"productName": "houhou",
"version": "0.0.0"
"version": "0.1.0"
},
"tauri": {
"allowlist": {
@ -16,12 +16,15 @@
"shell": {
"all": false,
"open": true
},
"notification": {
"all": true
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.tauri.dev",
"identifier": "io.mzhang.houhou",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -33,6 +36,10 @@
"security": {
"csp": null
},
"systemTray": {
"iconPath": "icons/icon.ico",
"iconAsTemplate": true
},
"windows": [
{
"fullscreen": false,

View file

@ -1,3 +1,14 @@
$navLinkAccentColor: #09c;
$navLinkColor: hsl(203, 91%, 91%);
.main {
min-height: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
display: flex;
flex-direction: row;
@ -6,6 +17,38 @@
list-style-type: none;
li {
padding-left: 0;
flex-basis: 0;
flex-grow: 1;
}
}
}
.body {
min-height: 0;
flex-grow: 1;
display: flex;
}
.link {
display: inline-block;
width: 100%;
text-align: center;
padding: 12px;
border-top: 4px solid transparent;
transition: background-color 0.1s ease-out, border-top-color 0.1s ease-out;
// Make the top bar seem less web-ish
user-select: none;
cursor: default;
-webkit-user-drag: none;
&:not(.link-active):hover {
border-top-color: #f4f4f4;
background: linear-gradient(#f4f4f4, transparent);
}
}
.link-active {
border-top-color: $navLinkAccentColor;
// background-color: $navLinkColor;
background: linear-gradient($navLinkColor, transparent);
}

View file

@ -1,27 +1,117 @@
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import styles from "./App.module.scss"
import KanjiPane from "./panes/KanjiPane";
import HomePane from "./panes/HomePane";
import { Link, RouterProvider, createHashRouter } from "react-router-dom";
import classNames from "classnames";
import { ChakraProvider, Flex } from "@chakra-ui/react";
import { createBrowserRouter } from "react-router-dom";
import { Outlet, Route, createRoutesFromElements, matchPath, useLocation } from "react-router";
import { StrictMode } from "react";
export default function App() {
const router = createBrowserRouter(routes);
import styles from "./App.module.scss";
function Layout() {
const location = useLocation();
return (
<>
<Flex className={styles.main} direction="column" alignSelf="start">
<ul className={styles.header}>
<li><a href="/">Home</a></li>
<li><a href="/srs">Srs</a></li>
<li><a href="/kanji">Kanji</a></li>
<li><a href="/vocab">Vocab</a></li>
<li><a href="/settings">Settings</a></li>
{navLinks.map((navLink: NavLink) => {
const active = (
navLink.subPaths ? navLink.subPaths : [{ key: navLink.key, path: navLink.path }]
).some((item) => matchPath({ path: item.path }, location.pathname));
const mainPath = navLink.subPaths ? navLink.subPaths[0].path : navLink.path;
const className = classNames(styles.link, active && styles["link-active"]);
return (
<li key={`navLink-${navLink.key}`}>
<Link to={mainPath} className={className}>
{navLink.title}
</Link>
</li>
);
})}
</ul>
<RouterProvider router={router} />
</>
<div className={styles.body}>
<Outlet />
</div>
</Flex>
);
}
const routes = [
{ path: "/", element: <HomePane /> },
{ path: "/kanji", element: <KanjiPane /> },
];
export default function App() {
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Layout />}>
{navLinks.flatMap((route, idx) => {
if (route.subPaths) {
return route.subPaths.map((subRoute, idx2) => {
return (
<Route
key={`route-${route.key}-${subRoute.key}`}
index={idx + idx2 == 0}
path={subRoute.path}
lazy={() => import(`./panes/${subRoute.element ?? route.element}.tsx`)}
/>
);
});
} else {
return (
<Route
key={`route-${route.key}`}
index={idx == 0}
path={route.path}
lazy={() => import(`./panes/${route.element}.tsx`)}
/>
);
}
})}
</Route>,
),
);
return (
<StrictMode>
<ChakraProvider>
<RouterProvider router={router} />
</ChakraProvider>
</StrictMode>
);
}
type NavLinkPath =
| {
path: string;
subPaths?: undefined;
}
| {
path?: undefined;
subPaths: { key: string; path: string; element?: string }[];
};
type NavLink = {
key: string;
title: string;
element: string;
} & NavLinkPath;
const navLinks: NavLink[] = [
// { key: "home", path: "/", title: "Home", element: <HomePane /> },
{
key: "srs",
title: "SRS",
element: "SrsPane",
subPaths: [
{ key: "index", path: "/" },
{ key: "review", path: "/srs/review", element: "SrsReviewPane" },
],
},
{
key: "kanji",
title: "Kanji",
element: "KanjiPane",
subPaths: [
{ key: "index", path: "/kanji" },
{ key: "selected", path: "/kanji/:selectedKanji" },
],
},
{ key: "vocab", path: "/vocab", title: "Vocab", element: "VocabPane" },
{ key: "settings", path: "/settings", title: "Settings", element: "SettingsPane" },
];

View file

@ -0,0 +1,10 @@
.reviews-stats {
background-color: gray;
color: white;
}
.reviews-available {
display: flex;
flex-direction: column;
text-align: left;
}

View file

@ -0,0 +1,82 @@
import { Button, Grid, GridItem, Stat, StatLabel, StatNumber, Tooltip } from "@chakra-ui/react";
import { ArrowRightIcon } from "@chakra-ui/icons";
import styles from "./DashboardReviewStats.module.scss";
import useSWR from "swr";
import { invoke } from "@tauri-apps/api/tauri";
import { Link } from "react-router-dom";
import ConditionalWrapper from "./utils/ConditionalWrapper";
interface SrsStats {
reviews_available: number;
reviews_today: number;
total_items: number;
total_reviews: number;
/// Used to calculate average success
num_success: number;
num_failure: number;
}
interface Stat {
label: string;
value: any;
}
export default function DashboardReviewStats() {
const {
data: srsStats,
error,
isLoading,
} = useSWR(["get_srs_stats"], ([command]) => invoke<SrsStats>(command));
if (!srsStats)
return (
<>
{JSON.stringify([srsStats, error, isLoading])}
Loading...
</>
);
const averageSuccess = srsStats.num_success / (srsStats.num_success + srsStats.num_failure || 1);
const averageSuccessStr = `${Math.round(averageSuccess * 10000) / 100}%`;
const canReview = srsStats.reviews_available == 0;
const generateStat = (stat: Stat) => {
return (
<GridItem>
<Stat>
<StatLabel>{stat.label}</StatLabel>
<StatNumber>{stat.value}</StatNumber>
</Stat>
</GridItem>
);
};
return (
<Grid templateColumns="2fr 1fr 1fr" templateRows="1fr 1fr" gap={4}>
<GridItem rowSpan={2} className={styles["reviews-available"]}>
<Stat>
<StatLabel>reviews available</StatLabel>
<StatNumber>{srsStats.reviews_available}</StatNumber>
</Stat>
<ConditionalWrapper
condition={canReview}
wrapper={(children) => <Tooltip label="Add items to start reviewing">{children}</Tooltip>}
elseWrapper={(children) => <Link to="/srs/review">{children}</Link>}
>
<Button isDisabled={canReview} colorScheme="blue">
Start reviewing <ArrowRightIcon marginLeft={3} />
</Button>
</ConditionalWrapper>
</GridItem>
{generateStat({ label: "reviews available", value: srsStats.reviews_available })}
{generateStat({ label: "reviews today", value: srsStats.reviews_today })}
{generateStat({ label: "total items", value: srsStats.total_items })}
{generateStat({ label: "average success", value: averageSuccessStr })}
</Grid>
);
}

View file

@ -0,0 +1,11 @@
$kanjiDisplaySize: 80px;
.display {
border: 1px solid rgb(87, 87, 210);
width: $kanjiDisplaySize;
height: $kanjiDisplaySize;
line-height: $kanjiDisplaySize;
font-size: $kanjiDisplaySize * 0.8;
text-align: center;
vertical-align: middle;
}

View file

@ -0,0 +1,80 @@
import { invoke } from "@tauri-apps/api/tauri";
import { GetKanjiResult } from "../panes/KanjiPane";
import TimeAgo from "react-timeago";
import styles from "./KanjiDisplay.module.scss";
import useSWR from "swr";
import { Button } from "@chakra-ui/button";
import { AddIcon } from "@chakra-ui/icons";
import SelectOnClick from "./utils/SelectOnClick";
import { Alert, AlertIcon } from "@chakra-ui/alert";
import { isValid } from "date-fns";
interface KanjiDisplayProps {
kanjiCharacter: string;
}
export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) {
const {
data: kanjiResult,
error,
isLoading,
mutate,
} = useSWR(["get_kanji", kanjiCharacter], ([command, character]) =>
invoke<GetKanjiResult>(command, { options: { character, include_srs_info: true } }),
);
if (!kanjiResult || !kanjiResult.kanji)
return (
<>
{JSON.stringify([kanjiResult, error, isLoading])}
Loading...
</>
);
const kanji = kanjiResult.kanji[0];
const addSrsItem = async () => {
await invoke("add_srs_item", {
options: {
character: kanji.character,
},
});
mutate();
};
let srsPart = (
<Button onClick={addSrsItem} colorScheme="green">
<AddIcon /> Add to SRS
</Button>
);
if (kanji.srs_info) {
const nextAnswerDate = new Date(kanji.srs_info.next_answer_date);
srsPart = (
<Alert status="info">
<AlertIcon />
<p>You are learning this item!</p>
{isValid(nextAnswerDate) && (
<p>
(Due: <TimeAgo date={nextAnswerDate} />)
</p>
)}
</Alert>
);
}
return (
<>
<details>
<summary>Debug</summary>
<pre>{JSON.stringify(kanji, null, 2)}</pre>
</details>
<SelectOnClick className={styles.display}>{kanji.character}</SelectOnClick>
{kanji.meanings.map((m) => m.meaning).join(", ")}
<div>{srsPart}</div>
</>
);
}

View file

@ -0,0 +1,70 @@
$kanjiCharacterSize: 28px;
.kanji-list {
display: inline-flex;
flex-direction: column;
gap: 2px;
min-height: 0;
}
.kanji-link {
padding: 4px 8px;
border-left: 4px solid transparent;
transition: background-color 0.1s ease-out, border-left-color 0.1s ease-out;
user-select: none;
-webkit-user-drag: none;
&:not(.kanji-link-active):hover {
border-top-color: transparent;
background-color: #f4f4f4;
}
}
.kanji-link-character {
font-size: $kanjiCharacterSize;
line-height: $kanjiCharacterSize;
vertical-align: middle;
text-align: center;
display: flex;
align-items: center;
}
.kanji-list-scroll {
direction: rtl;
overflow-y: scroll;
}
.kanji-list-inner {
display: flex;
flex-direction: column;
gap: 4px;
direction: ltr;
}
.kanji-link-active {
border-left-color: #09c;
background-color: hsl(203, 91%, 91%);
}
.loading {
padding: 4px 8px;
text-align: center;
}
.result-count {
padding: 4px 8px;
}
.search-container {
padding: 4px 8px;
}
.badges {
display: flex;
flex-direction: row;
gap: 6px;
align-items: flex-start;
}

View file

@ -0,0 +1,102 @@
import { GetKanjiResult } from "../panes/KanjiPane";
import classNames from "classnames";
import { Link } from "react-router-dom";
import { Badge, Grid, GridItem } from "@chakra-ui/layout";
import styles from "./KanjiList.module.scss";
import { Kanji } from "../lib/kanji";
import { Input, Spinner } from "@chakra-ui/react";
import { useCallback, useEffect, useState } from "react";
import SearchBar from "./SearchBar";
import LevelBadge from "./utils/LevelBadge";
export interface KanjiListProps {
kanjiList: Kanji[];
totalCount: number;
selectedCharacter?: string;
loadMoreKanji: () => void;
}
export function KanjiList({
kanjiList,
totalCount,
selectedCharacter,
loadMoreKanji,
}: KanjiListProps) {
// Set up intersection observer
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadingCanary, setLoadingCanary] = useState<HTMLDivElement | null>(null);
const loadingCanaryRef = useCallback(
(element: HTMLDivElement) => {
if (element) setLoadingCanary(element);
},
[setLoadingCanary],
);
// Infinite scroll
useEffect(() => {
if (loadingCanary && !isLoadingMore) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadMoreKanji();
setIsLoadingMore(true);
}
}
});
observer.observe(loadingCanary);
return () => {
observer.unobserve(loadingCanary);
};
}
}, [loadingCanary, isLoadingMore]);
useEffect(() => {
setIsLoadingMore(false);
}, [kanjiList]);
const renderKanjiItem = (kanji: Kanji, active: boolean) => {
const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]);
if (kanji.srs_info) console.log("kanji", kanji);
return (
<Link key={kanji.character} className={className} to={`/kanji/${kanji.character}`}>
<Grid templateRows="repeat(2, 1fr)" templateColumns="auto 1fr" columnGap={4}>
<GridItem rowSpan={2} className={styles["kanji-link-character"]}>
{kanji.character}
</GridItem>
<GridItem>{kanji.meanings[0].meaning}</GridItem>
<GridItem className={styles.badges}>
<LevelBadge grade={kanji.srs_info?.current_grade} />
<Badge>#{kanji.most_used_rank} common</Badge>
</GridItem>
</Grid>
</Link>
);
};
return (
<>
<div className={styles["search-container"]}>
<SearchBar />
</div>
<small className={styles["result-count"]}>
Displaying {kanjiList.length} of {totalCount} results.
</small>
<div className={styles["kanji-list-scroll"]}>
<div className={styles["kanji-list-inner"]}>
{kanjiList.map((kanji) => {
const active = kanji.character == selectedCharacter;
return renderKanjiItem(kanji, active);
})}
<div className={styles.loading} ref={loadingCanaryRef}>
<Spinner />
</div>
</div>
</div>
</>
);
}

View file

@ -1,3 +0,0 @@
export default function KanjiSearch() {
}

View file

@ -0,0 +1,23 @@
import { SearchIcon } from "@chakra-ui/icons";
import { Input, InputGroup, InputRightElement, Spinner } from "@chakra-ui/react";
import { FormEvent, useState } from "react";
export default function SearchBar() {
const [status, setStatus] = useState("idle");
const onSubmit = (evt: FormEvent) => {
evt.preventDefault();
setStatus("loading");
};
return (
<form onSubmit={onSubmit}>
<InputGroup>
<Input autoFocus placeholder="Search..." />
<InputRightElement>
{{ idle: <SearchIcon />, loading: <Spinner /> }[status]}
</InputRightElement>
</InputGroup>
</form>
);
}

View file

@ -0,0 +1,21 @@
export interface ConditionalWrapperProps<S, T> {
condition: boolean;
wrapper?: (_: S) => T;
elseWrapper?: (_: S) => T;
children: S;
}
export default function ConditionalWrapper<S, T>({
condition,
wrapper,
children,
elseWrapper,
}: ConditionalWrapperProps<S, T>) {
return condition
? wrapper
? wrapper(children)
: children
: elseWrapper
? elseWrapper(children)
: children;
}

View file

@ -0,0 +1,42 @@
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react";
import { Link } from "react-router-dom";
export interface ConfirmQuitModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function ConfirmQuitModal({ isOpen, onClose }: ConfirmQuitModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Confirm Quit</ModalHeader>
<ModalCloseButton />
<ModalBody>
Are you sure you want to go back? Your current progress into this batch will not be saved.
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Link to="/">
<Button colorScheme="red" mr={3}>
Close
</Button>
</Link>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,33 @@
import { Badge } from "@chakra-ui/react";
import { srsLevels } from "../../lib/srs";
export interface LevelBadgeProps {
grade?: number;
}
export default function LevelBadge({ grade }: LevelBadgeProps) {
if (grade == undefined) return null;
const levelInfo = srsLevels.get(grade);
if (!levelInfo) return null;
const { color, name } = levelInfo;
return (
<Badge backgroundColor={color} color="white">
{name}
</Badge>
);
}
const badgeMap = new Map<number, [string | JSX.Element, string]>([
[8, [<>&#9733;</>, "green"]],
[7, ["A2", "blue"]],
[6, ["A1", "blue"]],
[5, ["B2", "yellow"]],
[4, ["B1", "yellow"]],
[3, ["C2", "orange"]],
[2, ["C1", "orange"]],
[1, ["D2", "red"]],
[0, ["D1", "red"]],
]);

View file

@ -0,0 +1,30 @@
import { HTMLAttributes, PropsWithChildren, useCallback, useState } from "react";
export default function SelectOnClick({
children,
...props
}: PropsWithChildren<HTMLAttributes<HTMLDivElement>>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const containerRef = useCallback((element: HTMLDivElement | null) => {
if (element) setContainer(element);
}, []);
const onClick = useCallback(() => {
if (!container) return;
const range = document.createRange();
range.selectNodeContents(container);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
}, [container]);
return (
<div onClick={onClick} ref={containerRef} {...props}>
{children}
</div>
);
}

887
src/data/kanadata.json Normal file
View file

@ -0,0 +1,887 @@
{
"kanaToHiragana": [
["ッ", "っ"],
["チ", "ち"],
["シ", "し"],
["ツ", "つ"],
["ヅ", "づ"],
["ヂ", "ぢ"],
["ヮ", "ゎ"],
["ャ", "ゃ"],
["ィ", "ぃ"],
["ュ", "ゅ"],
["ェ", "ぇ"],
["ョ", "ょ"],
["カ", "か"],
["キ", "き"],
["ク", "く"],
["ケ", "け"],
["コ", "こ"],
["サ", "さ"],
["ス", "す"],
["セ", "せ"],
["ソ", "そ"],
["タ", "た"],
["テ", "て"],
["ト", "と"],
["ナ", "な"],
["ニ", "に"],
["ヌ", "ぬ"],
["ネ", "ね"],
["", "の"],
["ハ", "は"],
["ヒ", "ひ"],
["フ", "ふ"],
["ヘ", "へ"],
["ホ", "ほ"],
["マ", "ま"],
["ミ", "み"],
["ム", "む"],
["メ", "め"],
["モ", "も"],
["ヤ", "や"],
["ユ", "ゆ"],
["ヨ", "よ"],
["ラ", "ら"],
["リ", "り"],
["ル", "る"],
["レ", "れ"],
["ロ", "ろ"],
["ワ", "わ"],
["ヲ", "を"],
["ガ", "が"],
["ギ", "ぎ"],
["グ", "ぐ"],
["ゲ", "げ"],
["ゴ", "ご"],
["ジ", "じ"],
["ダ", "だ"],
["デ", "で"],
["ド", "ど"],
["バ", "ば"],
["ビ", "び"],
["ブ", "ぶ"],
["ベ", "べ"],
["ボ", "ぼ"],
["パ", "ぱ"],
["ピ", "ぴ"],
["プ", "ぷ"],
["ペ", "ぺ"],
["ポ", "ぽ"],
["ザ", "ざ"],
["ズ", "ず"],
["ゼ", "ぜ"],
["ゾ", "ぞ"],
["ァ", "ぁ"],
["ィ", "ぃ"],
["ゥ", "ぅ"],
["ェ", "ぇ"],
["ォ", "ぉ"],
["ン", "ん"],
["ア", "あ"],
["イ", "い"],
["ウ", "う"],
["エ", "え"],
["オ", "お"]
],
"katakanaToHiragana": [
["っ", "ッ"],
["ち", "チ"],
["し", "シ"],
["つ", "ツ"],
["づ", "ヅ"],
["ぢ", "ヂ"],
["ゎ", "ヮ"],
["ゃ", "ャ"],
["ぃ", "ィ"],
["ゅ", "ュ"],
["ぇ", "ェ"],
["ょ", "ョ"],
["か", "カ"],
["き", "キ"],
["く", "ク"],
["け", "ケ"],
["こ", "コ"],
["さ", "サ"],
["す", "ス"],
["せ", "セ"],
["そ", "ソ"],
["た", "タ"],
["て", "テ"],
["と", "ト"],
["な", "ナ"],
["に", "ニ"],
["ぬ", "ヌ"],
["ね", "ネ"],
["の", ""],
["は", "ハ"],
["ひ", "ヒ"],
["ふ", "フ"],
["へ", "ヘ"],
["ほ", "ホ"],
["ま", "マ"],
["み", "ミ"],
["む", "ム"],
["め", "メ"],
["も", "モ"],
["や", "ヤ"],
["ゆ", "ユ"],
["よ", "ヨ"],
["ら", "ラ"],
["り", "リ"],
["る", "ル"],
["れ", "レ"],
["ろ", "ロ"],
["わ", "ワ"],
["を", "ヲ"],
["が", "ガ"],
["ぎ", "ギ"],
["ぐ", "グ"],
["げ", "ゲ"],
["ご", "ゴ"],
["じ", "ジ"],
["だ", "ダ"],
["で", "デ"],
["ど", "ド"],
["ば", "バ"],
["び", "ビ"],
["ぶ", "ブ"],
["べ", "ベ"],
["ぼ", "ボ"],
["ぱ", "パ"],
["ぴ", "ピ"],
["ぷ", "プ"],
["ぺ", "ペ"],
["ぽ", "ポ"],
["ざ", "ザ"],
["ず", "ズ"],
["ぜ", "ゼ"],
["ぞ", "ゾ"],
["ぁ", "ァ"],
["ぃ", "ィ"],
["ぅ", "ゥ"],
["ぇ", "ェ"],
["ぉ", "ォ"],
["ん", "ン"],
["あ", "ア"],
["い", "イ"],
["う", "ウ"],
["え", "エ"],
["お", "オ"]
],
"kanaToRomaji": {
"2letter": [
["イェ", "YE"],
["いぇ", "ye"],
["ウィ", "WI"],
["うぃ", "wi"],
["ウェ", "WE"],
["うぇ", "we"],
["ヴァ", "WA"],
["ヴぁ", "wa"],
["ヴィ", "WI"],
["ヴぃ", "wi"],
["ヴェ", "WE"],
["ヴぇ", "we"],
["ヴォ", "WO"],
["ヴぉ", "wo"],
["ファ", "FA"],
["ふぁ", "fa"],
["フィ", "FI"],
["ふぃ", "fi"],
["フェ", "FE"],
["ふぇ", "fe"],
["フォ", "FO"],
["ふぉ", "fo"],
["チャ", "CHA"],
["ちゃ", "cha"],
["チュ", "CHU"],
["ちゅ", "chu"],
["チェ", "CHE"],
["ちぇ", "che"],
["チョ", "CHO"],
["ちょ", "cho"],
["シャ", "SHA"],
["しゃ", "sha"],
["シュ", "SHU"],
["しゅ", "shu"],
["シェ", "SHE"],
["しぇ", "she"],
["ショ", "SHO"],
["しょ", "sho"],
["リャ", "RYA"],
["りゃ", "rya"],
["リィ", "RYI"],
["りぃ", "ryi"],
["リュ", "RYU"],
["りゅ", "ryu"],
["リェ", "RYE"],
["りぇ", "rye"],
["リョ", "RYO"],
["りょ", "ryo"],
["ヒャ", "HYA"],
["ひゃ", "hya"],
["ヒィ", "HYI"],
["ひぃ", "hyi"],
["ヒュ", "HYU"],
["ひゅ", "hyu"],
["ヒェ", "HYE"],
["ひぇ", "hye"],
["ヒョ", "HYO"],
["ひょ", "hyo"],
["ビャ", "BYA"],
["びゃ", "bya"],
["ビィ", "BYI"],
["びぃ", "byi"],
["ビュ", "BYU"],
["びゅ", "byu"],
["ビェ", "BYE"],
["びぇ", "bye"],
["ビョ", "BYO"],
["びょ", "byo"],
["ピャ", "PYA"],
["ぴゃ", "pya"],
["ピィ", "PYI"],
["ぴぃ", "pyi"],
["ピュ", "PYU"],
["ぴゅ", "pyu"],
["ピェ", "PYE"],
["ぴぇ", "pye"],
["ピョ", "PYO"],
["ぴょ", "pyo"],
["ミャ", "MYA"],
["みゃ", "mya"],
["ミィ", "MYI"],
["みぃ", "myi"],
["ミュ", "MYU"],
["みゅ", "myu"],
["ミェ", "MYE"],
["みぇ", "mye"],
["ミョ", "MYO"],
["みょ", "myo"],
["キャ", "KYA"],
["きゃ", "kya"],
["キィ", "KYI"],
["きぃ", "kyi"],
["キュ", "KYU"],
["きゅ", "kyu"],
["キェ", "KYE"],
["きぇ", "kye"],
["キョ", "KYO"],
["きょ", "kyo"],
["ギャ", "GYA"],
["ぎゃ", "gya"],
["ギィ", "GYI"],
["ぎぃ", "gyi"],
["ギュ", "GYU"],
["ぎゅ", "gyu"],
["ギェ", "GYE"],
["ぎぇ", "gye"],
["ギョ", "GYO"],
["ぎょ", "gyo"],
["ニャ", "NYA"],
["にゃ", "nya"],
["ニィ", "NYI"],
["にぃ", "nyi"],
["ニュ", "NYU"],
["にゅ", "nyu"],
["ニェ", "NYE"],
["にぇ", "nye"],
["ニョ", "NYO"],
["にょ", "nyo"],
["ジャ", "JA"],
["じゃ", "ja"],
["ジィ", "JI"],
["じぃ", "ji"],
["ジュ", "JU"],
["じゅ", "ju"],
["ジェ", "JE"],
["じぇ", "je"],
["ジョ", "JO"],
["じょ", "jo"],
["ヂャ", "DYA"],
["ぢゃ", "dya"],
["ヂィ", "DYI"],
["ぢぃ", "dyi"],
["ヂュ", "DYU"],
["ぢゅ", "dyu"],
["ヂェ", "DYE"],
["ぢぇ", "dye"],
["ヂョ", "DYO"],
["ぢょ", "dyo"]
],
"1letter": [
["ア", "A"],
["あ", "a"],
["イ", "I"],
["い", "i"],
["ウ", "U"],
["う", "u"],
["エ", "E"],
["え", "e"],
["オ", "O"],
["お", "o"],
["カ", "KA"],
["か", "ka"],
["キ", "KI"],
["き", "ki"],
["ク", "KU"],
["く", "ku"],
["ケ", "KE"],
["け", "ke"],
["コ", "KO"],
["こ", "ko"],
["サ", "SA"],
["さ", "sa"],
["シ", "SI"],
["し", "si"],
["ス", "SU"],
["す", "su"],
["セ", "SE"],
["せ", "se"],
["ソ", "SO"],
["そ", "so"],
["タ", "TA"],
["た", "ta"],
["テ", "TE"],
["て", "te"],
["ト", "TO"],
["と", "to"],
["ナ", "NA"],
["な", "na"],
["ニ", "NI"],
["に", "ni"],
["ヌ", "NU"],
["ぬ", "nu"],
["ネ", "NE"],
["ね", "ne"],
["", "NO"],
["の", "no"],
["ハ", "HA"],
["は", "ha"],
["ヒ", "HI"],
["ひ", "hi"],
["フ", "HU"],
["ふ", "hu"],
["ヘ", "HE"],
["へ", "he"],
["ホ", "HO"],
["ほ", "ho"],
["マ", "MA"],
["ま", "ma"],
["ミ", "MI"],
["み", "mi"],
["ム", "MU"],
["む", "mu"],
["メ", "ME"],
["め", "me"],
["モ", "MO"],
["も", "mo"],
["ヤ", "YA"],
["や", "ya"],
["ユ", "YU"],
["ゆ", "yu"],
["ヨ", "YO"],
["よ", "yo"],
["ラ", "RA"],
["ら", "ra"],
["リ", "RI"],
["り", "ri"],
["ル", "RU"],
["る", "ru"],
["レ", "RE"],
["れ", "re"],
["ロ", "RO"],
["ろ", "ro"],
["ワ", "WA"],
["わ", "wa"],
["ヲ", "WO"],
["を", "wo"],
["ガ", "GA"],
["が", "ga"],
["ギ", "GI"],
["ぎ", "gi"],
["グ", "GU"],
["ぐ", "gu"],
["ゲ", "GE"],
["げ", "ge"],
["ゴ", "GO"],
["ご", "go"],
["チ", "CHI"],
["ち", "chi"],
["ジ", "JI"],
["じ", "ji"],
["ダ", "DA"],
["だ", "da"],
["ヂ", "DI"],
["ぢ", "di"],
["ヅ", "DU"],
["づ", "du"],
["デ", "DE"],
["で", "de"],
["ド", "DO"],
["ど", "do"],
["バ", "BA"],
["ば", "ba"],
["ビ", "BI"],
["び", "bi"],
["ブ", "BU"],
["ぶ", "bu"],
["ベ", "BE"],
["べ", "be"],
["ボ", "BO"],
["ぼ", "bo"],
["ヴ", "VU"],
["ヴ", "vu"],
["パ", "PA"],
["ぱ", "pa"],
["ピ", "PI"],
["ぴ", "pi"],
["プ", "PU"],
["ぷ", "pu"],
["ペ", "PE"],
["ぺ", "pe"],
["ポ", "PO"],
["ぽ", "po"],
["ザ", "ZA"],
["ざ", "za"],
["ジ", "ZI"],
["じ", "zi"],
["ズ", "ZU"],
["ず", "zu"],
["ゼ", "ZE"],
["ぜ", "ze"],
["ゾ", "ZO"],
["ぞ", "zo"],
["フ", "FU"],
["ふ", "fu"],
["ァ", "A"],
["ぁ", "a"],
["ィ", "I"],
["ぃ", "i"],
["ゥ", "U"],
["ぅ", "u"],
["ェ", "E"],
["ぇ", "e"],
["ォ", "O"],
["ぉ", "o"],
["ン", "N"],
["ん", "n"],
["シ", "SHI"],
["し", "shi"],
["ツ", "TSU"],
["つ", "tsu"],
["ヅ", "DU"],
["づ", "du"],
["ヂ", "DI"],
["ぢ", "di"],
["ヮ", "WA"],
["ゎ", "wa"],
["ヵ", "KA"],
["ヵ", "ka"],
["ヶ", "KE"],
["ヶ", "ke"],
["ャ", "YA"],
["ゃ", "ya"],
["ィ", "YI"],
["ぃ", "yi"],
["ュ", "YU"],
["ゅ", "yu"],
["ェ", "YE"],
["ぇ", "ye"],
["ョ", "YO"],
["ょ", "yo"]
]
},
"romajiToKana": {
"4letter": [
["XTSU", "ッ"],
["xtsu", "っ"],
["LTSU", "ッ"],
["ltsu", "っ"]
],
"3letter": [
["CHA", "チャ"],
["cha", "ちゃ"],
["CHI", "チ"],
["chi", "ち"],
["CHU", "チュ"],
["chu", "ちゅ"],
["CHE", "チェ"],
["che", "ちぇ"],
["CHO", "チョ"],
["cho", "ちょ"],
["SHA", "シャ"],
["sha", "しゃ"],
["SHI", "シ"],
["shi", "し"],
["SHU", "シュ"],
["shu", "しゅ"],
["SHE", "シェ"],
["she", "しぇ"],
["SHO", "ショ"],
["sho", "しょ"],
["RYA", "リャ"],
["rya", "りゃ"],
["RYI", "リィ"],
["ryi", "りぃ"],
["RYU", "リュ"],
["ryu", "りゅ"],
["RYE", "リェ"],
["rye", "りぇ"],
["RYO", "リョ"],
["ryo", "りょ"],
["HYA", "ヒャ"],
["hya", "ひゃ"],
["HYI", "ヒィ"],
["hyi", "ひぃ"],
["HYU", "ヒュ"],
["hyu", "ひゅ"],
["HYE", "ヒェ"],
["hye", "ひぇ"],
["HYO", "ヒョ"],
["hyo", "ひょ"],
["BYA", "ビャ"],
["bya", "びゃ"],
["BYI", "ビィ"],
["byi", "びぃ"],
["BYU", "ビュ"],
["byu", "びゅ"],
["BYE", "ビェ"],
["bye", "びぇ"],
["BYO", "ビョ"],
["byo", "びょ"],
["PYA", "ピャ"],
["pya", "ぴゃ"],
["PYI", "ピィ"],
["pyi", "ぴぃ"],
["PYU", "ピュ"],
["pyu", "ぴゅ"],
["PYE", "ピェ"],
["pye", "ぴぇ"],
["PYO", "ピョ"],
["pyo", "ぴょ"],
["MYA", "ミャ"],
["mya", "みゃ"],
["MYI", "ミィ"],
["myi", "みぃ"],
["MYU", "ミュ"],
["myu", "みゅ"],
["MYE", "ミェ"],
["mye", "みぇ"],
["MYO", "ミョ"],
["myo", "みょ"],
["KYA", "キャ"],
["kya", "きゃ"],
["KYI", "キィ"],
["kyi", "きぃ"],
["KYU", "キュ"],
["kyu", "きゅ"],
["KYE", "キェ"],
["kye", "きぇ"],
["KYO", "キョ"],
["kyo", "きょ"],
["GYA", "ギャ"],
["gya", "ぎゃ"],
["GYI", "ギィ"],
["gyi", "ぎぃ"],
["GYU", "ギュ"],
["gyu", "ぎゅ"],
["GYE", "ギェ"],
["gye", "ぎぇ"],
["GYO", "ギョ"],
["gyo", "ぎょ"],
["NYA", "ニャ"],
["nya", "にゃ"],
["NYI", "ニィ"],
["nyi", "にぃ"],
["NYU", "ニュ"],
["nyu", "にゅ"],
["NYE", "ニェ"],
["nye", "にぇ"],
["NYO", "ニョ"],
["nyo", "にょ"],
["JYA", "ジャ"],
["jya", "じゃ"],
["JYI", "ジィ"],
["jyi", "じぃ"],
["JYU", "ジュ"],
["jyu", "じゅ"],
["JYE", "ジェ"],
["jye", "じぇ"],
["JYO", "ジョ"],
["jyo", "じょ"],
["TSU", "ツ"],
["tsu", "つ"],
["DZU", "ヅ"],
["dzu", "づ"],
["DZI", "ヂ"],
["dzi", "ぢ"],
["DYA", "ヂャ"],
["dya", "ぢゃ"],
["DYI", "ヂィ"],
["dyi", "ぢぃ"],
["DYU", "ヂュ"],
["dyu", "ぢゅ"],
["DYE", "ヂェ"],
["dye", "ぢぇ"],
["DYO", "ヂョ"],
["dyo", "ぢょ"],
["XTU", "ッ"],
["xtu", "っ"],
["XWA", "ヮ"],
["xwa", "ゎ"],
["XKA", "ヵ"],
["xka", "ヵ"],
["XKE", "ヶ"],
["xke", "ヶ"],
["XYA", "ャ"],
["xya", "ゃ"],
["XYI", "ィ"],
["xyi", "ぃ"],
["XYU", "ュ"],
["xyu", "ゅ"],
["XYE", "ェ"],
["xye", "ぇ"],
["XYO", "ョ"],
["xyo", "ょ"],
["cya", "ちゃ"],
["cyu", "ちゅ"],
["cyo", "ちょ"],
["sya", "しゃ"],
["syu", "しゅ"],
["syo", "しょ"],
["tya", "ちゃ"],
["tyu", "ちゅ"],
["tyo", "ちょ"],
["TYA", "チャ"],
["TYU", "チュ"],
["TYO", "チョ"]
],
"2letter": [
["KA", "カ"],
["ka", "か"],
["KI", "キ"],
["ki", "き"],
["KU", "ク"],
["ku", "く"],
["KE", "ケ"],
["ke", "け"],
["KO", "コ"],
["ko", "こ"],
["CA", "カ"],
["ca", "か"],
["CI", "キ"],
["ci", "き"],
["CU", "ク"],
["cu", "く"],
["CE", "ケ"],
["ce", "け"],
["CO", "コ"],
["co", "こ"],
["SA", "サ"],
["sa", "さ"],
["SI", "シ"],
["si", "し"],
["SU", "ス"],
["su", "す"],
["SE", "セ"],
["se", "せ"],
["SO", "ソ"],
["so", "そ"],
["TA", "タ"],
["ta", "た"],
["TE", "テ"],
["ti", "ち"],
["TI", "チ"],
["tu", "つ"],
["TU", "ツ"],
["te", "て"],
["TO", "ト"],
["to", "と"],
["NA", "ナ"],
["na", "な"],
["NI", "ニ"],
["ni", "に"],
["NU", "ヌ"],
["nu", "ぬ"],
["NE", "ネ"],
["ne", "ね"],
["NO", ""],
["no", "の"],
["HA", "ハ"],
["ha", "は"],
["HI", "ヒ"],
["hi", "ひ"],
["HU", "フ"],
["hu", "ふ"],
["HE", "ヘ"],
["he", "へ"],
["HO", "ホ"],
["ho", "ほ"],
["MA", "マ"],
["ma", "ま"],
["MI", "ミ"],
["mi", "み"],
["MU", "ム"],
["mu", "む"],
["ME", "メ"],
["me", "め"],
["MO", "モ"],
["mo", "も"],
["YA", "ヤ"],
["ya", "や"],
["YU", "ユ"],
["yu", "ゆ"],
["YE", "イェ"],
["ye", "いぇ"],
["YO", "ヨ"],
["yo", "よ"],
["RA", "ラ"],
["ra", "ら"],
["RI", "リ"],
["ri", "り"],
["RU", "ル"],
["ru", "る"],
["RE", "レ"],
["re", "れ"],
["RO", "ロ"],
["ro", "ろ"],
["LA", "ラ"],
["la", "ら"],
["LI", "リ"],
["li", "り"],
["LU", "ル"],
["lu", "る"],
["LE", "レ"],
["le", "れ"],
["LO", "ロ"],
["lo", "ろ"],
["WA", "ワ"],
["wa", "わ"],
["WI", "ウィ"],
["wi", "うぃ"],
["WU", "ウ"],
["wu", "う"],
["WE", "ウェ"],
["we", "うぇ"],
["WO", "ヲ"],
["wo", "を"],
["GA", "ガ"],
["ga", "が"],
["GI", "ギ"],
["gi", "ぎ"],
["GU", "グ"],
["gu", "ぐ"],
["GE", "ゲ"],
["ge", "げ"],
["GO", "ゴ"],
["go", "ご"],
["JA", "ジャ"],
["ja", "じゃ"],
["JI", "ジ"],
["ji", "じ"],
["JU", "ジュ"],
["ju", "じゅ"],
["JE", "ジェ"],
["je", "じぇ"],
["JO", "ジョ"],
["jo", "じょ"],
["DA", "ダ"],
["da", "だ"],
["DI", "ヂ"],
["di", "ぢ"],
["DU", "ヅ"],
["du", "づ"],
["DE", "デ"],
["de", "で"],
["DO", "ド"],
["do", "ど"],
["BA", "バ"],
["ba", "ば"],
["BI", "ビ"],
["bi", "び"],
["BU", "ブ"],
["bu", "ぶ"],
["BE", "ベ"],
["be", "べ"],
["BO", "ボ"],
["bo", "ぼ"],
["VA", "ヴァ"],
["va", "ヴぁ"],
["VI", "ヴィ"],
["vi", "ヴぃ"],
["VU", "ヴ"],
["vu", "ヴ"],
["VE", "ヴェ"],
["ve", "ヴぇ"],
["VO", "ヴォ"],
["vo", "ヴぉ"],
["PA", "パ"],
["pa", "ぱ"],
["PI", "ピ"],
["pi", "ぴ"],
["PU", "プ"],
["pu", "ぷ"],
["PE", "ペ"],
["pe", "ぺ"],
["PO", "ポ"],
["po", "ぽ"],
["ZA", "ザ"],
["za", "ざ"],
["ZI", "ジ"],
["zi", "じ"],
["ZU", "ズ"],
["zu", "ず"],
["ZE", "ゼ"],
["ze", "ぜ"],
["ZO", "ゾ"],
["zo", "ぞ"],
["FA", "ファ"],
["fa", "ふぁ"],
["FI", "フィ"],
["fi", "ふぃ"],
["FU", "フ"],
["fu", "ふ"],
["FE", "フェ"],
["fe", "ふぇ"],
["FO", "フォ"],
["fo", "ふぉ"],
["XA", "ァ"],
["xa", "ぁ"],
["XI", "ィ"],
["xi", "ぃ"],
["XU", "ゥ"],
["xu", "ぅ"],
["XE", "ェ"],
["xe", "ぇ"],
["XO", "ォ"],
["xo", "ぉ"],
["XN", "ン"],
["xn", "ん"],
["NN", "ン"],
["N'", "ン"],
["nn", "ん"],
["n'", "ん"]
],
"1letter": [
["A", "ア"],
["a", "あ"],
["I", "イ"],
["i", "い"],
["U", "ウ"],
["u", "う"],
["E", "エ"],
["e", "え"],
["O", "オ"],
["o", "お"]
],
"replaceN": [
["N", "ン"],
["n", "ん"]
]
}
}

69
src/data/srslevels.json Normal file
View file

@ -0,0 +1,69 @@
[
{
"group": "Set In Stone",
"name": "★",
"value": 8,
"delay": null,
"color": "#1C1C1C"
},
{
"group": "Assimilating",
"name": "A2",
"value": 7,
"delay": 10368000,
"color": "#890062"
},
{
"group": "Assimilating",
"name": "A1",
"value": 6,
"delay": 2592000,
"color": "#890062"
},
{
"group": "Bolstering",
"name": "B2",
"value": 5,
"delay": 1209600,
"color": "#5C1696"
},
{
"group": "Bolstering",
"name": "B1",
"value": 4,
"delay": 604800,
"color": "#5C1696"
},
{
"group": "Committing",
"name": "C2",
"value": 3,
"delay": 259200,
"color": "#004E8E"
},
{
"group": "Committing",
"name": "C1",
"value": 2,
"delay": 86400,
"color": "#004E8E"
},
{
"group": "Discovering",
"name": "D2",
"value": 1,
"delay": 28800,
"color": "#1A814D"
},
{
"group": "Discovering",
"name": "D1",
"value": 0,
"delay": 14400,
"color": "#1A814D"
}
]

44
src/lib/kanaHelper.ts Normal file
View file

@ -0,0 +1,44 @@
// See https://github.com/Doublevil/Houhou-SRS/blob/master/Kanji.Common/Helpers/KanaHelper.cs
import kanadata from "../data/kanadata.json";
/**
* Specifically converts a romaji string to kana.
* The result may be hiragana, katakana or mixed, depending on the case
* of the input romaji.
*
* @param romaji Input romaji string.
* @param isLive Set to true to specify that the conversion is done in live (while the user is writing). Disables certain functions.
* @returns Output kana string.
*/
export function romajiToKana(romaji: string, isLive = false): string | null {
let s = romaji.trim();
if (s == "") return null;
// Replace the double vowels for katakana.
const doubleVowelRegex = /([AEIOU])\1/g;
s = s.replaceAll(doubleVowelRegex, "$1ー");
// Replace the double consonants.
// Then, replace - by ー.
s = s.replaceAll("-", "ー");
s = s.replaceAll("_", "ー");
// Then, replace 4 letter characters:
for (const [find, repl] of kanadata.romajiToKana["4letter"]) s = s.replaceAll(find, repl);
// Then, replace 3 letter characters:
for (const [find, repl] of kanadata.romajiToKana["3letter"]) s = s.replaceAll(find, repl);
// Then, replace 2 letter characters:
for (const [find, repl] of kanadata.romajiToKana["2letter"]) s = s.replaceAll(find, repl);
// Then, replace 1 letter characters:
for (const [find, repl] of kanadata.romajiToKana["1letter"]) s = s.replaceAll(find, repl);
if (!isLive)
for (const [find, repl] of kanadata.romajiToKana["replaceN"]) s = s.replaceAll(find, repl);
return s;
}

18
src/lib/kanji.ts Normal file
View file

@ -0,0 +1,18 @@
export interface Kanji {
character: string;
most_used_rank: number;
meanings: KanjiMeaning[];
srs_info?: KanjiSrsInfo;
}
export interface KanjiMeaning {
id: number;
meaning: string;
}
export interface KanjiSrsInfo {
id: number;
current_grade: number;
next_answer_date: number;
associated_kanji: string;
}

57
src/lib/srs.ts Normal file
View file

@ -0,0 +1,57 @@
export interface SrsEntry {
id: number;
associated_kanji: string;
current_grade: number;
meanings: string[];
readings: string[];
}
export interface SrsLevel {
group: string;
name: string;
value: number;
/** In seconds */
delay: number | null;
color: string;
}
import srsLevelMap from "../data/srslevels.json";
export const srsLevels: Map<number, SrsLevel> = new Map(srsLevelMap.map((v) => [v.value, v]));
export interface SrsQuestionGroup {
srsEntry: SrsEntry;
questions: { meaningQuestion: ReviewItem; readingQuestion: ReviewItem };
}
export function allQuestionsAnswered(group: SrsQuestionGroup): boolean {
return Object.values(group.questions).every((v) => v.isCorrect != null);
}
export function isGroupCorrect(group: SrsQuestionGroup): boolean {
return Object.values(group.questions).every((v) => v.isCorrect == true);
}
export function groupUpdatedLevel(group: SrsQuestionGroup): SrsLevel {
const grade = group.srsEntry.current_grade;
const modifier = isGroupCorrect(group) ? 1 : -1;
return (
srsLevels.get(grade + modifier) ??
// Rip type coercion, but current grade should be pretty much set
(srsLevels.get(grade) as SrsLevel)
);
}
export enum ReviewItemType {
MEANING = "MEANING",
READING = "READING",
}
export interface ReviewItem {
parent: SrsQuestionGroup;
type: ReviewItemType;
challenge: string;
possibleAnswers: string[];
isCorrect: boolean | null;
timesRepeated: number;
}

View file

@ -1,10 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);

View file

@ -1,5 +0,0 @@
export default function HomePane() {
return <>
hellosu
</>
}

View file

@ -1,10 +1,21 @@
$kanjiDisplaySize: 80px;
.kanji-pane-container {
display: flex;
align-items: stretch;
width: 100%;
height: 100%;
.kanjiDisplay {
border: 1px solid rgb(87, 87, 210);
width: $kanjiDisplaySize;
height: $kanjiDisplaySize;
font-size: $kanjiDisplaySize * 0.8;
text-align: center;
vertical-align: middle;
}
padding: 16px;
gap: 16px;
}
.kanji-pane-list {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 280px;
}
.right-side {
flex-grow: 5;
}

View file

@ -1,13 +1,63 @@
import { useState } from "react"
import { Kanji } from "../types/Kanji"
import styles from "./KanjiPane.module.scss"
import { invoke } from "@tauri-apps/api/tauri";
import useSWR from "swr";
import { Box, Flex, Grid, GridItem, LinkBox, Stack } from "@chakra-ui/layout";
export default function KanjiPane() {
const [selectedKanji, setSelectedKanji] = useState(null);
import styles from "./KanjiPane.module.scss";
import { useParams } from "react-router-dom";
import KanjiDisplay from "../components/KanjiDisplay";
import { Kanji } from "../lib/kanji";
import { KanjiList } from "../components/KanjiList";
import { useEffect, useState } from "react";
return <>
{JSON.stringify(selectedKanji)}
export interface GetKanjiResult {
count: number;
kanji: Kanji[];
}
<div className={styles.kanjiDisplay}></div>
</>
}
export function Component() {
const { selectedKanji } = useParams();
const { data: baseData, error, isLoading } = useSWR("get_kanji", invoke<GetKanjiResult>);
const [totalCount, setTotalCount] = useState(0);
const [kanjiList, setKanjiList] = useState<Kanji[]>([]);
// Set the base info
useEffect(() => {
if (baseData) {
setTotalCount(baseData.count);
setKanjiList(baseData.kanji);
}
}, [baseData]);
if (error) {
console.error(error);
}
const loadMoreKanji = async () => {
const result = await invoke<GetKanjiResult>("get_kanji", {
options: { skip: kanjiList.length, include_srs_info: true },
});
setKanjiList([...kanjiList, ...result.kanji]);
};
return (
<Flex className={styles["kanji-pane-container"]}>
<Box className={styles["kanji-pane-list"]}>
{kanjiList && (
<KanjiList
kanjiList={kanjiList}
totalCount={totalCount}
selectedCharacter={selectedKanji}
loadMoreKanji={loadMoreKanji}
/>
)}
</Box>
<Box className={styles["right-side"]}>
{selectedKanji ? <KanjiDisplay kanjiCharacter={selectedKanji} /> : "nothing selected"}
</Box>
</Flex>
);
}
Component.displayName = "KanjiPane";

View file

@ -0,0 +1,5 @@
export function Component() {
return <></>;
}
Component.displayName = "SettingsPane";

View file

@ -0,0 +1,8 @@
.main {
width: 100%;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}

View file

@ -1,3 +1,14 @@
export default function SrsPane() {
import DashboardReviewStats from "../components/DashboardReviewStats";
import styles from "./SrsPane.module.scss";
}
export function Component() {
return (
<main className={styles.main}>
<DashboardReviewStats />
<hr />
</main>
);
}
Component.displayName = "SrsPane";

View file

@ -0,0 +1,22 @@
.main {
width: 100%;
padding-top: 40px;
}
.container {
margin-top: 8px;
border-radius: 4px;
background-color: lighten(#393, 45%);
padding: 16px;
}
.test-word {
width: 100%;
text-align: center;
font-size: 4em;
padding: 64px 0;
}
.incorrect {
background-color: rgb(255, 202, 202) !important;
}

249
src/panes/SrsReviewPane.tsx Normal file
View file

@ -0,0 +1,249 @@
import {
Button,
Container,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Progress,
Spinner,
useDisclosure,
} from "@chakra-ui/react";
import styles from "./SrsReviewPane.module.scss";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/tauri";
import { Link, ScrollRestoration, useNavigate } from "react-router-dom";
import { ArrowBackIcon } from "@chakra-ui/icons";
import ConfirmQuitModal from "../components/utils/ConfirmQuitModal";
import * as _ from "lodash-es";
import { romajiToKana } from "../lib/kanaHelper";
import {
ReviewItem,
ReviewItemType,
SrsEntry,
SrsQuestionGroup,
allQuestionsAnswered,
groupUpdatedLevel,
isGroupCorrect,
} from "../lib/srs";
import classNames from "classnames";
const batchSize = 10;
function Done() {
return (
<>
<p>oh Shit you're done!!! poggerse</p>
<Link to="/">
<Button colorScheme="blue">
<ArrowBackIcon /> Return
</Button>
</Link>
</>
);
}
export function Component() {
// null = has not started, (.length == 0) = finished
const [reviewQueue, setReviewQueue] = useState<ReviewItem[] | null>(null);
const [completedQueue, setCompletedQueue] = useState<ReviewItem[]>([]);
const [anyProgress, setAnyProgress] = useState(false);
const [startingSize, setStartingSize] = useState<number | null>(null);
const [currentAnswer, setCurrentAnswer] = useState("");
const [incorrectTimes, setIncorrectTimes] = useState(0);
const { isOpen, onOpen, onClose } = useDisclosure();
const navigate = useNavigate();
useEffect(() => {
if (!reviewQueue) {
invoke<SrsEntry[]>("generate_review_batch")
.then((result) => {
const newReviews: ReviewItem[] = result.flatMap((srsEntry) => {
// L @ breaking type safety, but this is mutually recursive too
const srsQuestionGroup: SrsQuestionGroup = {
srsEntry,
questions: {},
} as SrsQuestionGroup;
const meaningQuestion: ReviewItem = {
parent: srsQuestionGroup,
type: ReviewItemType.MEANING,
challenge: srsEntry.associated_kanji,
possibleAnswers: srsEntry.meanings,
isCorrect: null,
timesRepeated: 0,
};
const readingQuestion: ReviewItem = {
parent: srsQuestionGroup,
type: ReviewItemType.READING,
challenge: srsEntry.associated_kanji,
possibleAnswers: srsEntry.readings,
isCorrect: null,
timesRepeated: 0,
};
srsQuestionGroup.questions.meaningQuestion = meaningQuestion;
srsQuestionGroup.questions.readingQuestion = readingQuestion;
return [meaningQuestion, readingQuestion];
});
const newReviewsShuffled = _.shuffle(newReviews);
setReviewQueue(newReviewsShuffled);
setStartingSize(newReviews.length);
})
.catch((err) => {
console.error("fuck!", err);
});
}
}, [reviewQueue]);
if (!reviewQueue) return <Spinner />;
if (reviewQueue.length == 0) return <Done />;
const [nextItem, ...restOfQueue] = reviewQueue;
const possibleAnswers = new Set(nextItem.possibleAnswers);
const formSubmit = async (evt: FormEvent) => {
evt.preventDefault();
if (!reviewQueue) return;
const isCorrect = possibleAnswers.has(currentAnswer);
nextItem.isCorrect =
new Map([
[null, isCorrect],
[false, false],
[true, isCorrect],
]).get(nextItem.isCorrect) ?? isCorrect;
// Figure out if we need to update the backend
if (allQuestionsAnswered(nextItem.parent)) {
console.log("SHIET");
const group = nextItem.parent;
const newLevel = groupUpdatedLevel(group);
const params = {
itemId: nextItem.parent.srsEntry.id,
correct: isGroupCorrect(nextItem.parent),
newGrade: newLevel.value,
delay: newLevel.delay,
};
const result = await invoke("update_srs_item", params);
console.log("result", result);
}
// If it's wrong this time
if (!isCorrect) {
setCurrentAnswer("");
setIncorrectTimes(incorrectTimes + 1);
return;
}
// Set up for next question!
setAnyProgress(true);
setIncorrectTimes(0);
setCurrentAnswer("");
if (nextItem.isCorrect || nextItem.timesRepeated > 0) {
setCompletedQueue([...completedQueue, nextItem]);
setReviewQueue(restOfQueue);
} else {
nextItem.timesRepeated++;
setReviewQueue([...restOfQueue, nextItem]);
}
};
const inputBox = (kanaInput: boolean) => {
const onChange = (evt: ChangeEvent<HTMLInputElement>) => {
let newValue = evt.target.value;
if (kanaInput) newValue = romajiToKana(newValue) ?? newValue;
setCurrentAnswer(newValue);
};
const className = classNames(styles["input-box"], incorrectTimes > 0 && styles["incorrect"]);
const placeholder =
{
0: "Enter your answer...",
1: "Wrong! Try again...",
}[incorrectTimes] || `Answer is: ${nextItem.possibleAnswers.join(", ")}`;
return (
<InputGroup>
<InputLeftElement>{kanaInput ? "あ" : "A"}</InputLeftElement>
<Input
value={currentAnswer}
onChange={onChange}
autoFocus
className={className}
placeholder={placeholder}
spellCheck={false}
backgroundColor={"white"}
/>
</InputGroup>
);
};
const renderInside = () => {
const kanaInput = nextItem.type == ReviewItemType.READING;
return (
<>
<p>{JSON.stringify(completedQueue.map((x) => x.challenge))}</p>
<p>{JSON.stringify(reviewQueue.map((x) => x.challenge))}</p>
{startingSize && (
<Progress
colorScheme="linkedin"
hasStripe
isAnimated
max={startingSize}
value={completedQueue.length}
/>
)}
<h1 className={styles["test-word"]}>{nextItem.challenge}</h1>
<form onSubmit={formSubmit}>
{
{
[ReviewItemType.MEANING]: "What is the meaning?",
[ReviewItemType.READING]: "What is the reading?",
}[nextItem.type]
}
{inputBox(kanaInput)}
</form>
</>
);
};
const quit = () => {
if (!reviewQueue || !anyProgress) {
return navigate("/");
}
onOpen();
};
return (
<main className={styles.main}>
<Container>
<Button onClick={quit}>
<ArrowBackIcon />
Back
</Button>
<div className={styles.container}>{renderInside()}</div>
</Container>
<ConfirmQuitModal isOpen={isOpen} onClose={onClose} />
</main>
);
}
Component.displayName = "SrsReviewPane";

5
src/panes/VocabPane.tsx Normal file
View file

@ -0,0 +1,5 @@
export function Component() {
return <></>;
}
Component.displayName = "VocabPane";

View file

@ -1,109 +1,15 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
@tailwind base;
@tailwind components;
@tailwind utilities;
color: #0f0f0f;
background-color: #f6f6f6;
html,
body {
width: 100%;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
height: 100%;
padding: 0;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
body {
overflow: hidden;
}

View file

@ -1,4 +0,0 @@
export interface Kanji {
character: string;
translation: string;
}

10
tailwind.config.js Normal file
View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js,jsx,ts,tsx}", "./node_modules/flowbite/**/*.js"],
theme: {
extend: {},
},
plugins: [require("flowbite/plugin")],
};

View file

@ -1,5 +1,5 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig(async () => ({