Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@

# Review logs, which sit beside whichever deck file they belong to.
*.log

# ...except the ones the golden tests ship as fixtures. Those are not study
# data, they are the input a transcript is checked against, and a case whose
# log is missing fails on a fresh clone while passing on the machine that
# wrote it.
!/tests/golden/cases/*/files/*.log
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
Notable changes per release. Dates are the release date; the PR numbers link the
detail, which is where the reasoning lives.

## Unreleased

### Added

- **`--absorb-conflicts`,** which merges the sync-conflict copies a file-sync
client leaves beside the review log back into it, and brings the deck's
counters and due dates up to date with the reviews they contain. Two machines
reviewing before they sync no longer costs one side's scheduling: the log is
append-only, so neither copy is wrong, and the two are simply unioned by event
id. Syncthing's, Dropbox's and Nextcloud's naming schemes are recognised, and
the copies are read and left in place rather than deleted.

This is what `merge_events()` and `replay()` have been waiting for since #8.
The question that kept `replay()` unwired — how replayed state should meet
counters that predate the log — is answered by replaying the log twice, before
the merge and after it, and applying only the difference. A deck whose history
began before the log keeps it.

## 0.2.0 — 2026-08-17

Audio, a second test suite, and a review screen that fits in a terminal.
Expand Down
74 changes: 64 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ supported for staged installs if you are packaging FlashTerm.
./FlashTerm --help # usage
./FlashTerm --version # version

./FlashTerm deck.txt --absorb-conflicts # merge the sync-conflict copies of
# deck.txt.log back into it

export FLASHTERM_DECK=~/Sync/spanish.txt # the deck to use when none is named
export FLASHTERM_THEME=ocean # default, ocean or sunset
```
Expand Down Expand Up @@ -88,7 +91,7 @@ typed `init`.
* **Multiple accepted answers** — Separate alternatives with `|` — `std::unique_ptr|unique_ptr` — and any of them counts. The first is shown back to you when you miss the card, the rest as also accepted.
* **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. See [Syncing Between Machines](#syncing-between-machines).
* **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).
* **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.
* **Single-keypress menus** — `2` enters review; no Enter, no waiting. Every screen that takes a key shows a legend of what the keys do. Guarded on `isatty`, so piped input still reads whole lines and every script, pipeline and recording keeps working unchanged. `Ctrl+C` at a menu saves and exits cleanly rather than killing the process.
Expand Down Expand Up @@ -377,12 +380,58 @@ The review log behaves better, because appending is not overwriting. Each
machine's log stays complete on its own, and since the file only ever grows,
sync clients handle it far more gracefully than the deck — git in particular
merges append-only files cleanly, where deck lines conflict. So even when a
deck write is lost, the record of *what you actually answered* usually is not.
deck write is lost, the record of *what you actually answered* usually is not
— and the next section is how you get it back.

### Absorbing sync-conflict copies

Two machines appending to the same log is the one case a sync client cannot
resolve on its own. It keeps one version, parks the other beside it under a
name like `spanish.txt.sync-conflict-20260818-101112-K3JQ7ZP.log`, and leaves
it for a human. But neither copy is wrong: they are two halves of one history,
and taking both is simply correct. So FlashTerm does:

```bash
FlashTerm ~/Sync/decks/spanish.txt --absorb-conflicts
```

Every conflict copy beside the log is merged into it by event id — a union,
not a concatenation, so the events both machines already had appear once — the
log is rewritten in timestamp order, and the deck's counters and due dates are
brought up to date with the reviews it just gained:

```
Reading 1 sync-conflict copy of spanish.txt.log:
spanish.txt.sync-conflict-20260818-101112-K3JQ7ZP.log: 4 events
Absorbed 3 new events into spanish.txt.log, now 12 events.
el perro +1 correct, box 2 -> 3, due 2026-08-25
la casa +1 incorrect, due 2026-08-19
Updated 2 cards in spanish.txt.
The copy was left in place. Delete when you are happy with the result:
rm spanish.txt.sync-conflict-20260818-101112-K3JQ7ZP.log
```

Syncthing, Dropbox and Nextcloud all name their copies differently and all
three are recognised, along with anything else whose inserted name contains
"conflict". A `.bak` or a `.tmp` sitting beside the log is not touched.

Two things it deliberately does not do:

* **It does not delete the conflict copies.** That is your call. Absorbing is
idempotent — running it again finds nothing new — so leaving them costs only
disk, while deleting the wrong file costs a history that exists nowhere else.
* **It does not rebuild your counters from the log.** A deck that predates the
log has counters with no events behind them, and replaying it wholesale would
report every such card as brand new. Instead the log is replayed *twice*,
before the merge and after it, and only the difference is applied: your
counters gain exactly what the other machine recorded, and a card's box and
due date follow the merged log only when its newest event is no older than
what the deck already says. History from before the log survives untouched.

Reconstructing the deck from merged logs — the thing that would make concurrent
review on two machines genuinely safe — is deliberately not wired up yet.
`merge_events()` and `replay()` in `src/event.h` are the working, tested halves
of it, waiting on the command that will call them.
If the deck file and the log arrive out of step — they are two files, and
nothing makes a sync client deliver them together — events naming cards this
deck has not received yet are kept in the log and counted the next time you
absorb, once the cards are there.

## Development

Expand All @@ -398,8 +447,10 @@ the scheduling logic (calendar arithmetic, leap years, box intervals, due
dates), the `Deck` persistence layer (atomic writes, write failures, lossless
import/export, legacy-deck migration, statistics), the review log (event
round-trips, damaged lines, card ids, streaks, and merging and replaying two
machines' logs), text layout (column-accurate word wrapping), and command-line
and environment handling.
machines' logs), absorbing sync-conflict copies (which names count as one,
finding them, atomic log rewrites, and the differential replay that leaves
pre-log counters alone), text layout (column-accurate word wrapping), and
command-line and environment handling.

### Golden End-to-End Tests

Expand All @@ -416,8 +467,10 @@ which are hard to reach any other way.

Adding a case means creating a directory under `tests/golden/cases/` with an
`input` file, optionally a starting `deck.txt`, an `args` file, an `env` file of
`KEY=VALUE` lines, and an `audio/` directory for the deck's audio column to
point at, and then:
`KEY=VALUE` lines, an `audio/` directory for the deck's audio column to point
at, and a `files/` directory whose contents are copied in as they are — which is
how a case ships a review log and the conflict copies beside it, whose names the
sync client invents — and then:

```bash
tests/golden/run.sh --update # write the expected transcripts
Expand Down Expand Up @@ -477,6 +530,7 @@ cannot drive an app that insists on a tty.
| `src/generate.*` | `--generate-audio`: rendering a deck's recordings in bulk |
| `src/voice.*` | Finding piper voices on disk, and explaining how to get one |
| `src/event.*` | The append-only review log: events, ids, timestamps, merge and replay |
| `src/sync.*` | `--absorb-conflicts`: finding a sync client's conflict copies and folding them back in |
| `src/deck.*` | The `Deck` class: load, atomic save, import/export, tags, statistics |
| `src/review.*` | Review session flow and the Leitner promotion rules |
| `src/ui.*` | Menus, prompts and the statistics screen |
Expand Down
27 changes: 26 additions & 1 deletion src/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace FlashTerm {
namespace {
constexpr char kOneModeOnly[] =
"--generate-audio and --absorb-conflicts cannot be combined";

CliOptions error(const std::string& message) {
CliOptions options;
options.action = CliAction::Error;
Expand Down Expand Up @@ -34,13 +37,23 @@ std::string usage_text() {
Run it without --voice to be shown which ones are
installed and how to get more.

FlashTerm [deck] --absorb-conflicts
Merge the sync-conflict copies your file-sync tool
left beside the deck's review log back into it, and
bring the deck's counters and due dates up to date
with the reviews they contain. Reads the copies and
leaves them alone; running it twice finds nothing the
second time.

The deck is created if it does not exist, and saved after every answer and
edit, so interrupting a session costs nothing. Import a starter deck from
examples/ with menu option 5.

Every answer is also appended to a review log beside the deck, named after it
with ".log" added, which is where the streak and review-count statistics on the
progress screen come from.
progress screen come from. Because it is only ever appended to, two machines
reviewing before they sync produce two complete halves of one history rather
than a lost one -- which is what --absorb-conflicts puts back together.

Menus take a single keypress when run in a terminal, and fall back to reading
whole lines when input is piped, so scripting still works.
Expand Down Expand Up @@ -83,6 +96,10 @@ CliOptions parse_args(int argc, const char* const argv[],
CliOptions options;
bool options_ended = false;
bool have_deck = false;
// Each of these does one thing to a deck and exits, so two of them is not a
// request that can be honoured -- and picking the last one silently would
// skip whichever the user typed first.
bool mode_given = false;

for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
Expand All @@ -103,7 +120,15 @@ CliOptions parse_args(int argc, const char* const argv[],
// Unlike --help and --version these do not return immediately: both are
// about a deck, so the rest of the command line still has to be read.
if (arg == "--generate-audio") {
if (mode_given) return error(kOneModeOnly);
options.action = CliAction::GenerateAudio;
mode_given = true;
continue;
}
if (arg == "--absorb-conflicts") {
if (mode_given) return error(kOneModeOnly);
options.action = CliAction::AbsorbConflicts;
mode_given = true;
continue;
}
if (arg == "--force") {
Expand Down
1 change: 1 addition & 0 deletions src/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ inline constexpr char kVersion[] = "0.2.0";
enum class CliAction {
RunDeck, // Study `deck_path`.
GenerateAudio, // --generate-audio: render the deck's audio, then exit.
AbsorbConflicts, // --absorb-conflicts: merge the log's conflict copies in.
ShowHelp, // --help: usage to stdout, exit 0.
ShowVersion, // --version: version to stdout, exit 0.
Error, // Bad usage: `error` to stderr, exit 2.
Expand Down
33 changes: 33 additions & 0 deletions src/event.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,39 @@ bool EventLog::append(const ReviewEvent& event, std::string* error) {
return true;
}

bool EventLog::rewrite(const std::vector<ReviewEvent>& events,
std::string* error) {
const std::string tmp_path = path_ + ".tmp";

{
std::ofstream file(tmp_path);
if (!file) {
if (error) *error = "cannot write " + tmp_path + ": " + errno_message();
return false;
}
for (const auto& event : events) {
file << event_to_csv(event) << "\n";
}
file.flush();
if (!file) {
if (error) *error = "failed writing " + tmp_path;
file.close();
std::remove(tmp_path.c_str());
return false;
}
}

if (std::rename(tmp_path.c_str(), path_.c_str()) != 0) {
if (error) *error = "cannot replace " + path_ + ": " + errno_message();
std::remove(tmp_path.c_str());
return false;
}
// Only after the rename, so a failed rewrite leaves the object describing
// what is actually on disk rather than what was meant to be.
events_ = events;
return true;
}

std::vector<ReviewEvent> merge_events(const std::vector<ReviewEvent>& a,
const std::vector<ReviewEvent>& b) {
std::map<std::string, ReviewEvent> by_id;
Expand Down
26 changes: 20 additions & 6 deletions src/event.h
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ class EventLog {
// is kept in memory regardless: failing to log must never cost a review.
bool append(const ReviewEvent& event, std::string* error = nullptr);

// Replaces the whole file with `events`, and the in-memory list with it.
// Written to a temporary file and renamed into place, exactly as Deck::save
// does, because unlike an append this one can leave a half-written log
// behind and the log is the only copy of what actually happened.
//
// Appending is how a log normally grows; this exists for the one operation
// that legitimately rewrites lines already on disk -- absorbing another
// machine's copy, whose events interleave with this one's rather than
// following them.
bool rewrite(const std::vector<ReviewEvent>& events,
std::string* error = nullptr);

private:
std::string path_;
std::vector<ReviewEvent> events_;
Expand All @@ -100,8 +112,7 @@ class EventLog {
// machines' logs become one, and ordering by a field both sides agree on makes
// the result independent of which log was merged into which.
//
// Not yet called by the application: it is the half of file-based sync that
// has to exist before syncing can be more than "last writer wins".
// Called by --absorb-conflicts; see sync.h.
std::vector<ReviewEvent> merge_events(const std::vector<ReviewEvent>& a,
const std::vector<ReviewEvent>& b);

Expand All @@ -110,10 +121,13 @@ std::vector<ReviewEvent> merge_events(const std::vector<ReviewEvent>& a,
// result even though it stays visible in the log.
//
// This is the function that makes the counters a derived cache rather than the
// source of truth. It is deliberately not wired into Deck::load() yet: the
// counters in an existing deck have no events behind them, so replaying today
// would report every deck as brand new. Sync is where this gets turned on,
// against a log that covers the whole history.
// source of truth. It is still not wired into Deck::load(), and for the same
// reason as before: the counters in an existing deck have no events behind
// them, so replaying on load would report every deck as brand new.
// --absorb-conflicts uses it differentially instead -- replaying the log
// before and after a merge and applying only what changed -- so that a deck
// gains what the other machine did without losing what predates the log. See
// sync.h.
std::map<std::string, CardState> replay(const std::vector<ReviewEvent>& events);

struct LogStats {
Expand Down
13 changes: 13 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "deck.h"
#include "generate.h"
#include "review.h"
#include "sync.h"
#include "terminal.h"
#include "text.h"
#include "ui.h"
Expand Down Expand Up @@ -110,6 +111,7 @@ int main(int argc, char* argv[]) {
std::cerr << "FlashTerm: " << options.error << "\n\n" << usage_text();
return 2;
case CliAction::GenerateAudio:
case CliAction::AbsorbConflicts:
case CliAction::RunDeck:
break;
}
Expand All @@ -118,6 +120,17 @@ int main(int argc, char* argv[]) {
std::setlocale(LC_ALL, "");
color::detect();

if (options.action == CliAction::AbsorbConflicts) {
Deck deck(options.deck_path);
// Same rule as --generate-audio below: these operate on a deck that is
// already there, and a mistyped path must not become an empty new deck.
if (!deck.load()) {
std::cerr << "FlashTerm: no deck at " << deck.path() << "\n";
return 2;
}
return absorb_conflicts(deck, std::cout, std::cerr).exit_code();
}

if (options.action == CliAction::GenerateAudio) {
Deck deck(options.deck_path);
// Refusing beats creating an empty deck and reporting nothing to do: the
Expand Down
Loading
Loading