From 934c0676e1240e84309ef00bcd4cfa8485553be8 Mon Sep 17 00:00:00 2001 From: Aduneer <249940941+Aduneer@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:32:33 +0200 Subject: [PATCH] Cloze deletion: cards that are a sentence with a hole in it Wrap a word in {{braces}} and the card is asked with that word taken out. It is a way of writing a card, not a new kind of card: one Leitner box, one id, one due date, and tags, hints, undo, editing, images 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 card_from_csv accepts a one-field line when -- and only when -- it holds a real deletion, and card_to_csv lowers its three-column floor to one for such a card. Both halves are needed or a deck of bare sentences would be rewritten as "sentence,," the first time it was opened, 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. The one place it needed a decision is the hint separator, which collides with the "::" a C++ deck is made of. Resolved by looking for a hint only after a "cN::" prefix: {{std::vector}} is one answer, {{c1::std::vector}} is Anki's reading of answer-plus-hint, and {{c1::std::vector::}} says so explicitly. A sentence with several holes is asked one hole at a time within a single presentation and scheduled once, on the worst of the answers. The alternative -- one prompt per hole, each moving the box -- would promote a three-blank card three boxes in one sitting. So the review loop now walks a vector per card instead of a single expected answer, which is also what collapsed the reversed and normal paths into one thing built up front rather than rediscovered inside the prompt loop. Two deliberate exclusions. A cloze card is never asked reversed: its answers are already inside its question, so a reversed session asks it forwards and logs it as 'n', because that is how it was actually asked. And --generate-audio skips them, since a recording is of the question and a cloze question read aloud is the answer read aloud; the `a` key still works during review, speaking the open blank as the word "blank". Also here, because they were in the way: - The in-app help screen (`h`) had no golden coverage at all. The existing `help` case is `--help`, which is a different screen printed by different code. Added `menu-help` before touching print_help. - Six cloze golden cases, covering a single blank, three blanks with a hint and a grouped repeat, a reversed session over a mixed deck, undo restoring a part-answered card, per-blank key withholding on a vim deck, and --generate-audio skipping one. --- CHANGELOG.md | 57 +++ README.md | 121 +++++- examples/cloze-science.csv | 12 + src/cloze.cpp | 173 ++++++++ src/cloze.h | 72 ++++ src/deck.cpp | 27 +- src/generate.cpp | 13 + src/review.cpp | 374 +++++++++++++----- src/review.h | 6 + src/ui.cpp | 60 ++- tests/golden/cases/add-cancelled/expected | 1 + tests/golden/cases/add-cloze-card/expected | 64 +++ tests/golden/cases/add-cloze-card/input | 7 + tests/golden/cases/eof-mid-session/expected | 1 + tests/golden/cases/generate-audio-cloze/args | 1 + .../cases/generate-audio-cloze/deck.txt | 2 + .../cases/generate-audio-cloze/expected | 15 + tests/golden/cases/generate-audio-cloze/input | 0 tests/golden/cases/menu-help/deck.txt | 1 + tests/golden/cases/menu-help/expected | 65 +++ tests/golden/cases/menu-help/input | 2 + tests/golden/cases/new-deck-add-card/expected | 1 + .../golden/cases/review-cloze-multi/deck.txt | 1 + .../golden/cases/review-cloze-multi/expected | 120 ++++++ tests/golden/cases/review-cloze-multi/input | 10 + .../cases/review-cloze-reversed/deck.txt | 2 + .../cases/review-cloze-reversed/expected | 95 +++++ .../golden/cases/review-cloze-reversed/input | 9 + .../cases/review-cloze-shadowed-key/deck.txt | 1 + .../cases/review-cloze-shadowed-key/expected | 90 +++++ .../cases/review-cloze-shadowed-key/input | 8 + tests/golden/cases/review-cloze-undo/deck.txt | 1 + tests/golden/cases/review-cloze-undo/expected | 127 ++++++ tests/golden/cases/review-cloze-undo/input | 11 + tests/golden/cases/review-cloze/deck.txt | 1 + tests/golden/cases/review-cloze/expected | 75 ++++ tests/golden/cases/review-cloze/input | 7 + tests/tests.cpp | 215 +++++++++- 38 files changed, 1730 insertions(+), 118 deletions(-) create mode 100644 examples/cloze-science.csv create mode 100644 src/cloze.cpp create mode 100644 src/cloze.h create mode 100644 tests/golden/cases/add-cloze-card/expected create mode 100644 tests/golden/cases/add-cloze-card/input create mode 100644 tests/golden/cases/generate-audio-cloze/args create mode 100644 tests/golden/cases/generate-audio-cloze/deck.txt create mode 100644 tests/golden/cases/generate-audio-cloze/expected create mode 100644 tests/golden/cases/generate-audio-cloze/input create mode 100644 tests/golden/cases/menu-help/deck.txt create mode 100644 tests/golden/cases/menu-help/expected create mode 100644 tests/golden/cases/menu-help/input create mode 100644 tests/golden/cases/review-cloze-multi/deck.txt create mode 100644 tests/golden/cases/review-cloze-multi/expected create mode 100644 tests/golden/cases/review-cloze-multi/input create mode 100644 tests/golden/cases/review-cloze-reversed/deck.txt create mode 100644 tests/golden/cases/review-cloze-reversed/expected create mode 100644 tests/golden/cases/review-cloze-reversed/input create mode 100644 tests/golden/cases/review-cloze-shadowed-key/deck.txt create mode 100644 tests/golden/cases/review-cloze-shadowed-key/expected create mode 100644 tests/golden/cases/review-cloze-shadowed-key/input create mode 100644 tests/golden/cases/review-cloze-undo/deck.txt create mode 100644 tests/golden/cases/review-cloze-undo/expected create mode 100644 tests/golden/cases/review-cloze-undo/input create mode 100644 tests/golden/cases/review-cloze/deck.txt create mode 100644 tests/golden/cases/review-cloze/expected create mode 100644 tests/golden/cases/review-cloze/input 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 --- +,,