aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Cargo.lock395
-rw-r--r--Cargo.toml15
-rw-r--r--README.md26
-rwxr-xr-xbin/run.sh4
-rwxr-xr-xbin/watch.sh7
-rw-r--r--flake.lock66
-rw-r--r--flake.nix30
-rw-r--r--src/db/db.rs122
-rw-r--r--src/db/mod.rs1
-rw-r--r--src/db/sql/1-init.sql10
-rw-r--r--src/deck.rs59
-rw-r--r--src/gui/gui.rs34
-rw-r--r--src/gui/mod.rs2
-rw-r--r--src/gui/question.rs198
-rw-r--r--src/main.rs15
-rw-r--r--src/model/card.rs8
-rw-r--r--src/model/deck.rs5
-rw-r--r--src/model/difficulty.rs16
-rw-r--r--src/model/mod.rs3
-rw-r--r--src/space_repetition.rs361
-rw-r--r--src/util/event.rs75
-rw-r--r--src/util/mod.rs3
-rw-r--r--src/util/serialization.rs7
24 files changed, 1465 insertions, 0 deletions
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<Connection> {
+ 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<Entry>) -> 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<Card> {
+ 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<u64> {
+ 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<Vec<Entry>> {
+ let file = File::open("deck")?;
+ let reader = BufReader::new(file);
+ let mut entries: Vec<Entry> = 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::<Vec<&str>>();
+ 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<B: Backend>(
+ terminal: &mut Terminal<B>,
+ events: &Events,
+ card: &Card,
+ deck: String,
+) -> Result<Difficulty> {
+ 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::<Vec<Vec<Span>>>()
+ .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<String>) -> bool {
+ responses.contains(input)
+}
+
+fn relative_element<T: Clone + PartialEq>(xs: &Vec<T>, x: &T, ri: i32) -> Option<T> {
+ 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<String>,
+ 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<String>,
+ pub part_2: Vec<String>,
+}
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<Difficulty> {
+ 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<I> {
+ 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<Event<Key>>,
+ 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<Event<Key>, 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<String> {
+ line.split("|").map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect()
+}
+
+pub fn words_to_line(words: &Vec<String>) -> String {
+ words.join(" | ")
+}