diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fadbc3..036c2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,63 @@ Notable changes per release. Dates are the release date; the PR numbers link the detail, which is where the reasoning lives. +## Unreleased + +### Added + +- **Cloze deletion.** Wrap a word in `{{braces}}` and the card becomes a + sentence with a hole in it: + + ``` + The {{mitochondrion}} is the powerhouse of the cell,,biology + ``` + + is asked as `The [...] is the powerhouse of the cell`, and the finished + sentence is shown back once you have answered. It is a way of *writing* a + card rather than a new kind of card: one Leitner box, one id, one due date, + and tags, hints, undo, editing and the log all work on it unchanged. + + **The answer column is empty**, because the answers live inside the question. + That makes a cloze card a whole card on a single field, so a deck of them is + a file of bare sentences — and it is written back exactly that way rather + than expanded to `sentence,,`, which is the same rule that already keeps a + `question,answer,tags` deck from growing trailing commas. + + The syntax is Anki's, so decks paste across in both directions: `{{text}}`, + `{{c1::text}}`, and `{{c1::text::hint}}` for a nudge shown in place of the + blank. Alternatives work inside a deletion exactly as they do in an answer + column, so `{{c1::powerhouse|mitochondrion}}` accepts either. Repeating a + number makes two places in the sentence into one blank; an unnumbered blank + takes the lowest number nothing else has claimed. + + A hint is only looked for after a `cN::`, which is what lets the short form + hold an answer with a `::` in it — `{{std::vector}}` is one answer, not an + answer of `std` hinted with `vector`. A C++ deck needs that distinction and + the separator alone cannot make it. + + **A sentence with several holes is asked one hole at a time and scheduled + once, on the worst of the answers.** Each blank you finish is filled in for + the next, and what it earned stays on screen underneath. One review and one + log event however many holes the sentence has — otherwise a three-blank + sentence would promote a card three boxes in a single sitting. + + Two things cloze cards deliberately do not do. They are **never asked + reversed**: their answers are already inside their question, so there is + nothing to turn round, and a reversed session asks them forwards and logs + them as `n` — which is also what lets a mixed deck be studied backwards + without splitting it in two. And **`--generate-audio` skips them**, because a + recording is of the question and a cloze question read aloud is the answer + read aloud. The `a` key still works during review: the open blank is spoken + as the word "blank", and the whole sentence is read once the card is done. + + `examples/cloze-science.csv` is twelve cards' worth to copy from, and the + manage list shows a cloze card as it will be asked rather than as a row of + braces. (#29) + +- **A golden case for the in-app help screen.** `h` from the main menu had no + end-to-end coverage at all — the existing `help` case is `--help`, which is a + different screen printed by different code. (#29) + ## 0.3.2 — 2026-08-23 Three ways a deck file could be damaged or a path mistyped, all of them found diff --git a/README.md b/README.md index 31e30eb..5567507 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ any file from `examples/`: | `nato-phonetic.csv` | The phonetic alphabet, all 26 | | `spanish.csv`, `japanese.csv` | Vocabulary, foreign → English | | `colores.csv` | Colours, English → Spanish, **with pictures** | +| `cloze-science.csv` | Sentences with the word taken out, **fill in the blank** | | `general-knowledge.csv` | A bit of everything | Imported cards arrive in Box 1 and are due immediately. Most of the examples are @@ -97,6 +98,10 @@ FlashTerm ~/spanish.txt --generate-audio --voice es_ES-davefx-medium `ja_JA-hi_fi_captain-medium` does the same for `japanese.csv`, though Japanese needs one extra package first — see [docs/audio.md](docs/audio.md). +`cloze-science.csv` is the one to copy if you want [fill-in-the-blank +cards](#cloze-deletion). It has no answer column at all: the answers are the +words in `{{braces}}` inside each sentence. + `colores.csv` is the exception, and the one to copy if you want [pictures](#images): each card carries a colour swatch from `examples/images/`. It also runs **English → Spanish**, against the direction of every other language deck here, @@ -127,6 +132,7 @@ that asks, not the side that answers. * **Undo and fix in place** — After each answer, `u` takes it back — box, scores and due date restored exactly — and `e` edits the card on the spot, which is when you actually notice a bad question. Editing keeps the prompt open, so you can fix a card and *then* undo the answer it cost you. * **Custom decks via CLI** — `./FlashTerm vocabulary.txt` loads any deck file; the default is `flashcards.txt`, or whatever `FLASHTERM_DECK` points at. * **Works with the sync tool you already have** — Decks are plain text and saves are atomic, so Syncthing, Dropbox, `rsync` or git sync a deck between machines with no support needed from FlashTerm. And when two machines review before they sync, `--absorb-conflicts` merges the conflict copy your sync tool left behind back into the review log and puts the scheduling it recorded back on the cards. See [Syncing Between Machines](#syncing-between-machines). +* **Cloze deletion** — Wrap a word in `{{braces}}` and the card becomes a sentence with a hole in it: `The {{mitochondrion}} is the powerhouse of the cell` asks `The [...] is the powerhouse of the cell`. The answer column is empty, because the answers are inside the question — so a deck of them is a file of bare sentences. Anki's syntax, including numbered blanks and per-blank hints, so decks paste across in both directions. A sentence with several holes is asked one hole at a time and scheduled *once*, on the worst of the answers. See [Cloze Deletion](#cloze-deletion). * **Images** — A card can name a picture in the deck's eleventh column, drawn inside the card frame. Terminals that speak the kitty graphics protocol (kitty, Ghostty) need nothing installed at all; everything else draws it as coloured text blocks via [chafa](https://hpjansson.org/chafa/), which works even over `ssh` and inside `tmux`. Aspect ratio is preserved and the picture is fitted to the frame, so a panorama and a portrait both land inside the borders. A deck of pictures still reviews as plain text anywhere that cannot draw them. See [Images](#images). * **Deck statistics** — Success rates, review counts, a box-by-box mastery breakdown with ASCII bars, automatic flagging of your hardest card, and how much you reviewed today alongside your current daily streak. * **Review log** — Every answer is appended to a `deck.txt.log` beside the deck: what was asked, which way round, whether you got it, and when, to the second. The card counters say what a card's state *is*; the log says what actually happened, which is what streaks, retention over time and merging two machines' reviews all need. It is append-only, so it never rewrites history and never conflicts. @@ -217,6 +223,10 @@ so a deck and the audio directory beside it can be moved or synced as one thing. `image` is a picture for the card, resolved the same way. See [Images](#images). +A card whose question holds `{{braces}}` is a +[cloze card](#cloze-deletion): its answers are the words in the braces, its +answer column is empty, and it needs no other column at all. + **Every card is written only as far as the last column it actually uses**, so a deck of plain `question,answer,tags` rows — which is what the examples are, and what you get writing one by hand — is saved back in that form rather than @@ -268,7 +278,9 @@ c9072b87405e0369,2fb76783d6b65f93,2026-08-17T09:50:49Z,n,correct,1,2, * `timestamp` is UTC to the second, so events from two machines sort into one order regardless of timezone. Due dates stay whole days; only the log is finer-grained than that. -* `direction` is `n` for a normal prompt and `r` for a reversed one. +* `direction` is `n` for a normal prompt and `r` for a reversed one. A + [cloze card](#cloze-deletion) is always `n`: it is asked forwards even in a + reversed session. * `result` is `correct`, `partial`, `incorrect`, or `undo`. A `partial` is an answer that needed the hint. An answer you take back with `u` is *recorded* as undone rather than erased — a line that may already have been synced @@ -278,6 +290,112 @@ c9072b87405e0369,2fb76783d6b65f93,2026-08-17T09:50:49Z,n,correct,1,2, Losing or deleting the log costs you the history, not the deck: cards keep their own counters and schedule. +## Cloze Deletion + +Wrap a word in `{{braces}}` and the card becomes a sentence with a hole in it: + +``` +The {{mitochondrion}} is the powerhouse of the cell,,biology +``` + +``` +┌──────────────────────────────────────────┐ +│ Box 1 · new · biology │ +├──────────────────────────────────────────┤ +│ │ +│ The [...] is the powerhouse of the cell│ +│ │ +└──────────────────────────────────────────┘ +``` + +You type `mitochondrion`; the finished sentence is shown back to you. This is a +way of *writing* a card, not a new kind of card: it has one Leitner box, one id +and one due date like every other, and everything else — tags, boxes, the hint +key, undo, the log — works on it unchanged. + +**The answer column is empty**, because the answers are inside the question. A +cloze card is therefore a whole card on one field, so a deck of them is a file +of bare sentences with no commas in sight — and it is written back exactly that +way, never expanded to `sentence,,`. A sentence that *does* contain a comma has +to be quoted, like any other CSV field: + +``` +Water boils at {{100}} °C at sea level +"{{Photosynthesis}} turns light, water and carbon dioxide into sugar" +``` + +### The syntax + +It is Anki's, so decks paste across in both directions. + +| Written | Means | +| --- | --- | +| `{{text}}` | One blank | +| `{{c1::text}}` | The same, numbered | +| `{{c1::text::hint}}` | With a nudge shown in place of the blank | + +`text` may list alternatives with `|` exactly as an answer column does, so +`{{c1::powerhouse|mitochondrion}}` accepts either. + +**Numbers group blanks and order them.** Repeating a number makes two places in +the sentence into one blank, opened and answered together: + +``` +{{c1::Nitrogen}} makes up most of the air we breathe; {{c1::nitrogen}} also fills a bag of crisps +``` + +An unnumbered blank takes the lowest number nothing else has claimed, in the +order it appears — so a sentence that numbers nothing is asked left to right, +and one that numbers everything is asked in the order it asked for. + +**A hint is only looked for after a `cN::`,** which is what lets the short form +hold an answer with a `::` in it: + +``` +Use {{std::vector}} for a dynamic array → answer "std::vector" +Use {{c1::std::vector}} for a dynamic array → answer "std", hint "vector" +Use {{c1::std::vector::}} for a dynamic array → answer "std::vector", no hint +``` + +A `{{` with no closing `}}`, or one with nothing inside it to answer, is not a +deletion: that card stays an ordinary card and its braces are shown as written. + +### Several blanks in one sentence + +They are asked one at a time, within a single presentation of the card. Each +blank you finish is filled in for the next one, and what it earned stays on +screen underneath: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Box 1 · new · geography · blank 2 of 3 │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ Paris is the capital of [...], on the [river] │ +│ │ +└──────────────────────────────────────────────────────────┘ + + ✅ blank 1 Paris +``` + +**The card is then scheduled once, on the worst of the answers** — so a +three-blank sentence cannot promote a card three boxes in one sitting, and one +missed blank counts the sentence wrong. One review, one log event, whatever the +sentence is made of. + +### What cloze cards do not do + +* **They are never asked reversed.** A reversed session shows the answer and + asks for the question; a cloze card's answers are already inside its + question, so there is nothing to turn round. Such cards are asked forwards in + a reversed session — which is also what lets a mixed deck be studied + backwards without splitting it in two first — and the log records them as + `n`, because that is how they were actually asked. +* **`--generate-audio` skips them.** A recording is of the question, and a + cloze question read aloud is the answer read aloud. The `a` key still works + during review: it reads the sentence with the open blank spoken as the word + "blank", and reads the whole thing once the card is done. + ## Audio Press `a` while a card is on screen to hear it. Nothing needs setting up, and @@ -643,6 +761,7 @@ cannot drive an app that insists on a tty. | --- | --- | | `src/flashcard.*` | The `Flashcard` model | | `src/answer.*` | Accepted-answer alternatives and typo-tolerant matching | +| `src/cloze.*` | Fill-in-the-blank cards: parsing `{{deletions}}` and rendering a sentence with one open | | `src/date.*` | Civil-calendar arithmetic and due-date formatting | | `src/schedule.*` | Box intervals, due checks, and the Leitner move for an answer | | `src/text.*` | String, CSV and UTF-8 column helpers (no I/O) | diff --git a/examples/cloze-science.csv b/examples/cloze-science.csv new file mode 100644 index 0000000..8c93633 --- /dev/null +++ b/examples/cloze-science.csv @@ -0,0 +1,12 @@ +The {{c1::mitochondrion}} is the powerhouse of the cell,,science;biology +"{{c1::Photosynthesis}} turns light, water and carbon dioxide into sugar",,science;biology +"DNA is built from four bases: adenine, cytosine, guanine and {{c1::thymine}}",,science;biology +Water boils at {{100}} °C at sea level,,science;physics +Force equals mass times {{c1::acceleration::Newton's second law}},,science;physics +A {{c1::light year}} measures distance rather than time,,science;astronomy +The chemical symbol for gold is {{c1::Au}},,science;chemistry +The largest planet in the Solar System is {{c1::Jupiter}},,science;astronomy +The Earth orbits the {{c1::Sun}} once every {{c2::365}} days,,science;astronomy +{{c1::Nitrogen}} makes up most of the air we breathe; {{c1::nitrogen}} also fills a bag of crisps,,science;chemistry +An atom's {{c1::electrons}} carry a negative charge and its {{c2::protons}} a positive one,,science;chemistry +"Light travels about {{c1::300,000}} kilometres every second in a vacuum",,science;physics diff --git a/src/cloze.cpp b/src/cloze.cpp new file mode 100644 index 0000000..8a3179c --- /dev/null +++ b/src/cloze.cpp @@ -0,0 +1,173 @@ +#include "cloze.h" + +#include +#include + +#include "answer.h" +#include "text.h" + +namespace FlashTerm { +namespace cloze { +namespace { +constexpr char kOpen[] = "{{"; +constexpr char kClose[] = "}}"; +constexpr char kSeparator[] = "::"; +// A blank is numbered "c1", not "c99999999". Capped so that a nonsense number +// cannot overflow the parse; anything above it is simply not a group prefix, +// which leaves the text to be read as an answer like any other. +constexpr int kMaxGroup = 999; + +// Where one deletion sits in the text, and what it holds. Kept per occurrence +// rather than per group because rendering has to put something back at every +// one of them, and two occurrences of one group show their own text when the +// group is not the one being asked. +struct Occurrence { + std::size_t start = 0; + std::size_t length = 0; + int group = 0; // 0 until the second pass numbers an unnumbered deletion + std::string answer; + std::string hint; +}; + +std::vector split_on_separator(const std::string& body) { + std::vector parts; + std::size_t at = 0; + while (true) { + const std::size_t next = body.find(kSeparator, at); + if (next == std::string::npos) { + parts.push_back(body.substr(at)); + return parts; + } + parts.push_back(body.substr(at, next - at)); + at = next + 2; + } +} + +std::string join_on_separator(const std::vector& parts, + std::size_t count) { + std::string joined; + for (std::size_t i = 0; i < count; ++i) { + if (i > 0) joined += kSeparator; + joined += parts[i]; + } + return joined; +} + +// The number in a "c12" prefix, or 0 when the segment is not one. Zero rather +// than a flag because a group is numbered from 1, so 0 already means "none". +int group_prefix(const std::string& segment) { + if (segment.size() < 2 || segment[0] != 'c') return 0; + int value = 0; + for (std::size_t i = 1; i < segment.size(); ++i) { + if (segment[i] < '0' || segment[i] > '9') return 0; + value = value * 10 + (segment[i] - '0'); + if (value > kMaxGroup) return 0; + } + return value; +} + +// Splits what is between the braces into a group, an answer and a hint. +// Returns false when there is no answer left to give, which is what makes +// "{{}}" and "{{c1::}}" ordinary text rather than an unanswerable blank. +// +// The hint is only looked for once a "cN::" prefix has been taken off. That is +// what lets "{{std::vector}}" be an answer containing a "::" rather than an +// answer of "std" hinted with "vector" -- a distinction a C++ deck needs and +// which the separator alone cannot make. Written the long way, +// "{{c1::std::vector::}}" says the same thing with an empty hint. +bool parse_body(const std::string& body, Occurrence* out) { + std::vector parts = split_on_separator(body); + const int numbered = group_prefix(parts.front()); + if (numbered != 0) { + out->group = numbered; + parts.erase(parts.begin()); + if (parts.size() >= 2) { + out->hint = trim(parts.back()); + parts.pop_back(); + } + } + out->answer = trim(join_on_separator(parts, parts.size())); + return !out->answer.empty(); +} + +std::vector scan(const std::string& text) { + std::vector found; + std::size_t at = 0; + while (true) { + const std::size_t open = text.find(kOpen, at); + if (open == std::string::npos) break; + const std::size_t close = text.find(kClose, open + 2); + if (close == std::string::npos) break; + + Occurrence occurrence; + occurrence.start = open; + occurrence.length = close + 2 - open; + if (parse_body(text.substr(open + 2, close - open - 2), &occurrence)) { + found.push_back(occurrence); + } + at = close + 2; + } + + // Second pass: an unnumbered deletion takes the lowest number no numbered + // one has claimed, in the order it appears. Done after the whole text has + // been read because a "{{c1::...}}" further along still owns 1. + std::set taken; + for (const auto& occurrence : found) { + if (occurrence.group != 0) taken.insert(occurrence.group); + } + int next = 1; + for (auto& occurrence : found) { + if (occurrence.group != 0) continue; + while (taken.count(next) != 0) ++next; + occurrence.group = next; + taken.insert(next); + } + return found; +} + +std::string blank_text(const Occurrence& occurrence, Blank style) { + if (style == Blank::kSpoken) return "blank"; + if (occurrence.hint.empty()) return "[...]"; + return "[" + occurrence.hint + "]"; +} +} // namespace + +bool contains(const std::string& text) { return !scan(text).empty(); } + +std::vector deletions(const std::string& text) { + std::vector found = scan(text); + // By group, and by where it appears within a group, so that a group written + // in two places is described by the first of them. + std::stable_sort(found.begin(), found.end(), + [](const Occurrence& a, const Occurrence& b) { + return a.group < b.group; + }); + + std::vector blanks; + for (const auto& occurrence : found) { + if (!blanks.empty() && blanks.back().group == occurrence.group) continue; + Deletion blank; + blank.group = occurrence.group; + blank.answer = occurrence.answer; + blank.hint = occurrence.hint; + blanks.push_back(blank); + } + return blanks; +} + +std::string render(const std::string& text, int group, Blank style) { + const std::vector found = scan(text); + std::string out; + std::size_t at = 0; + for (const auto& occurrence : found) { + out += text.substr(at, occurrence.start - at); + const bool open = (group == kAllGroups) || (occurrence.group == group); + out += open ? blank_text(occurrence, style) + : primary_answer(occurrence.answer); + at = occurrence.start + occurrence.length; + } + out += text.substr(at); + return out; +} +} // namespace cloze +} // namespace FlashTerm diff --git a/src/cloze.h b/src/cloze.h new file mode 100644 index 0000000..641ec73 --- /dev/null +++ b/src/cloze.h @@ -0,0 +1,72 @@ +#pragma once +#include +#include + +namespace FlashTerm { +// Cloze deletion: a card whose question is a sentence with holes in it. +// +// The {{mitochondrion}} is the powerhouse of the cell +// +// asks "The [...] is the powerhouse of the cell" and expects "mitochondrion". +// This is a way of writing a card, not a new kind of card: a cloze line has one +// Leitner box, one id and one due date like any other, and only how it is +// *asked* differs. A sentence with several holes is asked one hole at a time +// within a single presentation, and the card is scheduled once, on the worst of +// the answers -- so a three-blank sentence cannot promote a card three boxes in +// one sitting. +// +// The syntax is Anki's, so decks paste across in both directions and the +// planned Anki import gets it for nothing: +// +// {{text}} one blank +// {{c1::text}} the same, numbered -- repeat the number to make two +// places in the sentence into one blank +// {{c1::text::hint}} with a nudge shown in place of the blank +// +// `text` may carry "|" alternatives exactly as an answer column does, so +// {{c1::powerhouse|mitochondrion}} accepts either. +namespace cloze { + +// `group` values that mean something other than one numbered blank. +constexpr int kNoGroup = 0; // nothing blanked: the sentence as it reads +constexpr int kAllGroups = -1; // every blank open at once + +// One blank. +struct Deletion { + int group = 1; + std::string answer; // as written, "|" alternatives and all + std::string hint; // "" when the deletion gave none +}; + +// True when `text` holds at least one well-formed deletion. A "{{" with no +// closing "}}", or one with nothing inside it to answer, is not one: such a +// line stays an ordinary card and its braces are shown as written, because +// silently turning a card into an unanswerable one is worse than printing a +// brace. +bool contains(const std::string& text); + +// The blanks, one entry per distinct group, ordered by group number. Numbered +// deletions keep the number they were given; unnumbered ones take the lowest +// number not already spoken for, in the order they appear -- so an all-bare +// sentence is asked left to right, and a sentence that numbers its blanks is +// asked in the order it asked for. +std::vector deletions(const std::string& text); + +// How an open blank is drawn. +enum class Blank { + kBox, // "[...]", or "[hint]" where the deletion carries one + kSpoken, // "blank", for a synthesiser that would otherwise read punctuation +}; + +// `text` with the deletions in `group` left open and every other deletion +// replaced by the first of its accepted answers. kNoGroup fills them all in, +// kAllGroups opens them all. +std::string render(const std::string& text, int group, + Blank style = Blank::kBox); + +// The sentence as it reads once the card is done. +inline std::string reveal(const std::string& text) { + return render(text, kNoGroup); +} +} // namespace cloze +} // namespace FlashTerm diff --git a/src/deck.cpp b/src/deck.cpp index 8af4537..a8e8663 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -11,6 +11,7 @@ #include #include +#include "cloze.h" #include "date.h" #include "schedule.h" #include "text.h" @@ -116,8 +117,15 @@ std::string card_to_csv(const Flashcard& card) { // empty. Three columns is the documented short form of a deck, and a // two-column line, though it would read back correctly, is not a shape // anything else in the project produces. - std::size_t last = 2; - for (std::size_t i = 3; i < count; ++i) { + // + // A cloze card is the one exception to the three-column floor: its answers + // live inside its question, so its answer column has nothing to hold and a + // hand-written cloze deck is a file of bare sentences. Writing that back as + // "sentence,," would change every line of it on the first save, which is the + // very thing this rule exists to prevent. + const std::size_t minimum = cloze::contains(card.question) ? 0 : 2; + std::size_t last = minimum; + for (std::size_t i = minimum + 1; i < count; ++i) { if (columns[i] != kAbsent[i]) last = i; } @@ -130,7 +138,20 @@ std::string card_to_csv(const Flashcard& card) { bool card_from_csv(const std::string& line, Flashcard* out) { std::vector fields = parse_csv_line(without_cr(line)); - if (fields.size() < 2) return false; // needs at least a question and answer + // Needs at least a question and an answer -- unless the question is a cloze + // sentence, which carries its answers inside itself and so is a whole card + // on its own. + // + // Tested for a real deletion rather than accepting any single field: without + // that, every line of every text file would be a card, and the rule that + // keeps a mistyped path from being overwritten depends on most lines not + // being ones. A comma still separates columns in a bare cloze line, so a + // sentence containing one has to be quoted like any other field. + if (fields.empty()) return false; + if (fields.size() < 2) { + if (!cloze::contains(fields[0])) return false; + fields.push_back(""); + } const std::string tags_str = (fields.size() >= 3) ? fields[2] : ""; int leitner = parse_int_or(fields, 5, 1); diff --git a/src/generate.cpp b/src/generate.cpp index bd15031..724294f 100644 --- a/src/generate.cpp +++ b/src/generate.cpp @@ -7,6 +7,7 @@ #include #include "audio.h" +#include "cloze.h" #include "text.h" #include "voice.h" @@ -99,6 +100,18 @@ GenerateResult generate_audio(Deck& deck, const std::string& voice, bool force, bool reported = false; for (Flashcard& card : deck.cards()) { + // A recording is of the question, and a cloze question read out is the + // answer read out. There is no one rendering to make either: a sentence + // with three holes is asked three different ways, so this would have to + // record three files against a column that holds one. Skipped rather than + // failed -- a mixed deck should still get recordings for the cards that + // can have them. + if (cloze::contains(card.question)) { + out << " skipped " << card.question << " (cloze)\n"; + ++result.skipped; + continue; + } + const std::string relative = card.audio.empty() ? audio_file_for(card) : card.audio; const std::string absolute = deck.resolve(relative); diff --git a/src/review.cpp b/src/review.cpp index 21462b6..7154d82 100644 --- a/src/review.cpp +++ b/src/review.cpp @@ -12,6 +12,7 @@ #include "answer.h" #include "audio.h" +#include "cloze.h" #include "date.h" #include "event.h" #include "image.h" @@ -303,11 +304,11 @@ CardRefs collect_matches(Deck& deck, const Filters& filters, int today_days) { // Plays whatever the card is currently showing, and says whether anything came // out. In a reversed session what is showing is the answer, and the recording // is deliberately skipped there: the audio column holds a reading of the -// question, which is the very thing being asked for. -bool play_prompt(const Deck& deck, const Flashcard& card, bool reversed) { - const std::string file = reversed ? std::string() : deck.audio_path(card); - const std::string text = - reversed ? primary_answer(card.answer) : card.question; +// question, which is the very thing being asked for. A cloze card skips it for +// the same reason -- the recording is of the whole sentence, holes filled in. +bool play_prompt(const Deck& deck, const Flashcard& card, bool use_recording, + const std::string& text) { + const std::string file = use_recording ? deck.audio_path(card) : std::string(); return audio::play(file, text); } @@ -347,18 +348,100 @@ void print_progress(size_t position, size_t total) { } // "Box 2 · due today · spanish · reversed" -std::string card_summary(const Flashcard& card, int today_days, bool reversed) { +// +// `blank` and `blanks` say which hole of a cloze card is open; a card with one +// hole, and every card that is not cloze, says nothing about it. Worth putting +// here rather than under the frame because it is the one thing that changes +// between the several prompts a single cloze card produces, and the summary +// line is where the eye already goes to find out what it is looking at. +std::string card_summary(const Flashcard& card, int today_days, bool reversed, + std::size_t blank, std::size_t blanks) { std::string summary = "Box " + std::to_string(card.leitner_box) + " · " + describe_due(card.due_date, today_days); if (!card.tags.empty()) { summary += " · " + card.tags_to_string(); } + if (blanks > 1) { + summary += " · blank " + std::to_string(blank + 1) + " of " + + std::to_string(blanks); + } if (reversed) { summary += " · reversed"; } return summary; } +// One thing the user is asked to type. A plain card produces exactly one of +// these; a cloze card produces one per blank, asked in turn within a single +// presentation of the card. +// +// Everything the prompt loop needs is worked out here rather than rediscovered +// inside it, which is what keeps that loop from having to know whether it is +// looking at a cloze card, a reversed one or an ordinary one. +struct Ask { + std::string shown; // what goes inside the frame + std::string expected; // accepted answers, "|" alternatives and all + std::string reveal; // the single answer shown when it is missed + std::string alternatives; // the others, "" when there are none + std::string spoken; // what the audio key reads out + bool use_recording = false; + const char* label = "Your answer: "; +}; + +// A cloze card is never asked backwards. Its answers live inside its question, +// so there is nothing to turn round: reversing it would show the sentence with +// its holes filled in and ask for the sentence with its holes in it. A +// reversed session simply asks such cards forwards, which is also what stops a +// mixed deck from having to be split in two before it can be studied. +std::vector asks_for(const Flashcard& card, bool reversed) { + if (cloze::contains(card.question)) { + std::vector asks; + for (const auto& blank : cloze::deletions(card.question)) { + Ask ask; + ask.shown = cloze::render(card.question, blank.group); + ask.expected = blank.answer; + ask.reveal = primary_answer(blank.answer); + ask.alternatives = alternatives_summary(blank.answer); + ask.spoken = + cloze::render(card.question, blank.group, cloze::Blank::kSpoken); + asks.push_back(ask); + } + return asks; + } + + Ask ask; + if (reversed) { + // A question carries no "|" alternatives, so it is shown verbatim rather + // than having any pipe in it read as a separator. + ask.shown = primary_answer(card.answer); + ask.expected = card.question; + ask.reveal = card.question; + ask.spoken = ask.shown; + ask.label = "Your question: "; + } else { + ask.shown = card.question; + ask.expected = card.answer; + ask.reveal = primary_answer(card.answer); + ask.alternatives = alternatives_summary(card.answer); + ask.spoken = card.question; + ask.use_recording = true; + } + return {ask}; +} + +// The worst of what the blanks of one card earned, because the card is +// scheduled once however many holes it has. Getting two of three right is not +// a correct answer, and a hint taken on any of them is a hint taken. +Outcome worse_of(Outcome a, Outcome b) { + if (a == Outcome::kIncorrect || b == Outcome::kIncorrect) { + return Outcome::kIncorrect; + } + if (a == Outcome::kPartial || b == Outcome::kPartial) { + return Outcome::kPartial; + } + return Outcome::kCorrect; +} + // The card itself, framed. Widths are measured in columns rather than bytes, // so the right-hand border stays put on a Japanese or accented card, which is // the whole reason this is worth drawing at all. @@ -493,6 +576,24 @@ void print_correct_answer(const std::string& shown, const std::string& others) { std::cout << "\n"; } +// What a blank earned, in one line, so that a sentence with several holes can +// show what happened to the ones already answered while the next is open. +// Compact on purpose: this is a reminder, not the verdict, and the full verdict +// still gets printed once the card is done. +std::string blank_verdict(std::size_t blank, Outcome outcome, + const std::string& reveal, const std::string& typed) { + const std::string label = " blank " + std::to_string(blank + 1) + " "; + if (outcome == Outcome::kCorrect) { + return std::string(color::green) + " ✅" + label + reveal + color::reset; + } + if (outcome == Outcome::kPartial) { + return std::string(color::yellow) + " ⚠️ " + label + reveal + + " (hint)" + color::reset; + } + return std::string(color::red) + " ❌" + label + reveal + + " (you typed: " + typed + ")" + color::reset; +} + // What `?` reveals: the first character, with the shape of the rest. Spaces // are kept, so "la biblioteca" comes back as "l· ··········" — enough to jog // the memory and to show how long the answer is, without giving it away. @@ -520,7 +621,8 @@ enum class Action { kContinue, kUndo, kQuit }; // Editing keeps the prompt open, so a card fixed on the spot can still have // its answer taken back in the same breath. -Action prompt_next_action(Deck& deck, Flashcard& card) { +Action prompt_next_action(Deck& deck, Flashcard& card, + const std::string& spoken) { const bool audio_available = audio::available(); while (true) { std::vector hints = {{"Enter", "next card"}}; @@ -539,7 +641,7 @@ Action prompt_next_action(Deck& deck, Flashcard& card) { if (action == "q") return Action::kQuit; if (action == "u") return Action::kUndo; if (audio_available && action == "a") { - if (!audio::play(deck.audio_path(card), card.question)) { + if (!audio::play(deck.audio_path(card), spoken)) { std::cout << color::yellow << "No audio available for this card.\n" << color::reset; } @@ -666,10 +768,14 @@ void report_nothing_due(const Deck& deck, int today_days) { } // namespace std::string prompt_text(const Flashcard& card, bool reversed) { + if (cloze::contains(card.question)) { + return cloze::render(card.question, cloze::kAllGroups); + } return reversed ? primary_answer(card.answer) : card.question; } std::string expected_answer(const Flashcard& card, bool reversed) { + if (cloze::contains(card.question)) return cloze::reveal(card.question); return reversed ? card.question : card.answer; } @@ -716,91 +822,114 @@ void review_flashcards(Deck& deck) { size_t idx = 0; while (idx < matches.size()) { Flashcard& card = matches[idx].get(); - const std::string expected = expected_answer(card, session.reversed); - const std::string question_label = - session.reversed ? "Your question: " : "Your answer: "; - - // "?" asks for a hint and "q" leaves the session. Both are unambiguous - // except on a card that actually accepts them as answers, and there the - // answer wins — asked through the real matcher rather than a string - // compare, so "?|question mark" is graded rather than hinted, and a vim - // deck can still be asked what `q` does. On such a card the key simply is - // not offered, and the legend says so; the session can still be ended from - // the prompt after the answer, which is never ambiguous. - const bool hint_available = !check_answer("?", expected).exact; - const bool quit_available = !check_answer("q", expected).exact; - // Audio is offered on the same terms, plus one more: there has to be - // something on this machine that can make a sound. What it plays is - // whatever is on screen, which is what keeps it from giving the answer - // away in a reversed session. - const bool audio_available = - audio::available() && !check_answer("a", expected).exact; - bool hinted = false; - bool audio_failed = false; + const bool is_cloze = cloze::contains(card.question); + // A cloze card is asked forwards even in a reversed session, so it must not + // be labelled or logged as reversed. What the log records is how the card + // was actually asked, which is the only reading of that column that stays + // true when a mixed deck is studied backwards. + const bool asked_reversed = session.reversed && !is_cloze; + // One entry for an ordinary card, one per hole for a cloze one. Built here + // rather than inside the loop below so that "blank 2 of 3" knows what the + // 3 is before the first blank is asked. + const std::vector asks = asks_for(card, session.reversed); + + // What the blanks already answered earned, redrawn under the frame while + // the rest of the sentence is still being asked. Stays empty for a card + // with a single prompt, which has nothing to carry forward. + std::vector earned; + // Starts at the best and is dragged down by the worst blank, because the + // card is scheduled once however many holes it has. + Outcome outcome = Outcome::kCorrect; bool quit_requested = false; - std::string typed; - while (true) { - clear_screen(); - const std::string summary = - card_summary(card, today_days, session.reversed); - const std::string shown = prompt_text(card, session.reversed); - // Recomputed on every redraw rather than once per card, because the box - // depends on the terminal's size and the terminal can be resized between - // one keypress and the next. - const std::string picture_path = deck.image_path(card); - const image::Placement picture = card_image_box(picture_path); - // Two for the progress bar and its blank line, two for the prompt line - // and the breathing room above it. - centre_vertically(count_frame_lines(summary, shown, picture) + 4); - - print_progress(idx + 1, matches.size()); - std::cout << "\n"; - print_card(summary, shown, picture_path, picture); - std::cout << "\n"; - if (hinted) { - std::cout << color::yellow << "Hint: " << hint_for(expected) << "\n" - << color::reset; - } - // Said on the redraw rather than at the moment of failure, because the - // redraw is what would have wiped it. A missing recording is not worth - // interrupting a review over; it is worth not leaving the user pressing - // a key that appears to do nothing. - if (audio_failed) { - std::cout << color::yellow << "No audio available for this card.\n" - << color::reset; - } - std::vector hints = {{"Enter", "submit"}}; - if (audio_available) hints.push_back({"a", "play audio"}); - if (hint_available && !hinted) hints.push_back({"?", "hint"}); - if (quit_available) hints.push_back({"q", "end session"}); - std::cout << legend(hints) << "\n"; - - typed = prompt(question_label); - const std::string command = to_lowercase(trim(typed)); - if (audio_available && command == "a") { - audio_failed = !play_prompt(deck, card, session.reversed); - continue; // same card, unanswered; playing is not an attempt - } - if (hint_available && !hinted && command == "?") { - hinted = true; - continue; // same card, now with the hint on screen - } - if (quit_available && command == "q") { - // Left unanswered on purpose: walking away from a card must not be - // recorded as getting it wrong. - quit_requested = true; + for (std::size_t blank = 0; blank < asks.size(); ++blank) { + const Ask& ask = asks[blank]; + + // "?" asks for a hint and "q" leaves the session. Both are unambiguous + // except on a card that actually accepts them as answers, and there the + // answer wins — asked through the real matcher rather than a string + // compare, so "?|question mark" is graded rather than hinted, and a vim + // deck can still be asked what `q` does. On such a card the key simply + // is not offered, and the legend says so; the session can still be ended + // from the prompt after the answer, which is never ambiguous. + // + // Asked per blank, not per card: a cloze sentence may well have one hole + // whose answer is "?" and another whose answer is not. + const bool hint_available = !check_answer("?", ask.expected).exact; + const bool quit_available = !check_answer("q", ask.expected).exact; + // Audio is offered on the same terms, plus one more: there has to be + // something on this machine that can make a sound. What it plays is + // whatever is on screen, which is what keeps it from giving the answer + // away in a reversed session or from reading a cloze card's holes out. + const bool audio_available = + audio::available() && !check_answer("a", ask.expected).exact; + bool hinted = false; + bool audio_failed = false; + std::string typed; + while (true) { + clear_screen(); + const std::string summary = card_summary(card, today_days, + asked_reversed, blank, + asks.size()); + // Recomputed on every redraw rather than once per card, because the + // box depends on the terminal's size and the terminal can be resized + // between one keypress and the next. + const std::string picture_path = deck.image_path(card); + const image::Placement picture = card_image_box(picture_path); + // Two for the progress bar and its blank line, two for the prompt line + // and the breathing room above it, and one per blank already answered. + centre_vertically(count_frame_lines(summary, ask.shown, picture) + 4 + + static_cast(earned.size())); + + print_progress(idx + 1, matches.size()); + std::cout << "\n"; + print_card(summary, ask.shown, picture_path, picture); + std::cout << "\n"; + for (const auto& line : earned) std::cout << line << "\n"; + if (hinted) { + std::cout << color::yellow << "Hint: " << hint_for(ask.expected) + << "\n" + << color::reset; + } + // Said on the redraw rather than at the moment of failure, because the + // redraw is what would have wiped it. A missing recording is not worth + // interrupting a review over; it is worth not leaving the user pressing + // a key that appears to do nothing. + if (audio_failed) { + std::cout << color::yellow << "No audio available for this card.\n" + << color::reset; + } + + std::vector hints = {{"Enter", "submit"}}; + if (audio_available) hints.push_back({"a", "play audio"}); + if (hint_available && !hinted) hints.push_back({"?", "hint"}); + if (quit_available) hints.push_back({"q", "end session"}); + std::cout << legend(hints) << "\n"; + + typed = prompt(ask.label); + const std::string command = to_lowercase(trim(typed)); + if (audio_available && command == "a") { + audio_failed = + !play_prompt(deck, card, ask.use_recording, ask.spoken); + continue; // same blank, unanswered; playing is not an attempt + } + if (hint_available && !hinted && command == "?") { + hinted = true; + continue; // same blank, now with the hint on screen + } + if (quit_available && command == "q") { + // Left unanswered on purpose: walking away from a card must not be + // recorded as getting it wrong. A cloze card walked away from + // half-finished is not recorded at all, for the same reason. + quit_requested = true; + } + break; } - break; - } - if (quit_requested) break; + if (quit_requested) break; - const AnswerCheck check = check_answer(typed, expected); - bool counted_correct = check.exact; - if (counted_correct) { - std::cout << color::green << "\n✅ Correct!" << color::reset << "\n"; - } else { - if (check.near_miss) { + const AnswerCheck check = check_answer(typed, ask.expected); + bool counted_correct = check.exact; + if (!counted_correct && check.near_miss) { std::cout << color::yellow << "\n⚠️ Close! The correct answer is: " << check.closest << "\n (You typed: " << typed << ")\n" @@ -815,20 +944,51 @@ void review_flashcards(Deck& deck) { counted_correct = true; } } - if (!counted_correct) { - // A question has no "|" alternatives, so a reversed session shows it - // verbatim rather than treating any pipe in it as a separator. - print_correct_answer( - session.reversed ? card.question : primary_answer(card.answer), - session.reversed ? std::string() : alternatives_summary(card.answer)); + + // Producing the answer only after being shown its first letter is a + // partial, not a clean recall: it holds the box rather than advancing it. + const Outcome blank_outcome = !counted_correct ? Outcome::kIncorrect + : hinted ? Outcome::kPartial + : Outcome::kCorrect; + outcome = worse_of(outcome, blank_outcome); + + if (asks.size() > 1) { + earned.push_back( + blank_verdict(blank, blank_outcome, ask.reveal, trim(typed))); + } else if (counted_correct) { + std::cout << color::green << "\n✅ Correct!" << color::reset << "\n"; + } else { + print_correct_answer(ask.reveal, ask.alternatives); } } + if (quit_requested) break; - // Producing the answer only after being shown its first letter is a - // partial, not a clean recall: it holds the box rather than advancing it. - const Outcome outcome = !counted_correct ? Outcome::kIncorrect - : hinted ? Outcome::kPartial - : Outcome::kCorrect; + if (is_cloze) { + // The whole card at once, now that every hole has been filled. The lines + // under the frame only ever covered the blanks before the last one, so + // this is the first place the card can be read as a whole -- which is + // the thing a cloze card is actually for. + if (earned.size() > 1) { + std::cout << "\n"; + for (const auto& line : earned) std::cout << line << "\n"; + } + std::cout << color::cyan << "\n" << cloze::reveal(card.question) << "\n" + << color::reset; + } + + // A card with one prompt has already said how it went, in the line right + // above. One with several has only said how each blank went, so the thing + // that actually happened -- what the *card* earned -- has to be said out + // loud, or the schedule underneath looks as though it came from nowhere. + if (asks.size() > 1 && outcome == Outcome::kCorrect) { + std::cout << color::green << "\n✅ Every blank correct!" << color::reset + << "\n"; + } else if (asks.size() > 1 && outcome == Outcome::kIncorrect) { + std::cout << color::red + << "\n❌ Counted as incorrect — a sentence is only right when " + "every blank is.\n" + << color::reset; + } if (outcome == Outcome::kPartial) { std::cout << color::yellow << "Counted as a partial — the hint means this card stays " @@ -844,17 +1004,23 @@ void review_flashcards(Deck& deck) { autosave(deck); // Logged as soon as it happens rather than once the user moves on, so // that closing the terminal at the prompt below cannot leave an answer - // that the counters kept but the log never saw. + // that the counters kept but the log never saw. One event per card, not + // one per blank: the log records what a card was scheduled on, and a cloze + // card is scheduled once. const std::string answer_id = - log_answer(deck, card, session.reversed, outcome, result, &log_warned); + log_answer(deck, card, asked_reversed, outcome, result, &log_warned); - const Action action = prompt_next_action(deck, card); + // Nothing is left to give away by now, so the key that reads the card out + // reads all of it: a cloze sentence with its holes filled in, and in a + // reversed session the question that was just revealed. + const Action action = prompt_next_action( + deck, card, is_cloze ? cloze::reveal(card.question) : card.question); if (action == Action::kUndo) { restore_state(&card, before); --tally.bucket_for(outcome); autosave(deck); log_undo(deck, card, answer_id, &log_warned); - continue; // same card, asked again + continue; // same card, asked again from its first blank } ++idx; // this card is done either way; quitting does not un-answer it if (action == Action::kQuit) break; diff --git a/src/review.h b/src/review.h index 8c0ab8e..df9202c 100644 --- a/src/review.h +++ b/src/review.h @@ -16,6 +16,12 @@ void review_flashcards(Deck& deck); // // Only the first accepted answer is used as the prompt, since "git add|add" is // not a sensible thing to show. +// +// A cloze card ignores `reversed` and is described whole: the prompt is its +// sentence with every hole open and the expected answer is the same sentence +// with every hole filled in. Reversing it would mean showing the finished +// sentence and asking for the one with holes in it, which is not a question. +// The review loop asks such a card one hole at a time instead; see cloze.h. std::string prompt_text(const Flashcard& card, bool reversed); std::string expected_answer(const Flashcard& card, bool reversed); } // namespace FlashTerm diff --git a/src/ui.cpp b/src/ui.cpp index 17da525..59e4378 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -6,6 +6,8 @@ #include #include +#include "answer.h" +#include "cloze.h" #include "date.h" #include "event.h" #include "schedule.h" @@ -24,8 +26,30 @@ void draw_bar(int filled, int width) { // `number` is the card's position in the deck, not its position in whatever // list is being shown, so the number stays the same after a search narrows it. +// The blanks of a cloze card, in the order it asks them. What the list shows +// in the answer column, since a cloze card's own answer column is empty. +std::string cloze_answers(const std::string& question) { + std::string answers; + for (const auto& blank : cloze::deletions(question)) { + if (!answers.empty()) answers += ", "; + answers += primary_answer(blank.answer); + } + return answers; +} + void print_card_row(const Flashcard& card, std::size_t number, int today_days) { - std::cout << number << ". " << card.question << " - " << card.answer; + // A cloze card is listed as it will be asked -- holes open, answers beside + // it -- rather than as it is written. A column of raw braces is the one way + // of showing a cloze deck that cannot be read at a glance, and reading the + // deck at a glance is what the list is for. The braces are still there in + // the editor, which is where they are needed. + if (cloze::contains(card.question)) { + std::cout << number << ". " + << cloze::render(card.question, cloze::kAllGroups) << " - " + << cloze_answers(card.question); + } else { + std::cout << number << ". " << card.question << " - " << card.answer; + } if (!card.tags.empty()) { std::cout << " [Tags: " << card.tags_to_string() << "]"; } @@ -146,7 +170,9 @@ void autosave(const Deck& deck) { void add_flashcard(Deck& deck) { std::cout << color::cyan << "\n--- Add a Flashcard ---\n" - << color::reset << "Question, or Enter to cancel.\n"; + << color::reset + << "Question, or Enter to cancel.\n" + "Wrap a word in {{braces}} to make it a blank to fill in.\n"; print_prompt(); std::string question; read_line(question); @@ -158,8 +184,15 @@ void add_flashcard(Deck& deck) { return; } - const std::string answer = prompt( - "Enter answer (separate alternatives with |, e.g. std::vector|vector): "); + // A cloze question already holds its answers, so asking for one more would + // be asking for something the review loop is never going to use. + std::string answer; + if (cloze::contains(question)) { + std::cout << "Cloze card — its answers are the {{blanks}} above.\n"; + } else { + answer = prompt( + "Enter answer (separate alternatives with |, e.g. std::vector|vector): "); + } const std::string tags_str = prompt("Enter tags (semicolon-separated, e.g. math;science): "); @@ -185,9 +218,17 @@ void list_flashcards(const Deck& deck) { void edit_card_fields(Flashcard& card) { const std::string question = prompt("Enter new question (current: " + card.question + "): "); - const std::string answer = - prompt("Enter new answer (current: " + card.answer + - ") [use | to accept alternatives]: "); + // Asked against what the question is about to become, not what it was, so + // that turning a card into a cloze one stops asking for an answer in the + // same breath -- and turning it back asks for one again. + const std::string becomes = question.empty() ? card.question : question; + std::string answer; + if (cloze::contains(becomes)) { + std::cout << "This card's answers are the {{blanks}} in its question.\n"; + } else { + answer = prompt("Enter new answer (current: " + card.answer + + ") [use | to accept alternatives]: "); + } const std::string tags_str = prompt("Enter new tags (semicolon;separated, current: " + card.tags_to_string() + "): "); @@ -475,6 +516,11 @@ void print_help() { "7 - Manage Tags (list all unique tags)\n" "0 - Save and exit\n" "h/? - Show this help screen\n" + << color::cyan << "\nCard types\n" + << color::reset + << " Wrap a word in {{braces}} and the card becomes a sentence with a\n" + " hole in it, answered by filling the hole in. Such a card needs no\n" + " answer column: its answers are the words in the braces.\n" << color::cyan << "\nAt the answer prompt\n" << color::reset << " " << legend({{"?", "hint — reveals the first letter; counts as a partial"}}) diff --git a/tests/golden/cases/add-cancelled/expected b/tests/golden/cases/add-cancelled/expected index 40e2ef1..761f9d8 100644 --- a/tests/golden/cases/add-cancelled/expected +++ b/tests/golden/cases/add-cancelled/expected @@ -15,6 +15,7 @@ Creating a new flashcard deck: deck.txt > --- Add a Flashcard --- Question, or Enter to cancel. +Wrap a word in {{braces}} to make it a blank to fill in. > Cancelled. diff --git a/tests/golden/cases/add-cloze-card/expected b/tests/golden/cases/add-cloze-card/expected new file mode 100644 index 0000000..d15d071 --- /dev/null +++ b/tests/golden/cases/add-cloze-card/expected @@ -0,0 +1,64 @@ +--- output --- +Creating a new flashcard deck: deck.txt + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> +--- Add a Flashcard --- +Question, or Enter to cancel. +Wrap a word in {{braces}} to make it a blank to fill in. +> Cloze card — its answers are the {{blanks}} above. +Enter tags (semicolon-separated, e.g. math;science): Flashcard added! + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> +--- Manage Flashcards --- +[1] List flashcards +[2] Edit a flashcard +[3] Delete a flashcard +[4] Find flashcards +[q] Back to the main menu +> 1. The [...] is the longest river in Africa - Nile [Tags: geography] (Box 1, due new) + + +--- Manage Flashcards --- +[1] List flashcards +[2] Edit a flashcard +[3] Delete a flashcard +[4] Find flashcards +[q] Back to the main menu +> +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> Flashcards saved. Goodbye! +--- exit status --- +0 +--- file deck.txt --- +The {{c1::Nile}} is the longest river in Africa,,geography,0,0,1,,, diff --git a/tests/golden/cases/add-cloze-card/input b/tests/golden/cases/add-cloze-card/input new file mode 100644 index 0000000..1bf01ac --- /dev/null +++ b/tests/golden/cases/add-cloze-card/input @@ -0,0 +1,7 @@ +1 +The {{c1::Nile}} is the longest river in Africa +geography +3 +1 +q +0 diff --git a/tests/golden/cases/eof-mid-session/expected b/tests/golden/cases/eof-mid-session/expected index 38b7be5..85ca6ef 100644 --- a/tests/golden/cases/eof-mid-session/expected +++ b/tests/golden/cases/eof-mid-session/expected @@ -15,6 +15,7 @@ Loaded 1 flashcards from deck.txt > --- Add a Flashcard --- Question, or Enter to cancel. +Wrap a word in {{braces}} to make it a blank to fill in. > Enter answer (separate alternatives with |, e.g. std::vector|vector): Input stream closed/EOF. Flashcards saved. Goodbye! --- exit status --- diff --git a/tests/golden/cases/generate-audio-cloze/args b/tests/golden/cases/generate-audio-cloze/args new file mode 100644 index 0000000..396e853 --- /dev/null +++ b/tests/golden/cases/generate-audio-cloze/args @@ -0,0 +1 @@ +deck.txt --generate-audio diff --git a/tests/golden/cases/generate-audio-cloze/deck.txt b/tests/golden/cases/generate-audio-cloze/deck.txt new file mode 100644 index 0000000..5cee1e0 --- /dev/null +++ b/tests/golden/cases/generate-audio-cloze/deck.txt @@ -0,0 +1,2 @@ +Bonjour,Hello,french,0,0,1,,,1111111111111111 +The {{c1::mitochondrion}} is the powerhouse,,biology,0,0,1,,,2222222222222222 diff --git a/tests/golden/cases/generate-audio-cloze/expected b/tests/golden/cases/generate-audio-cloze/expected new file mode 100644 index 0000000..f957d2b --- /dev/null +++ b/tests/golden/cases/generate-audio-cloze/expected @@ -0,0 +1,15 @@ +--- output --- +Rendering audio for 2 cards in deck.txt + rendered audio/.wav Bonjour + skipped The {{c1::mitochondrion}} is the powerhouse (cloze) + +1 rendered, 1 skipped, 0 failed +--- exit status --- +0 +--- file audio/.wav --- +Bonjour +--- file deck.txt --- +Bonjour,Hello,french,0,0,1,,,,audio/.wav +The {{c1::mitochondrion}} is the powerhouse,,biology,0,0,1,,, +--- file played.txt --- +render: audio/.wav diff --git a/tests/golden/cases/generate-audio-cloze/input b/tests/golden/cases/generate-audio-cloze/input new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/cases/menu-help/deck.txt b/tests/golden/cases/menu-help/deck.txt new file mode 100644 index 0000000..6a913df --- /dev/null +++ b/tests/golden/cases/menu-help/deck.txt @@ -0,0 +1 @@ +Bonjour,Hello,french diff --git a/tests/golden/cases/menu-help/expected b/tests/golden/cases/menu-help/expected new file mode 100644 index 0000000..3c8c544 --- /dev/null +++ b/tests/golden/cases/menu-help/expected @@ -0,0 +1,65 @@ +--- output --- +Loaded 1 flashcards from deck.txt + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> Menu + Menu choices take a single keypress — no Enter. Typed answers, + searches and file paths still read a whole line. +1 - Add flashcard +2 - Review flashcards (Due now, All, by Tags, by Difficulty, or by Leitner Box) +3 - Manage flashcards (list/edit/delete) +4 - Display progress & statistics +5 - Import flashcards (.csv/.txt) +6 - Export flashcards (.csv) +7 - Manage Tags (list all unique tags) +0 - Save and exit +h/? - Show this help screen + +Card types + Wrap a word in {{braces}} and the card becomes a sentence with a + hole in it, answered by filling the hole in. Such a card needs no + answer column: its answers are the words in the braces. + +At the answer prompt + [?] hint — reveals the first letter; counts as a partial + [q] end the session and go back to the menu + Both are ignored on a card that accepts them as answers, so a + deck of vim keys or regex metacharacters still works. + +After answering + [Enter] next card + [e] edit the card you are looking at + [u] undo the answer you just gave + [q] end the session and go back to the menu + +Anywhere + [Ctrl+C] save and exit + Nothing is ever lost by leaving: the deck is saved after every + answer and every edit. + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> Flashcards saved. Goodbye! +--- exit status --- +0 +--- file deck.txt --- +Bonjour,Hello,french diff --git a/tests/golden/cases/menu-help/input b/tests/golden/cases/menu-help/input new file mode 100644 index 0000000..f8bbb84 --- /dev/null +++ b/tests/golden/cases/menu-help/input @@ -0,0 +1,2 @@ +h +0 diff --git a/tests/golden/cases/new-deck-add-card/expected b/tests/golden/cases/new-deck-add-card/expected index c0bd845..f92e516 100644 --- a/tests/golden/cases/new-deck-add-card/expected +++ b/tests/golden/cases/new-deck-add-card/expected @@ -15,6 +15,7 @@ Creating a new flashcard deck: deck.txt > --- Add a Flashcard --- Question, or Enter to cancel. +Wrap a word in {{braces}} to make it a blank to fill in. > Enter answer (separate alternatives with |, e.g. std::vector|vector): Enter tags (semicolon-separated, e.g. math;science): Flashcard added! diff --git a/tests/golden/cases/review-cloze-multi/deck.txt b/tests/golden/cases/review-cloze-multi/deck.txt new file mode 100644 index 0000000..8ce4714 --- /dev/null +++ b/tests/golden/cases/review-cloze-multi/deck.txt @@ -0,0 +1 @@ +"{{c1::Paris}} is the capital of {{c2::France}}, and {{c1::Paris}} sits on the {{c3::Seine::river}}",,geography diff --git a/tests/golden/cases/review-cloze-multi/expected b/tests/golden/cases/review-cloze-multi/expected new file mode 100644 index 0000000..d8926c9 --- /dev/null +++ b/tests/golden/cases/review-cloze-multi/expected @@ -0,0 +1,120 @@ +--- output --- +Loaded 1 flashcards from deck.txt + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> +--- Review --- +[1] Due now (1 card) +[2] All cards +[3] By tag +[4] Difficult only (incorrect > correct) +[5] By Leitner box, weakest first +[q] Back to the main menu +> +--- Prompt Direction --- +[Enter] Normal — question shown, you type the answer +[r] Reversed — answer shown, you type the question +> Progress: [████████████████████] 100% (1/1 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · geography · blank 1 of 3 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ [...] is the capital of France, and [...] sits on the │ +│ Seine │ +│ │ +└──────────────────────────────────────────────────────────────┘ + +[Enter] submit [a] play audio [?] hint [q] end session +Your answer: Progress: [████████████████████] 100% (1/1 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · geography · blank 2 of 3 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Paris is the capital of [...], and Paris sits on the Seine │ +│ │ +└──────────────────────────────────────────────────────────────┘ + + ✅ blank 1 Paris +[Enter] submit [a] play audio [?] hint [q] end session +Your answer: Progress: [████████████████████] 100% (1/1 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · geography · blank 3 of 3 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Paris is the capital of France, and Paris sits on the │ +│ [river] │ +│ │ +└──────────────────────────────────────────────────────────────┘ + + ✅ blank 1 Paris + ❌ blank 2 France (you typed: normandy) +[Enter] submit [a] play audio [?] hint [q] end session +Your answer: Progress: [████████████████████] 100% (1/1 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · geography · blank 3 of 3 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Paris is the capital of France, and Paris sits on the │ +│ [river] │ +│ │ +└──────────────────────────────────────────────────────────────┘ + + ✅ blank 1 Paris + ❌ blank 2 France (you typed: normandy) +Hint: S···· +[Enter] submit [a] play audio [q] end session +Your answer: + ✅ blank 1 Paris + ❌ blank 2 France (you typed: normandy) + ⚠️ blank 3 Seine (hint) + +Paris is the capital of France, and Paris sits on the Seine + +❌ Counted as incorrect — a sentence is only right when every blank is. +Next review in 1 day (). + +[Enter] next card [a] hear the question [e] edit this card +[u] undo this answer [q] end session +> ================================================== + REVIEW COMPLETE +================================================== + Correct: 0 + Incorrect: 1 + Total Reviewed: 1 + Success Rate: 0.00% + Still Due: 0 +================================================== + +[any key] back to the main menu +> +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> Flashcards saved. Goodbye! +--- exit status --- +0 +--- file deck.txt --- +"{{c1::Paris}} is the capital of {{c2::France}}, and {{c1::Paris}} sits on the {{c3::Seine::river}}",,geography,0,1,1,,, +--- file deck.txt.log --- +,,