diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/db/db.rs | 160 | ||||
-rw-r--r-- | src/db/mod.rs | 161 | ||||
-rw-r--r-- | src/deck.rs | 8 | ||||
-rw-r--r-- | src/gui/gui.rs | 98 | ||||
-rw-r--r-- | src/gui/message.rs | 7 | ||||
-rw-r--r-- | src/gui/mod.rs | 95 | ||||
-rw-r--r-- | src/gui/question.rs | 90 | ||||
-rw-r--r-- | src/gui/util.rs | 2 | ||||
-rw-r--r-- | src/main.rs | 12 | ||||
-rw-r--r-- | src/util/event.rs | 10 | ||||
-rw-r--r-- | src/util/serialization.rs | 6 | ||||
-rw-r--r-- | src/util/time.rs | 2 |
12 files changed, 318 insertions, 333 deletions
diff --git a/src/db/db.rs b/src/db/db.rs deleted file mode 100644 index b42da3f..0000000 --- a/src/db/db.rs +++ /dev/null @@ -1,160 +0,0 @@ -use crate::{ - model::{card::Card, entry::Entry}, - space_repetition, - util::serialization, -}; -use anyhow::Result; -use rusqlite::{params, Connection}; -use rusqlite_migration::{Migrations, M}; - -use crate::util::time; - -pub fn init(database: String) -> Result<Connection> { - let mut conn = Connection::open(database)?; - let migrations = Migrations::new(vec![ - M::up(include_str!("sql/1-init.sql")), - M::up(include_str!("sql/2-primary-key-question-responses.sql")), - ]); - migrations.to_latest(&mut conn)?; - Ok(conn) -} - -pub fn last_deck_read(conn: &Connection) -> Option<u64> { - let mut stmt = conn - .prepare("SELECT deck_read FROM cards ORDER BY deck_read DESC LIMIT 1") - .ok()?; - - let mut rows = stmt.query([]).ok()?; - let row = rows.next().ok()??; - row.get(0).ok()? -} - -/// Synchronize the DB with the deck: -/// -/// - insert new cards, -/// - keep existing cards, -/// - hide unused cards (keep state in case the card is added back afterward). -pub fn synchronize(conn: &Connection, entries: Vec<Entry>) -> Result<()> { - let now = time::seconds_since_unix_epoch()?; - - 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 w in entry.part_1.iter() { - insert(&conn, now, &w, &concat_2, &state)?; - } - - for w in entry.part_2.iter() { - insert(&conn, now, &w, &concat_1, &state)?; - } - } - - delete_read_before(&conn, now)?; - - Ok(()) -} - -fn insert( - conn: &Connection, - now: u64, - question: &String, - responses: &String, - state: &String, -) -> Result<()> { - conn.execute( - " - INSERT INTO cards (question, responses, state, created, deck_read, ready) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (question, responses) DO UPDATE SET deck_read = ?, deleted = null - ", - params![question, responses, state, now, now, now, 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_random_ready(conn: &Connection) -> Option<Card> { - let now = time::seconds_since_unix_epoch().ok()?; - - let mut stmt = conn - .prepare( - " - SELECT question, responses, state, ready - FROM cards - WHERE deleted IS NULL AND ready <= ? - ORDER BY RANDOM() - 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()?, - ready: row.get(3).ok()?, - }) -} - -pub fn next_ready(conn: &Connection) -> Option<u64> { - let mut stmt = conn - .prepare( - " - SELECT ready - FROM cards - WHERE deleted IS NULL - ORDER BY ready - LIMIT 1 - ", - ) - .ok()?; - - let mut rows = stmt.query([]).ok()?; - let row = rows.next().ok()??; - row.get(0).ok()? -} - -pub fn count_available(conn: &Connection) -> Option<i32> { - let now = time::seconds_since_unix_epoch().ok()?; - let mut stmt = conn - .prepare("SELECT COUNT(*) FROM cards WHERE ready <= ? AND deleted IS NULL") - .ok()?; - - let mut rows = stmt.query([now]).ok()?; - let row = rows.next().ok()??; - row.get(0).ok()? -} - -pub fn update(conn: &Connection, question: &String, state: &space_repetition::State) -> Result<()> { - let now = time::seconds_since_unix_epoch()?; - 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(()) -} diff --git a/src/db/mod.rs b/src/db/mod.rs index dec1023..f59aad5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1 +1,160 @@ -pub mod db; +use crate::{ + model::{card::Card, entry::Entry}, + space_repetition, + util::serialization, +}; +use anyhow::Result; +use rusqlite::{params, Connection}; +use rusqlite_migration::{Migrations, M}; + +use crate::util::time; + +pub fn init(database: String) -> Result<Connection> { + let mut conn = Connection::open(database)?; + let migrations = Migrations::new(vec![ + M::up(include_str!("sql/1-init.sql")), + M::up(include_str!("sql/2-primary-key-question-responses.sql")), + ]); + migrations.to_latest(&mut conn)?; + Ok(conn) +} + +pub fn last_deck_read(conn: &Connection) -> Option<u64> { + let mut stmt = conn + .prepare("SELECT deck_read FROM cards ORDER BY deck_read DESC LIMIT 1") + .ok()?; + + let mut rows = stmt.query([]).ok()?; + let row = rows.next().ok()??; + row.get(0).ok()? +} + +/// Synchronize the DB with the deck: +/// +/// - insert new cards, +/// - keep existing cards, +/// - hide unused cards (keep state in case the card is added back afterward). +pub fn synchronize(conn: &Connection, entries: Vec<Entry>) -> Result<()> { + let now = time::seconds_since_unix_epoch()?; + + 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 w in entry.part_1.iter() { + insert(conn, now, w, &concat_2, &state)?; + } + + for w in entry.part_2.iter() { + insert(conn, now, w, &concat_1, &state)?; + } + } + + delete_read_before(conn, now)?; + + Ok(()) +} + +fn insert( + conn: &Connection, + now: u64, + question: &str, + responses: &str, + state: &str, +) -> Result<()> { + conn.execute( + " + INSERT INTO cards (question, responses, state, created, deck_read, ready) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (question, responses) DO UPDATE SET deck_read = ?, deleted = null + ", + params![question, responses, state, now, now, now, 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_random_ready(conn: &Connection) -> Option<Card> { + let now = time::seconds_since_unix_epoch().ok()?; + + let mut stmt = conn + .prepare( + " + SELECT question, responses, state, ready + FROM cards + WHERE deleted IS NULL AND ready <= ? + ORDER BY RANDOM() + 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()?, + ready: row.get(3).ok()?, + }) +} + +pub fn next_ready(conn: &Connection) -> Option<u64> { + let mut stmt = conn + .prepare( + " + SELECT ready + FROM cards + WHERE deleted IS NULL + ORDER BY ready + LIMIT 1 + ", + ) + .ok()?; + + let mut rows = stmt.query([]).ok()?; + let row = rows.next().ok()??; + row.get(0).ok()? +} + +pub fn count_available(conn: &Connection) -> Option<i32> { + let now = time::seconds_since_unix_epoch().ok()?; + let mut stmt = conn + .prepare("SELECT COUNT(*) FROM cards WHERE ready <= ? AND deleted IS NULL") + .ok()?; + + let mut rows = stmt.query([now]).ok()?; + let row = rows.next().ok()??; + row.get(0).ok()? +} + +pub fn update(conn: &Connection, question: &str, state: &space_repetition::State) -> Result<()> { + let now = time::seconds_since_unix_epoch()?; + 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(()) +} diff --git a/src/deck.rs b/src/deck.rs index e0f9fab..a414a02 100644 --- a/src/deck.rs +++ b/src/deck.rs @@ -32,14 +32,14 @@ pub fn read(deck: &str) -> Result<Vec<Entry>> { let line = line?; let line = line.trim(); - if !line.starts_with("#") && !line.is_empty() { - if !line.starts_with("-") { + 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>>(); + let translation = line[1..].trim().split(':').collect::<Vec<&str>>(); if translation.len() != 2 { return Err(Error::from(ParseError { line: index + 1, @@ -68,7 +68,7 @@ pub fn read(deck: &str) -> Result<Vec<Entry>> { Ok(entries) } -pub fn pp_from_path(path: &String) -> Option<String> { +pub fn pp_from_path(path: &str) -> Option<String> { Some(capitalize( Path::new(&path).with_extension("").file_name()?.to_str()?, )) diff --git a/src/gui/gui.rs b/src/gui/gui.rs deleted file mode 100644 index 92b1a72..0000000 --- a/src/gui/gui.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::deck; -use crate::util::time; -use crate::{db::db, gui::message, gui::question, space_repetition, util::event::Events}; -use anyhow::Result; -use rusqlite::Connection; -use std::{fs, io, time::Duration}; -use termion::{raw::IntoRawMode, raw::RawTerminal, screen::AlternateScreen}; -use tui::{backend::TermionBackend, Terminal}; - -type Term = Terminal<TermionBackend<AlternateScreen<RawTerminal<io::Stdout>>>>; - -pub fn terminal() -> Result<Term> { - let stdout = io::stdout().into_raw_mode()?; - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); - Ok(Terminal::new(backend)?) -} - -pub fn synchronize( - conn: &Connection, - term: &mut Term, - events: &Events, - deck_path: &str, - deck_name: &str, -) -> Result<()> { - let last_modified = time::seconds_since_unix_epoch_of(fs::metadata(deck_path)?.modified()?)?; - let last_deck_read = db::last_deck_read(&conn); - let must_synchronize = last_deck_read.map(|r| r < last_modified).unwrap_or(true); - - if must_synchronize { - let _ = message::show(term, events, &deck_name, "Synchronization du deck…", false); - time::wait_at_least( - || db::synchronize(&conn, deck::read(&deck_path)?), - Duration::from_secs(1), - )?; - } - - Ok(()) -} - -pub fn start( - conn: &Connection, - term: &mut Term, - events: &Events, - deck_name: &String, -) -> Result<()> { - let mut answers = 0; - - loop { - let now = time::seconds_since_unix_epoch()?; - let title = title(deck_name, answers, db::count_available(&conn).unwrap_or(0)); - - match db::pick_random_ready(&conn) { - Some(card) => { - let difficulty = question::ask(term, events, &title, &card)?; - answers += 1; - db::update( - &conn, - &card.question, - &space_repetition::update(card.state, difficulty), - )?; - } - None => { - let message = match db::next_ready(&conn) { - Some(ready) => format!( - "Prochaine carte disponible dans {}.", - time::pp_duration(ready - now) - ), - None => format!("Aucune carte n’est disponible. Votre deck est-il vide ?"), - }; - let _ = message::show(term, events, &title, &message, true); - break; - } - } - } - - Ok(()) -} - -fn title(deck_name: &String, answers: i32, available_cards: i32) -> String { - if answers == 0 && available_cards == 0 { - deck_name.to_string() - } else if available_cards == 0 { - format!( - "{} ({} / {})", - deck_name, - answers, - answers + available_cards - ) - } else { - format!( - "{} ({} / {})", - deck_name, - answers + 1, - answers + available_cards - ) - } -} diff --git a/src/gui/message.rs b/src/gui/message.rs index 28a1d2c..b938150 100644 --- a/src/gui/message.rs +++ b/src/gui/message.rs @@ -33,11 +33,8 @@ pub fn show<B: Backend>( })?; if wait { - if let Event::Input(key) = events.next()? { - match key { - Key::Char('q') => break, - _ => (), - } + if let Event::Input(Key::Char('q')) = events.next()? { + break } } else { break; diff --git a/src/gui/mod.rs b/src/gui/mod.rs index f351eba..92cd943 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,4 +1,97 @@ -pub mod gui; pub mod message; pub mod question; pub mod util; + +use crate::deck; +use crate::util::time; +use crate::{db, space_repetition, util::event::Events}; +use anyhow::Result; +use rusqlite::Connection; +use std::{fs, io, time::Duration}; +use termion::{raw::IntoRawMode, raw::RawTerminal, screen::AlternateScreen}; +use tui::{backend::TermionBackend, Terminal}; + +type Term = Terminal<TermionBackend<AlternateScreen<RawTerminal<io::Stdout>>>>; + +pub fn terminal() -> Result<Term> { + let stdout = io::stdout().into_raw_mode()?; + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + +pub fn synchronize( + conn: &Connection, + term: &mut Term, + events: &Events, + deck_path: &str, + deck_name: &str, +) -> Result<()> { + let last_modified = time::seconds_since_unix_epoch_of(fs::metadata(deck_path)?.modified()?)?; + let last_deck_read = db::last_deck_read(conn); + let must_synchronize = last_deck_read.map(|r| r < last_modified).unwrap_or(true); + + if must_synchronize { + let _ = message::show(term, events, deck_name, "Synchronization du deck…", false); + time::wait_at_least( + || db::synchronize(conn, deck::read(deck_path)?), + Duration::from_secs(1), + )?; + } + + Ok(()) +} + +pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &str) -> Result<()> { + let mut answers = 0; + + loop { + let now = time::seconds_since_unix_epoch()?; + let title = title(deck_name, answers, db::count_available(conn).unwrap_or(0)); + + match db::pick_random_ready(conn) { + Some(card) => { + let difficulty = question::ask(term, events, &title, &card)?; + answers += 1; + db::update( + conn, + &card.question, + &space_repetition::update(card.state, difficulty), + )?; + } + None => { + let message = match db::next_ready(conn) { + Some(ready) => format!( + "Prochaine carte disponible dans {}.", + time::pp_duration(ready - now) + ), + None => "Aucune carte n’est disponible. Votre deck est-il vide ?".to_string(), + }; + let _ = message::show(term, events, &title, &message, true); + break; + } + } + } + + Ok(()) +} + +fn title(deck_name: &str, answers: i32, available_cards: i32) -> String { + if answers == 0 && available_cards == 0 { + deck_name.to_string() + } else if available_cards == 0 { + format!( + "{} ({} / {})", + deck_name, + answers, + answers + available_cards + ) + } else { + format!( + "{} ({} / {})", + deck_name, + answers + 1, + answers + available_cards + ) + } +} diff --git a/src/gui/question.rs b/src/gui/question.rs index 211bcda..5f060e3 100644 --- a/src/gui/question.rs +++ b/src/gui/question.rs @@ -28,7 +28,7 @@ enum Answer { pub fn ask<B: Backend>( terminal: &mut Terminal<B>, events: &Events, - title: &String, + title: &str, card: &Card, ) -> Result<Difficulty> { let mut state = State { @@ -59,7 +59,7 @@ pub fn ask<B: Backend>( let question = Paragraph::new(util::center_vertically(chunks[1], &card.question)) .style(match state.answer { Answer::Write => { - if state.input == "" { + if state.input.is_empty() { Style::default().fg(Color::Yellow) } else { Style::default() @@ -86,47 +86,44 @@ pub fn ask<B: Backend>( .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(util::center_vertically( - chunks[3], - &serialization::words_to_line(&card.responses), - )) - .alignment(Alignment::Center); - f.render_widget(paragraph, chunks[3]); - }; + if let Answer::Difficulty { + difficulty: selected, + } = state.answer + { + if !is_correct(&state.input, &card.responses) || card.responses.len() > 1 { + let paragraph = Paragraph::new(util::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]); - } - _ => {} + 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]); } })?; @@ -171,15 +168,14 @@ pub fn ask<B: Backend>( } } -fn is_correct(input: &String, responses: &Vec<String>) -> bool { +fn is_correct(input: &str, responses: &[String]) -> bool { responses .iter() - .map(|r| r.split("(").collect::<Vec<&str>>()[0].trim()) - .collect::<Vec<&str>>() - .contains(&input.as_str()) + .map(|r| r.split('(').collect::<Vec<&str>>()[0].trim()) + .any(|x| x == input) } -fn relative_element<T: Clone + PartialEq>(xs: &Vec<T>, x: &T, ri: i32) -> Option<T> { +fn relative_element<T: Clone + PartialEq>(xs: &[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()) diff --git a/src/gui/util.rs b/src/gui/util.rs index 38ed1e7..2314aba 100644 --- a/src/gui/util.rs +++ b/src/gui/util.rs @@ -12,7 +12,7 @@ pub fn title(str: &str) -> Paragraph { ) } -pub fn center_vertically(chunk: Rect, text: &String) -> String { +pub fn center_vertically(chunk: Rect, text: &str) -> String { let text_lines = text.lines().count(); let chunk_inner_lines: usize = (chunk.height - 2).into(); let blank_lines = chunk_inner_lines - text_lines; diff --git a/src/main.rs b/src/main.rs index 3e3e741..bed2ce1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,15 +19,15 @@ struct Opt { fn main() -> Result<()> { let deck_path = Opt::from_args().deck; - let conn = db::db::init(db_path(&deck_path))?; - let deck_name = deck::pp_from_path(&deck_path).unwrap_or("Deck".to_string()); - let mut term = gui::gui::terminal()?; + let conn = db::init(db_path(&deck_path))?; + let deck_name = deck::pp_from_path(&deck_path).unwrap_or_else(|| "Deck".to_string()); + let mut term = gui::terminal()?; let events = Events::new(); - gui::gui::synchronize(&conn, &mut term, &events, &deck_path, &deck_name)?; - gui::gui::start(&conn, &mut term, &events, &deck_name) + gui::synchronize(&conn, &mut term, &events, &deck_path, &deck_name)?; + gui::start(&conn, &mut term, &events, &deck_name) } -fn db_path(deck_path: &String) -> String { +fn db_path(deck_path: &str) -> String { let mut path = PathBuf::from(deck_path); path.set_extension("db"); path.to_string_lossy().to_string() diff --git a/src/util/event.rs b/src/util/event.rs index 33ee9ec..05d8581 100644 --- a/src/util/event.rs +++ b/src/util/event.rs @@ -43,12 +43,10 @@ impl Events { 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; - } + for key in stdin.keys().flatten() { + if let Err(err) = tx.send(Event::Input(key)) { + eprintln!("{}", err); + return; } } }) diff --git a/src/util/serialization.rs b/src/util/serialization.rs index cc2899f..61b3a83 100644 --- a/src/util/serialization.rs +++ b/src/util/serialization.rs @@ -1,10 +1,10 @@ -pub fn line_to_words(line: &String) -> Vec<String> { - line.split("|") +pub fn line_to_words(line: &str) -> 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 { +pub fn words_to_line(words: &[String]) -> String { words.join(" | ") } diff --git a/src/util/time.rs b/src/util/time.rs index d9a9f72..b8a85e6 100644 --- a/src/util/time.rs +++ b/src/util/time.rs @@ -3,7 +3,7 @@ use std::thread; use std::time::SystemTime; pub fn seconds_since_unix_epoch() -> Result<u64> { - Ok(seconds_since_unix_epoch_of(SystemTime::now())?) + seconds_since_unix_epoch_of(SystemTime::now()) } pub fn seconds_since_unix_epoch_of(time: SystemTime) -> Result<u64> { |