From 9f94611a42d41cf94cdccb00b5d2eec0d5d02970 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 14 Nov 2021 23:25:55 +0100 Subject: Add initial working version --- .gitignore | 3 + Cargo.lock | 395 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 15 ++ README.md | 26 +++ bin/run.sh | 4 + bin/watch.sh | 7 + flake.lock | 66 ++++++++ flake.nix | 30 ++++ src/db/db.rs | 122 ++++++++++++++ src/db/mod.rs | 1 + src/db/sql/1-init.sql | 10 ++ src/deck.rs | 59 +++++++ src/gui/gui.rs | 34 ++++ src/gui/mod.rs | 2 + src/gui/question.rs | 198 +++++++++++++++++++++++ src/main.rs | 15 ++ src/model/card.rs | 8 + src/model/deck.rs | 5 + src/model/difficulty.rs | 16 ++ src/model/mod.rs | 3 + src/space_repetition.rs | 361 ++++++++++++++++++++++++++++++++++++++++++ src/util/event.rs | 75 +++++++++ src/util/mod.rs | 3 + src/util/serialization.rs | 7 + 24 files changed, 1465 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100755 bin/run.sh create mode 100755 bin/watch.sh create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/db/db.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/sql/1-init.sql create mode 100644 src/deck.rs create mode 100644 src/gui/gui.rs create mode 100644 src/gui/mod.rs create mode 100644 src/gui/question.rs create mode 100644 src/main.rs create mode 100644 src/model/card.rs create mode 100644 src/model/deck.rs create mode 100644 src/model/difficulty.rs create mode 100644 src/model/mod.rs create mode 100644 src/space_repetition.rs create mode 100644 src/util/event.rs create mode 100644 src/util/mod.rs create mode 100644 src/util/serialization.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb1b496 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +deck +database.db diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bd2c0b6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,395 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "anyhow" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee10e43ae4a853c0a3591d4e2ada1719e553be18199d9da9d4a83f5927c2f5c7" + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "flashcards" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "rusqlite", + "rusqlite_migration", + "serde", + "serde_json", + "termion", + "tui", +] + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "libc" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869d572136620d55835903746bcb5cdc54cb2851fd0aeec53220b4bb65ef3013" + +[[package]] +name = "libsqlite3-sys" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd5850c449b40bacb498b2bbdfaff648b1b055630073ba8db499caf2d0ea9f2" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numtoa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" + +[[package]] +name = "once_cell" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" + +[[package]] +name = "pkg-config" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" + +[[package]] +name = "proc-macro2" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_termios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" +dependencies = [ + "redox_syscall", +] + +[[package]] +name = "rusqlite" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a82b0b91fad72160c56bf8da7a549b25d7c31109f52cc1437eac4c0ad2550a7" +dependencies = [ + "bitflags", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + +[[package]] +name = "rusqlite_migration" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2abaa6d2e015b59342d088590b0c0051ba1ed5508780f8c31315b2986ebbf5b6" +dependencies = [ + "log", + "rusqlite", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "serde" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e277c495ac6cd1a01a58d0a0c574568b4d1ddf14f59965c6a58b8d96400b54f3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" + +[[package]] +name = "syn" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "termion" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" +dependencies = [ + "libc", + "numtoa", + "redox_syscall", + "redox_termios", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "tui" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c8ce4e27049eed97cfa363a5048b09d995e209994634a0efc26a14ab6c0c23" +dependencies = [ + "bitflags", + "cassowary", + "termion", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-segmentation" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c9108f1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "flashcards" +version = "0.1.0" +authors = ["Joris GUYONVARCH"] +edition = "2018" + +[dependencies] +chrono = "0.4" +anyhow = "1.0" +rusqlite = { version = "0.26", features = [ "chrono" ] } +rusqlite_migration = "0.5" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +termion = "1.5" +tui = "0.16" diff --git a/README.md b/README.md new file mode 100644 index 0000000..69a4f26 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Getting started + +```bash +nix develop --command bin/watch.sh +``` + +# Deck + +Cards are created from a plain text `./deck` file: + +``` +# This is a comment + +- good moorning : bonjour +- alternative 1 | alternative 2 : choix 1 | choix 2 +``` + +# TODO + +- indications in parenthesis ? +- Fix crashes on zoom / changing size + +## Nice to have + +- Add phonetics indication +- Select deck diff --git a/bin/run.sh b/bin/run.sh new file mode 100755 index 0000000..8209ad6 --- /dev/null +++ b/bin/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +"$TERM" --command bash -c "cargo run" diff --git a/bin/watch.sh b/bin/watch.sh new file mode 100755 index 0000000..e3a6c28 --- /dev/null +++ b/bin/watch.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +watchexec \ + --watch src \ + --restart \ + "(killall flashcards || true) && tput reset && cargo build && bin/run.sh" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d557ed5 --- /dev/null +++ b/flake.lock @@ -0,0 +1,66 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1634851050, + "narHash": "sha256-N83GlSGPJJdcqhUxSCS/WwW5pksYf3VP1M13cDRTSVA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c91f3de5adaf1de973b797ef7485e441a65b8935", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1636730720, + "narHash": "sha256-JuZ9Zjy3Z/dChL6szbAVCstQoNzI1TKJaWIPlMmTkqA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "860795fd7e65502728c96e54f251e2a733a771e7", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1636683315, + "narHash": "sha256-udsMHw5gzZPvvFIhopoGicLK/LaYyDN3iAa1PEpP7uM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "e87b7ea329fc78c5e5775c983193f630458c317f", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a7238d3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + rust-overlay.inputs.flake-utils.follows = "flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + in + with pkgs; + { + devShell = mkShell { + buildInputs = [ + rust-bin.stable."1.56.1".default + watchexec + cargo-watch + sqlite + ]; + }; + } + ); +} diff --git a/src/db/db.rs b/src/db/db.rs new file mode 100644 index 0000000..93c9564 --- /dev/null +++ b/src/db/db.rs @@ -0,0 +1,122 @@ +use crate::{ + model::{card::Card, deck::Entry}, + space_repetition, + util::serialization, +}; +use anyhow::Result; +use rusqlite::{params, Connection}; +use rusqlite_migration::{Migrations, M}; +use std::time::SystemTime; + +pub fn init() -> Result { + let mut conn = Connection::open("database.db")?; + let migrations = Migrations::new(vec![M::up(include_str!("sql/1-init.sql"))]); + migrations.to_latest(&mut conn)?; + Ok(conn) +} + +pub fn add_missing_deck_entries(conn: &Connection, entries: Vec) -> Result<()> { + let now = get_current_time()?; + let ready = now; + let day: u64 = 60 * 60 * 24; + + let state = serde_json::to_string(&space_repetition::init())?; + + for entry in entries { + let concat_1 = serialization::words_to_line(&entry.part_1); + let concat_2 = serialization::words_to_line(&entry.part_2); + + for (i, w) in entry.part_1.iter().enumerate() { + let r = ready + (i as u64) * day; + insert(&conn, now, r, &w, &concat_2, &state)?; + } + + for (i, w2) in entry.part_2.iter().enumerate() { + let r = ready + ((entry.part_1.len() + i) as u64) * day; + insert(&conn, now, r, &w2, &concat_1, &state)?; + } + } + + delete_read_before(&conn, now)?; + + Ok(()) +} + +fn insert( + conn: &Connection, + now: u64, + ready: u64, + question: &String, + responses: &String, + state: &String, +) -> Result<()> { + conn.execute( + " + INSERT INTO cards (question, responses, state, created, deck_read, ready) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (question) DO UPDATE SET deck_read = ?, deleted = null + ", + params![question, responses, state, now, now, ready, now], + )?; + + Ok(()) +} + +fn delete_read_before(conn: &Connection, t: u64) -> Result<()> { + conn.execute( + "UPDATE cards SET deleted = ? WHERE deck_read < ?", + params![t, t], + )?; + + Ok(()) +} + +pub fn pick_ready(conn: &Connection) -> Option { + let now = get_current_time().ok()?; + + let mut stmt = conn + .prepare( + " + SELECT question, responses, state + FROM cards + WHERE ready <= ? AND deleted IS NULL + ORDER BY ready + LIMIT 1 + ", + ) + .ok()?; + + let mut rows = stmt.query([now]).ok()?; + let row = rows.next().ok()??; + let state_str: String = row.get(2).ok()?; + let responses_str: String = row.get(1).ok()?; + + Some(Card { + question: row.get(0).ok()?, + responses: serialization::line_to_words(&responses_str), + state: serde_json::from_str(&state_str).ok()?, + }) +} + +pub fn update(conn: &Connection, question: &String, state: &space_repetition::State) -> Result<()> { + let now = get_current_time()?; + let ready = now + state.get_interval_seconds(); + let state_str = serde_json::to_string(state)?; + + conn.execute( + " + UPDATE cards + SET state = ?, updated = ?, ready = ? + WHERE question = ? + ", + params![state_str, now, ready, question], + )?; + + Ok(()) +} + +fn get_current_time() -> Result { + Ok(SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..dec1023 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1 @@ +pub mod db; diff --git a/src/db/sql/1-init.sql b/src/db/sql/1-init.sql new file mode 100644 index 0000000..29d70ed --- /dev/null +++ b/src/db/sql/1-init.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS cards ( + question VARCHAR PRIMARY KEY, + responses VARCHAR NOT NULL, + state VARCHAR NOT NULL, + created TIMESTAMP NOT NULL, + updated TIMESTAMP NULL, + deleted TIMESTAMP NULL, + deck_read TIMESTAMP NOT NULL, + ready TIMESTAMP NOT NULL +) diff --git a/src/deck.rs b/src/deck.rs new file mode 100644 index 0000000..384ce19 --- /dev/null +++ b/src/deck.rs @@ -0,0 +1,59 @@ +use crate::{model::deck::Entry, util::serialization}; +use anyhow::{Result, Error}; +use std::fs::File; +use std::io::{prelude::*, BufReader}; +use std::fmt; + +#[derive(Debug, Clone)] +struct ParseError { + line: usize, + message: String, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} (parsing line {})", self.message, self.line) + } +} + +impl std::error::Error for ParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } +} + + +pub fn read() -> Result> { + let file = File::open("deck")?; + let reader = BufReader::new(file); + let mut entries: Vec = Vec::new(); + + for (index, line) in reader.lines().enumerate() { + let line = line?; + let line = line.trim(); + + if !line.starts_with("#") && !line.is_empty() { + if !line.starts_with("-") { + return Err(Error::from(ParseError { line: index + 1, message: "an entry should starts with “-”.".to_string() })) + } else { + let translation = line[1..].trim().split(":").collect::>(); + if translation.len() != 2 { + return Err(Error::from(ParseError { line: index + 1, message: "an entry should contain two parts separated by “:”.".to_string() })) + } else { + let t1 = translation[0].trim(); + let t2 = translation[1].trim(); + if t1.is_empty() || t2.is_empty() { + return Err(Error::from(ParseError { line: index + 1, message: "an entry should contain two parts separated by “:”.".to_string() })) + } else { + entries.push(Entry { + part_1: serialization::line_to_words(&t1.to_string()), + part_2: serialization::line_to_words(&t2.to_string()), + }) + } + } + } + } + } + + Ok(entries) +} diff --git a/src/gui/gui.rs b/src/gui/gui.rs new file mode 100644 index 0000000..fdf686b --- /dev/null +++ b/src/gui/gui.rs @@ -0,0 +1,34 @@ +use crate::{db::db, gui::question, space_repetition, util::event::Events}; +use anyhow::Result; +use rusqlite::Connection; +use std::io; +use termion::{raw::IntoRawMode, screen::AlternateScreen}; +use tui::{backend::TermionBackend, Terminal}; + +pub fn start(conn: &Connection) -> Result<()> { + let stdout = io::stdout().into_raw_mode()?; + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let events = Events::new(); + + loop { + match db::pick_ready(&conn) { + Some(card) => { + let difficulty = + question::ask(&mut terminal, &events, &card, "German".to_string())?; + db::update( + &conn, + &card.question, + &space_repetition::update(card.state, difficulty), + )?; + } + None => { + break; + } + } + } + + Ok(()) +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..cbf9675 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,2 @@ +pub mod gui; +pub mod question; diff --git a/src/gui/question.rs b/src/gui/question.rs new file mode 100644 index 0000000..a22b977 --- /dev/null +++ b/src/gui/question.rs @@ -0,0 +1,198 @@ +use crate::{ + model::{card::Card, difficulty, difficulty::Difficulty}, + util::event::{Event, Events}, + util::serialization, +}; +use anyhow::Result; +use termion::event::Key; +use tui::{ + backend::Backend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, + Terminal, +}; + +struct State { + pub input: String, + pub answer: Answer, +} + +enum Answer { + Write, + Difficulty { difficulty: Difficulty }, +} + +pub fn ask( + terminal: &mut Terminal, + events: &Events, + card: &Card, + deck: String, +) -> Result { + let mut state = State { + input: String::new(), + answer: Answer::Write, + }; + + loop { + terminal.draw(|f| { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints( + [ + Constraint::Length(1), + Constraint::Percentage(30), + Constraint::Length(5), + Constraint::Percentage(30), + Constraint::Length(5), + ] + .as_ref(), + ) + .split(f.size()); + + let d1 = Paragraph::new(format!("{}", deck)) + .alignment(Alignment::Center) + .style( + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ); + f.render_widget(d1, chunks[0]); + + let question = Paragraph::new(center_vertically(chunks[1], &card.question)) + .style(match state.answer { + Answer::Write => { + if state.input == "" { + Style::default().fg(Color::Yellow) + } else { + Style::default() + } + } + _ => Style::default(), + }) + .alignment(Alignment::Center); + f.render_widget(question, chunks[1]); + + let answer = Paragraph::new(center_vertically(chunks[2], &state.input)) + .style(match state.answer { + Answer::Write => Style::default(), + Answer::Difficulty { difficulty: _ } => { + if is_correct(&state.input, &card.responses) { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } + }) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title("Réponse")) + .wrap(Wrap { trim: true }); + f.render_widget(answer, chunks[2]); + + match state.answer { + Answer::Difficulty { + difficulty: selected, + } => { + if !is_correct(&state.input, &card.responses) || card.responses.len() > 1 { + let paragraph = Paragraph::new(center_vertically( + chunks[3], + &serialization::words_to_line(&card.responses), + )) + .alignment(Alignment::Center); + f.render_widget(paragraph, chunks[3]); + }; + + let difficulties = card.state.difficulties(); + let l = difficulties.len(); + let sep = Span::styled(" • ", Style::default()); + let tabs = difficulties + .iter() + .enumerate() + .map(|(i, d)| { + let style = if *d == selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::UNDERLINED) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + let d = Span::styled(difficulty::label(*d), style); + if i < l - 1 { + [d, sep.clone()].to_vec() + } else { + [d].to_vec() + } + }) + .collect::>>() + .concat(); + let p = + Paragraph::new(Text::from(Spans::from(tabs))).alignment(Alignment::Center); + f.render_widget(p, chunks[4]); + } + _ => {} + } + })?; + + if let Event::Input(key) = events.next()? { + match state.answer { + Answer::Write => match key { + Key::Char('\n') => { + let difficulty = if is_correct(&state.input, &card.responses) { + Difficulty::Good + } else { + Difficulty::Again + }; + state.answer = Answer::Difficulty { difficulty } + } + Key::Char(c) => { + state.input.push(c); + } + Key::Backspace => { + state.input.pop(); + } + _ => {} + }, + Answer::Difficulty { + difficulty: selected, + } => match key { + Key::Left => { + for d in relative_element(&card.state.difficulties(), &selected, -1).iter() + { + state.answer = Answer::Difficulty { difficulty: *d } + } + } + Key::Right => { + for d in relative_element(&card.state.difficulties(), &selected, 1).iter() { + state.answer = Answer::Difficulty { difficulty: *d } + } + } + Key::Char('\n') => return Ok(selected), + _ => {} + }, + } + } + } +} + +fn center_vertically(chunk: Rect, text: &String) -> String { + let text_lines = text.lines().count(); + let chunk_inner_lines: usize = (chunk.height - 2).into(); + let blank_lines = chunk_inner_lines - text_lines; + let newlines = "\n".repeat(blank_lines / 2); + format!("{}{}", newlines, text) +} + +fn is_correct(input: &String, responses: &Vec) -> bool { + responses.contains(input) +} + +fn relative_element(xs: &Vec, x: &T, ri: i32) -> Option { + let i = xs.iter().position(|t| t == x)? as i32 + ri; + if i >= 0 && i < xs.len() as i32 { + Some(xs[i as usize].clone()) + } else { + None + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4761366 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +mod db; +mod deck; +mod gui; +mod model; +mod space_repetition; +mod util; + +use anyhow::Result; + +fn main() -> Result<()> { + let conn = db::db::init()?; + let entries = deck::read()?; + db::db::add_missing_deck_entries(&conn, entries)?; + gui::gui::start(&conn) +} diff --git a/src/model/card.rs b/src/model/card.rs new file mode 100644 index 0000000..3ac395e --- /dev/null +++ b/src/model/card.rs @@ -0,0 +1,8 @@ +use crate::space_repetition; + +#[derive(Debug)] +pub struct Card { + pub question: String, + pub responses: Vec, + pub state: space_repetition::State, +} diff --git a/src/model/deck.rs b/src/model/deck.rs new file mode 100644 index 0000000..769b38c --- /dev/null +++ b/src/model/deck.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone)] +pub struct Entry { + pub part_1: Vec, + pub part_2: Vec, +} diff --git a/src/model/difficulty.rs b/src/model/difficulty.rs new file mode 100644 index 0000000..ea5a9ce --- /dev/null +++ b/src/model/difficulty.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Difficulty { + Again, + Hard, + Good, + Easy, +} + +pub fn label(difficulty: Difficulty) -> String { + match difficulty { + Difficulty::Again => "Recommencer".to_string(), + Difficulty::Hard => "Difficile".to_string(), + Difficulty::Good => "Bon".to_string(), + Difficulty::Easy => "Facile".to_string(), + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..bbd7891 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,3 @@ +pub mod card; +pub mod deck; +pub mod difficulty; diff --git a/src/space_repetition.rs b/src/space_repetition.rs new file mode 100644 index 0000000..25cae7f --- /dev/null +++ b/src/space_repetition.rs @@ -0,0 +1,361 @@ +// SM2-Anki +// https://gist.github.com/riceissa/1ead1b9881ffbb48793565ce69d7dbdd + +use crate::model::difficulty::{Difficulty, Difficulty::*}; +use serde::{Deserialize, Serialize}; + +// Learning +const LEARNING_INTERVALS: [f32; 2] = [ + 1.0 / 60.0 / 24.0, // 1 minute + 10.0 / 60.0 / 24.0, // 10 minutes +]; + +// Ease +const EASE_INIT: f32 = 2.5; +const EASE_MIN: f32 = 1.3; + +// Interval +const INTERVAL_INIT: f32 = 1.0; +const INTERVAL_INIT_EASY: f32 = 4.0; +const INTERVAL_MIN: f32 = 0.1; +const INTERVAL_MAX: f32 = 36500.0; + +// Learned +const EASE_AGAIN_SUB: f32 = 0.2; +const EASE_HARD_SUB: f32 = 0.15; +const EASE_EASY_ADD: f32 = 0.15; +const INTERVAL_AGAIN_MUL: f32 = 0.7; +const INTERVAL_HARD_MUL: f32 = 1.2; +const INTERVAL_EASY_MUL: f32 = 1.3; + +// Relearning +const RELEARNING_INTERVALS: [f32; 1] = [ + 10.0 / 60.0 / 24.0, // 10 minutes +]; + +#[derive(Debug, PartialEq, Deserialize, Serialize)] +pub enum State { + Learning { + step: usize, + }, + Learned { + ease: f32, // ratio + interval: f32, // in days + }, + Relearning { + step: usize, + ease: f32, + interval: f32, + }, +} + +pub fn init() -> State { + State::Learning { step: 0 } +} + +impl State { + pub fn get_interval_seconds(&self) -> u64 { + let days = match self { + State::Learning { step } => LEARNING_INTERVALS[*step], + State::Learned { interval, .. } => *interval, + State::Relearning { step, .. } => RELEARNING_INTERVALS[*step], + }; + (days * 24.0 * 60.0 * 60.0).round() as u64 + } + + pub fn difficulties(&self) -> Vec { + match self { + State::Learning { .. } => [Again, Good, Easy].to_vec(), + State::Learned { .. } => [Again, Hard, Good, Easy].to_vec(), + State::Relearning { .. } => [Again, Good].to_vec(), + } + } +} + +pub fn update(state: State, difficulty: Difficulty) -> State { + match state { + State::Learning { step } => match difficulty { + Again => State::Learning { step: 0 }, + Good => { + let new_step = step + 1; + if new_step < LEARNING_INTERVALS.len() { + State::Learning { step: new_step } + } else { + State::Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT, + } + } + } + Easy => State::Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT_EASY, + }, + _ => panic!("Learning is incompatible with {:?}", difficulty), + }, + State::Learned { ease, interval } => match difficulty { + Again => State::Relearning { + step: 0, + ease: clamp_ease(ease - EASE_AGAIN_SUB), + interval: clamp_interval(interval * INTERVAL_AGAIN_MUL), + }, + Hard => State::Learned { + ease: clamp_ease(ease - EASE_HARD_SUB), + interval: clamp_interval(interval * INTERVAL_HARD_MUL), + }, + Good => State::Learned { + ease, + interval: clamp_interval(interval * ease), + }, + Easy => State::Learned { + ease: clamp_ease(ease + EASE_EASY_ADD), + interval: clamp_interval(interval * ease * INTERVAL_EASY_MUL), + }, + }, + State::Relearning { + step, + ease, + interval, + } => match difficulty { + Again => State::Relearning { + step: 0, + ease, + interval, + }, + Good => { + let new_step = step + 1; + if new_step < RELEARNING_INTERVALS.len() { + State::Relearning { + step: new_step, + ease, + interval, + } + } else { + State::Learned { ease, interval } + } + } + _ => panic!("Relearning is incompatible with {:?}.", difficulty), + }, + } +} + +fn clamp_ease(f: f32) -> f32 { + if f < EASE_MIN { + EASE_MIN + } else { + f + } +} + +fn clamp_interval(i: f32) -> f32 { + if i < INTERVAL_MIN { + INTERVAL_MIN + } else if i > INTERVAL_MAX { + INTERVAL_MAX + } else { + i + } +} + +#[cfg(test)] +mod tests { + use super::{State::*, *}; + + #[test] + fn learning_again() { + assert_eq!(update(Learning { step: 1 }, Again), Learning { step: 0 }); + } + + #[test] + fn learning_good() { + assert_eq!(update(Learning { step: 0 }, Good), Learning { step: 1 }); + + assert_eq!( + update( + Learning { + step: LEARNING_INTERVALS.len() - 1 + }, + Good + ), + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT + } + ); + } + + #[test] + fn learning_easy() { + assert_eq!( + update(Learning { step: 0 }, Easy), + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT_EASY + } + ); + } + + #[test] + fn learned_again() { + assert_eq!( + update( + Learned { + ease: EASE_MIN, + interval: INTERVAL_MIN + }, + Again + ), + Relearning { + step: 0, + ease: EASE_MIN, + interval: INTERVAL_MIN + } + ); + + assert_eq!( + update( + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT + }, + Again + ), + Relearning { + step: 0, + ease: EASE_INIT - EASE_AGAIN_SUB, + interval: INTERVAL_INIT * INTERVAL_AGAIN_MUL + } + ); + } + + #[test] + fn learned_hard() { + assert_eq!( + update( + Learned { + ease: EASE_MIN, + interval: INTERVAL_MAX + }, + Hard + ), + Learned { + ease: EASE_MIN, + interval: INTERVAL_MAX + } + ); + + assert_eq!( + update( + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT + }, + Hard + ), + Learned { + ease: EASE_INIT - EASE_HARD_SUB, + interval: INTERVAL_INIT * INTERVAL_HARD_MUL + } + ); + } + + #[test] + fn learned_good() { + assert_eq!( + update( + Learned { + ease: EASE_INIT, + interval: INTERVAL_MAX + }, + Good + ), + Learned { + ease: EASE_INIT, + interval: INTERVAL_MAX + } + ); + + assert_eq!( + update( + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT + }, + Good + ), + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT * EASE_INIT + } + ); + } + + #[test] + fn learned_easy() { + assert_eq!( + update( + Learned { + ease: EASE_INIT, + interval: INTERVAL_MAX + }, + Easy + ), + Learned { + ease: EASE_INIT + EASE_EASY_ADD, + interval: INTERVAL_MAX + } + ); + + assert_eq!( + update( + Learned { + ease: EASE_INIT, + interval: INTERVAL_INIT + }, + Easy + ), + Learned { + ease: EASE_INIT + EASE_EASY_ADD, + interval: INTERVAL_INIT * EASE_INIT * INTERVAL_EASY_MUL + } + ); + } + + #[test] + fn relearning_again() { + let ease = EASE_INIT + EASE_EASY_ADD; + let interval = INTERVAL_INIT * ease; + assert_eq!( + update( + Relearning { + step: 1, + ease, + interval, + }, + Again + ), + Relearning { + step: 0, + ease, + interval + } + ); + } + + #[test] + fn relearning_good() { + let ease = EASE_INIT + EASE_EASY_ADD; + let interval = INTERVAL_INIT * ease; + assert_eq!( + update( + Relearning { + step: RELEARNING_INTERVALS.len() - 1, + ease, + interval, + }, + Good + ), + Learned { ease, interval } + ); + } +} diff --git a/src/util/event.rs b/src/util/event.rs new file mode 100644 index 0000000..33ee9ec --- /dev/null +++ b/src/util/event.rs @@ -0,0 +1,75 @@ +use std::io; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use termion::event::Key; +use termion::input::TermRead; + +pub enum Event { + Input(I), + Tick, +} + +/// A small event handler that wrap termion input and tick events. Each event +/// type is handled in its own thread and returned to a common `Receiver` +pub struct Events { + rx: mpsc::Receiver>, + input_handle: thread::JoinHandle<()>, + tick_handle: thread::JoinHandle<()>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Config { + pub tick_rate: Duration, +} + +impl Default for Config { + fn default() -> Config { + Config { + tick_rate: Duration::from_millis(250), + } + } +} + +impl Events { + pub fn new() -> Events { + Events::with_config(Config::default()) + } + + pub fn with_config(config: Config) -> Events { + let (tx, rx) = mpsc::channel(); + let input_handle = { + let tx = tx.clone(); + thread::spawn(move || { + let stdin = io::stdin(); + for evt in stdin.keys() { + if let Ok(key) = evt { + if let Err(err) = tx.send(Event::Input(key)) { + eprintln!("{}", err); + return; + } + } + } + }) + }; + let tick_handle = { + thread::spawn(move || loop { + if let Err(err) = tx.send(Event::Tick) { + eprintln!("{}", err); + break; + } + thread::sleep(config.tick_rate); + }) + }; + Events { + rx, + input_handle, + tick_handle, + } + } + + pub fn next(&self) -> Result, mpsc::RecvError> { + self.rx.recv() + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..c5504af --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,3 @@ +#[allow(dead_code)] +pub mod event; +pub mod serialization; diff --git a/src/util/serialization.rs b/src/util/serialization.rs new file mode 100644 index 0000000..fcb3062 --- /dev/null +++ b/src/util/serialization.rs @@ -0,0 +1,7 @@ +pub fn line_to_words(line: &String) -> Vec { + line.split("|").map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect() +} + +pub fn words_to_line(words: &Vec) -> String { + words.join(" | ") +} -- cgit v1.2.3