aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/deck.rs145
-rw-r--r--src/gui/message.rs16
-rw-r--r--src/gui/mod.rs70
-rw-r--r--src/gui/question.rs188
-rw-r--r--src/main.rs38
-rw-r--r--src/model/difficulty.rs2
-rw-r--r--src/model/mod.rs2
-rw-r--r--src/sync.rs206
-rw-r--r--src/util/event.rs73
-rw-r--r--src/util/mod.rs1
-rw-r--r--src/util/serialization.rs18
11 files changed, 441 insertions, 318 deletions
diff --git a/src/deck.rs b/src/deck.rs
index 0c302e1..4491d9b 100644
--- a/src/deck.rs
+++ b/src/deck.rs
@@ -23,51 +23,57 @@ impl std::error::Error for ParseError {
}
}
-pub fn read(deck: &str) -> Result<Vec<Line>> {
- let file = File::open(deck)?;
+pub fn read_file(path: &str) -> Result<Vec<Line>> {
+ let file = File::open(path)?;
let reader = BufReader::new(file);
let mut entries: Vec<Line> = 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 {
+ if let Some(line) = read_line(index, &line)? {
+ entries.push(line)
+ }
+ }
+
+ Ok(entries)
+}
+
+fn read_line(index: usize, line: &str) -> Result<Option<Line>> {
+ let line = line.trim();
+
+ if line.starts_with('#') || line.is_empty() {
+ Ok(None)
+ } else if !line.starts_with('-') {
+ Err(Error::from(ParseError {
+ line: index + 1,
+ message: "an entry should starts with “-”.".to_string(),
+ }))
+ } else {
+ let without_minus = line.split('-').skip(1).collect::<Vec<&str>>().join("-");
+ let without_comment = without_minus.split('#').collect::<Vec<&str>>()[0].trim();
+ let translation = without_comment.split(':').collect::<Vec<&str>>();
+ if translation.len() != 2 {
+ 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() {
+ Err(Error::from(ParseError {
line: index + 1,
- message: "an entry should starts with “-”.".to_string(),
- }));
+ message: "an entry should contain two parts separated by “:”.".to_string(),
+ }))
} else {
- let without_minus = line.split('-').skip(1).collect::<Vec<&str>>().join("-");
- let without_comment = without_minus.split('#').collect::<Vec<&str>>()[0].trim();
- let translation = without_comment.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(Line {
- part_1: serialization::line_to_words(t1),
- part_2: serialization::line_to_words(t2),
- })
- }
- }
+ Ok(Some(Line {
+ part_1: serialization::line_to_words(t1),
+ part_2: serialization::line_to_words(t2),
+ }))
}
}
}
-
- Ok(entries)
}
pub fn pp_from_path(path: &str) -> Option<String> {
@@ -83,3 +89,74 @@ fn capitalize(s: &str) -> String {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
+
+#[cfg(test)]
+pub mod tests {
+
+ use crate::model::Line;
+ use anyhow::Result;
+
+ #[test]
+ fn errors() {
+ is_error("A : a");
+ is_error("- A");
+ is_error("- A -> a");
+ is_error("- A : B : C");
+ is_error("- : ");
+ is_error("- A : a\n-")
+ }
+
+ #[test]
+ fn ignored() {
+ check("", &[]);
+ check(" ", &[]);
+ check(" \n \n ", &[]);
+ check("# 1", &[]);
+ check("# 1\n\n # 2", &[]);
+ }
+
+ #[test]
+ fn card() {
+ check("- A : a", &[(&["A"], &["a"])]);
+ }
+
+ #[test]
+ fn cards() {
+ check("- A : a\n- B : b", &[(&["A"], &["a"]), (&["B"], &["b"])]);
+ }
+
+ #[test]
+ fn alternatives() {
+ check("- A : a1 | a2", &[(&["A"], &["a1", "a2"])]);
+ check("- A1 | A2 : a", &[(&["A1", "A2"], &["a"])]);
+ check("- A1 | A2 : a1 | a2", &[(&["A1", "A2"], &["a1", "a2"])]);
+ }
+
+ fn is_error(content: &str) {
+ assert!(read_string(content).is_err())
+ }
+
+ fn check(content: &str, res: &[(&[&str], &[&str])]) {
+ assert_eq!(
+ read_string(content).unwrap(),
+ res.iter()
+ .map(|(part_1, part_2)| Line {
+ part_1: part_1.iter().map(|x| x.to_string()).collect::<Vec<_>>(),
+ part_2: part_2.iter().map(|x| x.to_string()).collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>()
+ )
+ }
+
+ pub fn read_string(content: &str) -> Result<Vec<Line>> {
+ let mut entries: Vec<Line> = Vec::new();
+
+ for (index, line) in content.lines().enumerate() {
+ if let Some(line) = super::read_line(index, line)? {
+ entries.push(line)
+ }
+ }
+
+ Ok(entries)
+ }
+}
diff --git a/src/gui/message.rs b/src/gui/message.rs
index 29b5d8a..61f57ba 100644
--- a/src/gui/message.rs
+++ b/src/gui/message.rs
@@ -1,7 +1,6 @@
use crate::gui::util;
-use crate::util::event::{Event, Events};
use anyhow::Result;
-use termion::event::Key;
+use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
@@ -11,7 +10,6 @@ use tui::{
pub fn show<B: Backend>(
terminal: &mut Terminal<B>,
- events: &Events,
title: &str,
message: &str,
wait: bool,
@@ -33,12 +31,12 @@ pub fn show<B: Backend>(
})?;
if wait {
- if let Event::Input(key) = events.next()? {
- match key {
- Key::Char('q') | Key::Ctrl('c') => {
- break;
- }
- _ => {}
+ if let Event::Key(key) = event::read()? {
+ if key.code == KeyCode::Char('q')
+ || key.code == KeyCode::Char('c')
+ && key.modifiers.contains(KeyModifiers::CONTROL)
+ {
+ break;
}
}
} else {
diff --git a/src/gui/mod.rs b/src/gui/mod.rs
index 358e4b5..3abe238 100644
--- a/src/gui/mod.rs
+++ b/src/gui/mod.rs
@@ -2,34 +2,59 @@ pub mod message;
pub mod question;
pub mod util;
-use crate::{db, space_repetition, util::event::Events, util::time};
+use crate::sync;
+use crate::{db, space_repetition, util::time};
use anyhow::Result;
+use crossterm::terminal;
use rusqlite::Connection;
-use std::io;
-use termion::{raw::IntoRawMode, raw::RawTerminal, screen::AlternateScreen};
-use tui::{backend::TermionBackend, Terminal};
+use std::fs;
+use std::io::Stdout;
+use tui::{backend::CrosstermBackend, Terminal};
-pub type Term = Terminal<TermionBackend<AlternateScreen<RawTerminal<io::Stdout>>>>;
+pub type Term = Terminal<CrosstermBackend<Stdout>>;
-pub fn terminal() -> Result<Term> {
- let stdout = io::stdout().into_raw_mode()?;
- let stdout = AlternateScreen::from(stdout);
- let backend = TermionBackend::new(stdout);
+pub fn setup_terminal() -> Result<Term> {
+ terminal::enable_raw_mode()?;
+ let mut stdout = std::io::stdout();
+ crossterm::execute!(stdout, terminal::EnterAlternateScreen)?;
+ let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
-pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &str) -> Result<()> {
- let mut answers = 0;
+pub fn restore_terminal(term: &mut Term) -> Result<()> {
+ terminal::disable_raw_mode()?;
+ crossterm::execute!(term.backend_mut(), terminal::LeaveAlternateScreen)?;
+ term.show_cursor()?;
+ Ok(())
+}
+pub fn start(
+ conn: &mut Connection,
+ term: &mut Term,
+ deck_path: &str,
+ deck_name: &str,
+ mut deck_last_sync: u64,
+ hide_remaining: bool,
+) -> Result<()> {
loop {
- let now = time::seconds_since_unix_epoch()?;
- let title = title(deck_name, answers, db::count_available(conn).unwrap_or(0));
+ // Synchronize deck if necessary
+ let deck_last_update =
+ time::seconds_since_unix_epoch_of(fs::metadata(deck_path)?.modified()?)?;
+ if deck_last_update > deck_last_sync {
+ sync::run(conn, deck_path)?;
+ deck_last_sync = time::seconds_since_unix_epoch()?;
+ }
+
+ let title = title(
+ deck_name,
+ db::count_available(conn).unwrap_or(0),
+ hide_remaining,
+ );
match db::pick_random_ready(conn) {
- Some(card) => match question::ask(term, events, &title, &card)? {
+ Some(card) => match question::ask(term, &title, &card)? {
question::Response::Aborted => break,
question::Response::Answered { difficulty } => {
- answers += 1;
db::update(
conn,
&card.question,
@@ -40,12 +65,13 @@ pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &st
None => {
let message = match db::next_ready(conn) {
Some(ready) => {
+ let now = time::seconds_since_unix_epoch()?;
let duration = time::pp_duration(ready - now);
format!("Prochaine carte disponible dans {duration}.")
}
None => "Aucune carte n’est disponible. Votre deck est-il vide ?".to_string(),
};
- let _ = message::show(term, events, &title, &message, true);
+ let _ = message::show(term, &title, &message, true);
break;
}
}
@@ -54,16 +80,10 @@ pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &st
Ok(())
}
-fn title(deck_name: &str, answers: i32, available_cards: i32) -> String {
- if answers == 0 && available_cards == 0 {
+fn title(deck_name: &str, available_cards: i32, hide_remaining: bool) -> String {
+ if available_cards == 0 || hide_remaining {
deck_name.to_string()
- } else if available_cards == 0 {
- let from = answers;
- let to = answers + available_cards;
- format!("{deck_name} ({from} / {to})")
} else {
- let from = answers + 1;
- let to = answers + available_cards;
- format!("{deck_name} ({from} / {to})")
+ format!("{deck_name} ({available_cards})")
}
}
diff --git a/src/gui/question.rs b/src/gui/question.rs
index 2aa6e65..512ca49 100644
--- a/src/gui/question.rs
+++ b/src/gui/question.rs
@@ -1,17 +1,16 @@
use crate::{
gui::util,
model::{difficulty, difficulty::Difficulty, Card},
- util::event::{Event, Events},
util::serialization,
};
use anyhow::Result;
-use termion::event::Key;
+use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
- text::{Span, Spans, Text},
- widgets::{Block, Borders, Paragraph, Wrap},
+ text::{Line, Span, Text},
+ widgets::{Paragraph, Wrap},
Terminal,
};
@@ -30,12 +29,7 @@ pub enum Response {
Answered { difficulty: Difficulty },
}
-pub fn ask<B: Backend>(
- terminal: &mut Terminal<B>,
- events: &Events,
- title: &str,
- card: &Card,
-) -> Result<Response> {
+pub fn ask<B: Backend>(terminal: &mut Terminal<B>, title: &str, card: &Card) -> Result<Response> {
let mut state = State {
input: String::new(),
answer: Answer::Write,
@@ -62,16 +56,6 @@ pub fn ask<B: Backend>(
f.render_widget(d1, chunks[0]);
let question = Paragraph::new(util::center_vertically(chunks[1], &card.question))
- .style(match state.answer {
- Answer::Write => {
- if state.input.trim().is_empty() {
- Style::default().fg(Color::Yellow)
- } else {
- Style::default()
- }
- }
- _ => Style::default(),
- })
.alignment(Alignment::Center);
f.render_widget(question, chunks[1]);
@@ -83,15 +67,15 @@ pub fn ask<B: Backend>(
.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)
+ match check_response(&state.input, &card.responses) {
+ CheckResponse::Correct { phonetics: _ } => {
+ Style::default().fg(Color::Green)
+ }
+ CheckResponse::Incorrect => 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]);
@@ -99,12 +83,17 @@ pub fn ask<B: Backend>(
difficulty: selected,
} = state.answer
{
- if !is_correct(&state.input, &card.responses) {
- let paragraph = Paragraph::new(util::center_vertically(
- chunks[3],
- &serialization::words_to_line(&card.responses),
- ))
- .alignment(Alignment::Center);
+ let maybe_indication: Option<String> =
+ match check_response(&state.input, &card.responses) {
+ CheckResponse::Correct { phonetics } => phonetics,
+ CheckResponse::Incorrect => {
+ Some(serialization::words_to_line(&card.responses))
+ }
+ };
+
+ if let Some(indication) = maybe_indication {
+ let paragraph = Paragraph::new(util::center_vertically(chunks[3], &indication))
+ .alignment(Alignment::Center);
f.render_widget(paragraph, chunks[3]);
};
@@ -131,73 +120,84 @@ pub fn ask<B: Backend>(
})
.collect::<Vec<Vec<Span>>>()
.concat();
- let p = Paragraph::new(Text::from(Spans::from(tabs))).alignment(Alignment::Center);
+ let p = Paragraph::new(Text::from(Line::from(tabs))).alignment(Alignment::Center);
f.render_widget(p, chunks[4]);
}
})?;
- if let Event::Input(key) = events.next()? {
+ if let Event::Key(key) = event::read()? {
match state.answer {
- Answer::Write => match key {
- Key::Char('\n') => {
- let difficulty = if is_correct(&state.input, &card.responses) {
+ Answer::Write => match key.code {
+ KeyCode::Enter => {
+ let difficulty = if state.input.is_empty() {
+ // Encourage solving without typing by defaulting to good answer
Difficulty::Good
} else {
- Difficulty::Again
+ match check_response(&state.input, &card.responses) {
+ CheckResponse::Correct { phonetics: _ } => Difficulty::Good,
+ CheckResponse::Incorrect => Difficulty::Again,
+ }
};
state.answer = Answer::Difficulty { difficulty }
}
- Key::Char(c) => {
- state.input.push(c);
- if is_correct(&state.input, &card.responses) {
- state.answer = Answer::Difficulty {
- difficulty: Difficulty::Good,
+ KeyCode::Char(c) => {
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ if c == 'u' {
+ state.input.clear();
+ } else if c == 'w' {
+ let mut words =
+ state.input.split_whitespace().collect::<Vec<&str>>();
+ if !words.is_empty() {
+ words.truncate(words.len() - 1);
+ let joined_words = words.join(" ");
+ let space = if !words.is_empty() { " " } else { "" };
+ state.input = format!("{joined_words}{space}");
+ }
+ } else if c == 'c' {
+ return Ok(Response::Aborted);
+ }
+ } else {
+ state.input.push(c);
+ if let CheckResponse::Correct { phonetics: _ } =
+ check_response(&state.input, &card.responses)
+ {
+ state.answer = Answer::Difficulty {
+ difficulty: Difficulty::Good,
+ }
}
}
}
- Key::Backspace => {
+ KeyCode::Backspace => {
state.input.pop();
}
- Key::Ctrl('u') => {
- state.input.clear();
- }
- Key::Ctrl('w') => {
- let mut words = state.input.split_whitespace().collect::<Vec<&str>>();
- if !words.is_empty() {
- words.truncate(words.len() - 1);
- state.input = format!(
- "{}{}",
- words.join(" "),
- if !words.is_empty() { " " } else { "" }
- );
- }
- }
- Key::Ctrl('c') => {
- return Ok(Response::Aborted);
- }
_ => {}
},
Answer::Difficulty {
difficulty: selected,
- } => match key {
- Key::Left => {
- for d in relative_element(&card.state.difficulties(), &selected, -1).iter()
+ } => match key.code {
+ KeyCode::Left => {
+ if let Some(difficulty) =
+ relative_element(&card.state.difficulties(), &selected, -1)
{
- state.answer = Answer::Difficulty { difficulty: *d }
+ state.answer = Answer::Difficulty { difficulty }
}
}
- Key::Right => {
- for d in relative_element(&card.state.difficulties(), &selected, 1).iter() {
- state.answer = Answer::Difficulty { difficulty: *d }
+ KeyCode::Right => {
+ if let Some(difficulty) =
+ relative_element(&card.state.difficulties(), &selected, 1)
+ {
+ state.answer = Answer::Difficulty { difficulty }
}
}
- Key::Char('\n') => {
+ KeyCode::Enter => {
return Ok(Response::Answered {
difficulty: selected,
})
}
- Key::Ctrl('c') => {
- return Ok(Response::Aborted);
+ KeyCode::Char('c') => {
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ return Ok(Response::Aborted);
+ }
}
_ => {}
},
@@ -206,18 +206,50 @@ pub fn ask<B: Backend>(
}
}
-fn is_correct(input: &str, responses: &[String]) -> bool {
- // Remove whitespaces
- let input = input
+enum CheckResponse {
+ Incorrect,
+ Correct { phonetics: Option<String> },
+}
+
+fn check_response(input: &str, responses: &[String]) -> CheckResponse {
+ let input = remove_whitespaces(input);
+
+ responses
+ .iter()
+ .find(|r| remove_indications_and_phonetics(r) == input)
+ .map(|r| CheckResponse::Correct {
+ phonetics: extract_phonetics(r),
+ })
+ .unwrap_or(CheckResponse::Incorrect)
+}
+
+fn remove_whitespaces(input: &str) -> String {
+ input
.split_whitespace()
.map(|word| word.trim())
.collect::<Vec<&str>>()
- .join(" ");
+ .join(" ")
+}
- responses
- .iter()
- .map(|r| r.split('(').collect::<Vec<&str>>()[0].trim())
- .any(|x| x == input)
+fn remove_indications_and_phonetics(response: &str) -> &str {
+ response
+ .split(|c| c == '(' || c == '[')
+ .collect::<Vec<&str>>()[0]
+ .trim()
+}
+
+fn extract_phonetics(response: &str) -> Option<String> {
+ let s1 = response.split('[').collect::<Vec<&str>>();
+ if s1.len() == 2 {
+ let s2 = s1[1].split(']').collect::<Vec<&str>>();
+ if s2.len() > 1 {
+ Some(format!("[{}]", s2[0]))
+ } else {
+ None
+ }
+ } else {
+ None
+ }
}
fn relative_element<T: Clone + PartialEq>(xs: &[T], x: &T, ri: i32) -> Option<T> {
diff --git a/src/main.rs b/src/main.rs
index a791f29..b18cb1a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,35 +6,49 @@ mod space_repetition;
mod sync;
mod util;
-use crate::util::event::Events;
use anyhow::Result;
+use clap::Parser;
use std::path::PathBuf;
-use structopt::StructOpt;
-#[derive(StructOpt)]
-#[structopt()]
+#[derive(Parser)]
+#[clap()]
struct Opt {
- #[structopt(long, default_value = "deck.deck")]
+ /// Path to the deck
+ #[clap(long, default_value = "deck.deck")]
deck: String,
+
+ /// Hide remaining card counts
+ #[clap(long)]
+ hide_remaining: bool,
}
fn main() -> Result<()> {
- let deck_path = Opt::from_args().deck;
+ let args = Opt::parse();
+ let deck_path = args.deck;
let mut conn = db::init(db_path(&deck_path))?;
let deck_name = deck::pp_from_path(&deck_path).unwrap_or_else(|| "Deck".to_string());
sync::run(&mut conn, &deck_path)?;
+ let deck_last_sync = util::time::seconds_since_unix_epoch()?;
+
+ let mut term = gui::setup_terminal()?;
- let mut term = gui::terminal()?;
- let events = Events::new();
- match gui::start(&conn, &mut term, &events, &deck_name) {
- Ok(()) => Ok(()),
+ match gui::start(
+ &mut conn,
+ &mut term,
+ &deck_path,
+ &deck_name,
+ deck_last_sync,
+ args.hide_remaining,
+ ) {
+ Ok(()) => (),
Err(msg) => {
// Show errors in TUI, otherwise they are hidden
- gui::message::show(&mut term, &events, &deck_name, &format!("{msg}"), true)?;
- Err(msg)
+ gui::message::show(&mut term, &deck_name, &format!("{msg}"), true)?
}
}
+
+ gui::restore_terminal(&mut term)
}
fn db_path(deck_path: &str) -> String {
diff --git a/src/model/difficulty.rs b/src/model/difficulty.rs
index ea5a9ce..727ce4b 100644
--- a/src/model/difficulty.rs
+++ b/src/model/difficulty.rs
@@ -1,4 +1,4 @@
-#[derive(Debug, Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Difficulty {
Again,
Hard,
diff --git a/src/model/mod.rs b/src/model/mod.rs
index 2dc1ab5..4df4c49 100644
--- a/src/model/mod.rs
+++ b/src/model/mod.rs
@@ -2,7 +2,7 @@ use crate::space_repetition;
pub mod difficulty;
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub struct Line {
pub part_1: Vec<String>,
pub part_2: Vec<String>,
diff --git a/src/sync.rs b/src/sync.rs
index 3911d55..6e3d84b 100644
--- a/src/sync.rs
+++ b/src/sync.rs
@@ -4,11 +4,12 @@ use crate::{
};
use anyhow::Result;
use rusqlite::Connection;
+use std::collections::HashMap;
use std::collections::HashSet;
pub fn run(conn: &mut Connection, deck_path: &str) -> Result<()> {
let db_entries = db::all(conn)?;
- let lines = deck::read(deck_path)?;
+ let lines = deck::read_file(deck_path)?;
let Diff {
new,
deleted,
@@ -29,29 +30,23 @@ struct Diff {
}
fn diff(db_entries: Vec<DbEntry>, lines: Vec<Line>) -> Diff {
- let mut file_questions: HashSet<Question> = HashSet::new();
- let mut db_questions_not_deleted: HashSet<Question> = HashSet::new();
- let mut db_questions_deleted: HashSet<Question> = HashSet::new();
+ let mut file_questions = HashMap::<String, Vec<String>>::new();
+ let mut db_questions_not_deleted = HashSet::<Question>::new();
+ let mut db_questions_deleted = HashSet::<Question>::new();
for Line { part_1, part_2 } in lines {
- for question in part_1.clone() {
- let mut responses = part_2.clone();
- responses.sort();
- file_questions.insert(Question {
- question,
- responses,
- });
- }
- for question in part_2 {
- let mut responses = part_1.clone();
- responses.sort();
- file_questions.insert(Question {
- question,
- responses,
- });
- }
+ insert(&mut file_questions, part_1.clone(), part_2.clone());
+ insert(&mut file_questions, part_2, part_1);
}
+ let file_questions: HashSet<Question> = file_questions
+ .iter()
+ .map(|(question, responses)| Question {
+ question: question.to_string(),
+ responses: responses.to_vec(),
+ })
+ .collect();
+
for DbEntry {
question,
mut responses,
@@ -97,77 +92,120 @@ fn diff(db_entries: Vec<DbEntry>, lines: Vec<Line>) -> Diff {
}
}
+fn insert(map: &mut HashMap<String, Vec<String>>, questions: Vec<String>, responses: Vec<String>) {
+ for question in questions {
+ let mut responses = responses.clone();
+ responses.sort();
+ match map.get_mut(&question) {
+ Some(existing_responses) => existing_responses.append(&mut responses),
+ None => {
+ map.insert(question, responses);
+ }
+ };
+ }
+}
+
#[cfg(test)]
mod tests {
- use super::*;
+ use super::{deck, DbEntry, Diff, Question};
+ use std::collections::HashSet;
#[test]
- fn sync() {
- let db_entries = vec![
- DbEntry {
- question: "A".to_string(),
- responses: vec!["A".to_string()],
- deleted: None,
- },
- DbEntry {
- question: "B".to_string(),
- responses: vec!["B".to_string()],
- deleted: None,
- },
- DbEntry {
- question: "C".to_string(),
- responses: vec!["C".to_string()],
- deleted: Some(0),
- },
- DbEntry {
- question: "D".to_string(),
- responses: vec!["D".to_string()],
- deleted: Some(0),
- },
- ];
-
- let lines = vec![
- Line {
- part_1: vec!["A".to_string()],
- part_2: vec!["A".to_string()],
- },
- Line {
- part_1: vec!["C".to_string()],
- part_2: vec!["C".to_string()],
- },
- Line {
- part_1: vec!["E".to_string()],
- part_2: vec!["E".to_string()],
- },
- ];
-
- let Res {
- new,
- deleted,
- undeleted,
- } = sync(db_entries, lines);
+ fn test_added() {
+ let diff = deck_diff("- A : a", "- A : a\n- B : b");
- assert_eq!(
- new,
- vec!(Question {
- question: "E".to_string(),
- responses: vec!("E".to_string())
- })
- );
- assert_eq!(
- deleted,
- vec!(Question {
- question: "B".to_string(),
- responses: vec!("B".to_string())
- })
+ has_questions(diff.new, vec![("B", vec!["b"]), ("b", vec!["B"])]);
+ assert!(diff.deleted.is_empty());
+ assert!(diff.undeleted.is_empty());
+ }
+
+ #[test]
+ fn test_updated() {
+ let diff = deck_diff("- A : a1", "- A : a2");
+
+ has_questions(diff.new, vec![("A", vec!["a2"]), ("a2", vec!["A"])]);
+ has_questions(diff.deleted, vec![("A", vec!["a1"]), ("a1", vec!["A"])]);
+ assert!(diff.undeleted.is_empty());
+ }
+
+ #[test]
+ fn test_deleted() {
+ let diff = deck_diff("- A : a", "");
+
+ assert!(diff.new.is_empty());
+ has_questions(diff.deleted, vec![("A", vec!["a"]), ("a", vec!["A"])]);
+ assert!(diff.undeleted.is_empty());
+ }
+
+ #[test]
+ fn test_undeleted() {
+ let db_entries = vec![DbEntry {
+ question: "A".to_string(),
+ responses: vec!["a".to_string()],
+ deleted: Some(0),
+ }];
+
+ let diff = super::diff(db_entries, deck::tests::read_string("- A : a").unwrap());
+
+ has_questions(diff.new, vec![("a", vec!["A"])]);
+ assert!(diff.deleted.is_empty());
+ has_questions(diff.undeleted, vec![("A", vec!["a"])]);
+ }
+ #[test]
+ fn regroup_same_question() {
+ let diff = deck_diff("", "- A : a\n- A | B : b");
+
+ has_questions(
+ diff.new,
+ vec![
+ ("A", vec!["a", "b"]),
+ ("B", vec!["b"]),
+ ("a", vec!["A"]),
+ ("b", vec!["A", "B"]),
+ ],
);
+ assert!(diff.deleted.is_empty());
+ assert!(diff.undeleted.is_empty());
+ }
+
+ fn deck_diff(from: &str, to: &str) -> Diff {
+ super::diff(db_entries(from), deck::tests::read_string(to).unwrap())
+ }
+
+ fn has_questions(questions: Vec<Question>, xs: Vec<(&str, Vec<&str>)>) {
assert_eq!(
- undeleted,
- vec!(Question {
- question: "C".to_string(),
- responses: vec!("C".to_string())
- })
- );
+ to_set(questions),
+ HashSet::from_iter(
+ xs.iter()
+ .map(|(y, ys)| Question {
+ question: y.to_string(),
+ responses: ys.iter().map(|z| z.to_string()).collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>()
+ )
+ )
+ }
+
+ fn to_set<A: std::cmp::Eq + std::hash::Hash + std::clone::Clone>(xs: Vec<A>) -> HashSet<A> {
+ xs.iter().cloned().collect()
+ }
+
+ fn db_entries(deck: &str) -> Vec<DbEntry> {
+ let lines = deck::tests::read_string(deck).unwrap();
+ let diff = super::diff(vec![], lines);
+ diff.new
+ .iter()
+ .map(
+ |Question {
+ question,
+ responses,
+ }| DbEntry {
+ question: question.to_string(),
+ responses: responses.to_vec(),
+ deleted: None,
+ },
+ )
+ .collect()
}
}
diff --git a/src/util/event.rs b/src/util/event.rs
deleted file mode 100644
index 379df99..0000000
--- a/src/util/event.rs
+++ /dev/null
@@ -1,73 +0,0 @@
-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 key in stdin.keys().flatten() {
- 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
index c866e61..3444389 100644
--- a/src/util/mod.rs
+++ b/src/util/mod.rs
@@ -1,4 +1,3 @@
#[allow(dead_code)]
-pub mod event;
pub mod serialization;
pub mod time;
diff --git a/src/util/serialization.rs b/src/util/serialization.rs
index 61b3a83..189a41a 100644
--- a/src/util/serialization.rs
+++ b/src/util/serialization.rs
@@ -8,3 +8,21 @@ pub fn line_to_words(line: &str) -> Vec<String> {
pub fn words_to_line(words: &[String]) -> String {
words.join(" | ")
}
+
+#[cfg(test)]
+mod tests {
+
+ use super::*;
+
+ #[test]
+ fn test_line_to_words() {
+ assert_eq!(line_to_words("a"), vec!("a"));
+ assert_eq!(line_to_words("a | b | c"), vec!("a", "b", "c"));
+ }
+
+ #[test]
+ fn test_words_to_line() {
+ assert_eq!(words_to_line(&["a".to_string()]), "a");
+ assert_eq!(words_to_line(&["a".to_string(), "b".to_string(), "c".to_string()]), "a | b | c");
+ }
+}