diff --git a/.gitignore b/.gitignore index b007fae..0453a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 513f075..7f90f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c85c9c0..ab7902e 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 | diff --git a/src/cli.cpp b/src/cli.cpp index fe5a57a..364f267 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -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; @@ -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. @@ -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]; @@ -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") { diff --git a/src/cli.h b/src/cli.h index e134a81..3183a1f 100644 --- a/src/cli.h +++ b/src/cli.h @@ -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. diff --git a/src/event.cpp b/src/event.cpp index e27ea47..d609301 100644 --- a/src/event.cpp +++ b/src/event.cpp @@ -228,6 +228,39 @@ bool EventLog::append(const ReviewEvent& event, std::string* error) { return true; } +bool EventLog::rewrite(const std::vector& 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 merge_events(const std::vector& a, const std::vector& b) { std::map by_id; diff --git a/src/event.h b/src/event.h index 8de6ec5..6004db0 100644 --- a/src/event.h +++ b/src/event.h @@ -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& events, + std::string* error = nullptr); + private: std::string path_; std::vector events_; @@ -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 merge_events(const std::vector& a, const std::vector& b); @@ -110,10 +121,13 @@ std::vector merge_events(const std::vector& 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 replay(const std::vector& events); struct LogStats { diff --git a/src/main.cpp b/src/main.cpp index f882cbe..6d704fc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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" @@ -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; } @@ -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 diff --git a/src/sync.cpp b/src/sync.cpp new file mode 100644 index 0000000..f23d218 --- /dev/null +++ b/src/sync.cpp @@ -0,0 +1,295 @@ +#include "sync.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include "date.h" +#include "event.h" +#include "schedule.h" +#include "text.h" + +namespace FlashTerm { +namespace { +// Columns the question gets before the change description starts, so a run +// over a few cards reads as a table rather than a ragged list. Measured in +// terminal columns, so a CJK deck lines up too. +constexpr std::size_t kQuestionWidth = 32; + +bool starts_with(const std::string& text, const std::string& prefix) { + return text.size() >= prefix.size() && + text.compare(0, prefix.size(), prefix) == 0; +} + +bool ends_with(const std::string& text, const std::string& suffix) { + return text.size() >= suffix.size() && + text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// The directory to list, the prefix that turns an entry in it back into a +// usable path, and the file name to match against. A path with no slash lives +// in the working directory, which opendir spells "." and paths spell "". +void split_path(const std::string& path, std::string* dir, std::string* prefix, + std::string* name) { + const std::size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) { + *dir = "."; + *prefix = ""; + *name = path; + return; + } + *dir = (slash == 0) ? std::string("/") : path.substr(0, slash); + *prefix = path.substr(0, slash + 1); + *name = path.substr(slash + 1); +} + +const CardState& state_of(const std::map& states, + const std::string& card_id) { + static const CardState kNothingYet; + const auto found = states.find(card_id); + return (found == states.end()) ? kNothingYet : found->second; +} + +bool same_schedule(const CardState& a, const CardState& b) { + return a.leitner_box == b.leitner_box && a.last_reviewed == b.last_reviewed && + a.due_date == b.due_date; +} + +std::string join(const std::vector& parts) { + std::string joined; + for (const auto& part : parts) { + if (!joined.empty()) joined += ", "; + joined += part; + } + return joined; +} + +// Signed, because a delta can go down: a conflict copy may contain the undo of +// an answer this machine already counted, and "-1 correct" is what that is. +std::string signed_count(int delta, const char* what) { + return (delta > 0 ? "+" : "") + std::to_string(delta) + " " + what; +} +} // namespace + +bool is_conflict_copy(const std::string& log_name, + const std::string& candidate) { + if (candidate == log_name || candidate.size() <= log_name.size()) return false; + + const std::size_t dot = log_name.find_last_of('.'); + const std::string stem = + (dot == std::string::npos) ? log_name : log_name.substr(0, dot); + const std::string extension = + (dot == std::string::npos) ? std::string() : log_name.substr(dot); + + std::string inserted; + if (starts_with(candidate, log_name)) { + // Appended: "spanish.txt.log.sync-conflict-...". + inserted = candidate.substr(log_name.size()); + } else if (!extension.empty() && starts_with(candidate, stem) && + ends_with(candidate, extension)) { + // Inserted before the extension, which is what Syncthing and Dropbox + // actually do: "spanish.txt.sync-conflict-....log". + inserted = candidate.substr( + stem.size(), candidate.size() - stem.size() - extension.size()); + } else { + return false; + } + + // The word itself is the test, rather than each client's exact spelling. + // Every one of them says "conflict" somewhere, none of them says it by + // accident, and a rule that has to be extended per sync tool is a rule that + // will be out of date the first time someone uses a different one. + return to_lowercase(inserted).find("conflict") != std::string::npos; +} + +std::vector find_conflict_copies(const std::string& log_path) { + std::string dir; + std::string prefix; + std::string name; + split_path(log_path, &dir, &prefix, &name); + + std::vector found; + DIR* handle = opendir(dir.c_str()); + if (handle == nullptr) return found; + while (const dirent* entry = readdir(handle)) { + const std::string candidate = entry->d_name; + if (is_conflict_copy(name, candidate)) found.push_back(prefix + candidate); + } + closedir(handle); + + std::sort(found.begin(), found.end()); + return found; +} + +AbsorbResult absorb_conflicts(Deck& deck, std::ostream& out, + std::ostream& errors) { + AbsorbResult result; + const std::string log_path = deck.log().path(); + + const std::vector copies = find_conflict_copies(log_path); + result.copies = static_cast(copies.size()); + if (copies.empty()) { + out << "No sync-conflict copies of " << log_path << " to absorb.\n"; + return result; + } + + out << "Reading " + << count_label(result.copies, "sync-conflict copy", "sync-conflict copies") + << " of " << log_path << ":\n"; + + const std::vector ours = deck.log().events(); + std::vector merged = ours; + for (const auto& copy : copies) { + EventLog other(copy); + // The copy was found by listing the directory, so failing to read it means + // something is wrong with the file rather than with the guess that it + // exists -- and absorbing part of a conflict is worse than absorbing none. + if (!other.load()) { + errors << "FlashTerm: cannot read " << copy << "\n"; + result.failed = true; + return result; + } + out << " " << copy << ": " + << count_label(static_cast(other.events().size()), "event", + "events") + << "\n"; + merged = merge_events(merged, other.events()); + } + + result.absorbed = + static_cast(merged.size()) - static_cast(ours.size()); + if (result.absorbed <= 0) { + out << "Nothing new: " << log_path << " already has every event in " + << ((result.copies == 1) ? "that copy" : "those copies") << ".\n"; + return result; + } + + std::string error; + if (!deck.log().rewrite(merged, &error)) { + errors << "FlashTerm: " << error << "\n"; + result.failed = true; + return result; + } + out << "Absorbed " + << count_label(result.absorbed, "new event", "new events") << " into " + << log_path << ", now " + << count_label(static_cast(merged.size()), "event", "events") + << ".\n"; + + // The whole point of replaying twice: the difference between the two is + // exactly what the other machine did, and nothing else. Applying `after` on + // its own would quietly throw away every review a deck did before the log + // existed, which is the reason replay() has never been wired into load(). + const std::map before = replay(ours); + const std::map after = replay(merged); + + std::set in_deck; + for (const auto& card : deck.cards()) in_deck.insert(card.id); + for (const auto& entry : after) { + if (in_deck.count(entry.first) > 0) continue; + const CardState& was = state_of(before, entry.first); + if (was.times_correct != entry.second.times_correct || + was.times_incorrect != entry.second.times_incorrect || + !same_schedule(was, entry.second)) { + ++result.unknown_cards; + } + } + + for (auto& card : deck.cards()) { + const auto found = after.find(card.id); + if (found == after.end()) continue; + const CardState& now = found->second; + const CardState& was = state_of(before, card.id); + + const int gained_correct = now.times_correct - was.times_correct; + const int gained_incorrect = now.times_incorrect - was.times_incorrect; + if (gained_correct == 0 && gained_incorrect == 0 && + same_schedule(was, now)) { + continue; + } + + std::vector changes; + card.times_correct += gained_correct; + card.times_incorrect += gained_incorrect; + if (gained_correct != 0) { + changes.push_back(signed_count(gained_correct, "correct")); + } + if (gained_incorrect != 0) { + changes.push_back(signed_count(gained_incorrect, "incorrect")); + } + + // Whether the merged log gets to say what this card's schedule is. + // + // It does when this machine's log already had events for the card: replay + // is then the whole story for it, and following the log backwards is right + // too, since an absorbed undo takes an answer back rather than adding one. + // + // It does not when the deck's own state is newer than anything the log + // knows about, which is what a card reviewed before the log existed looks + // like: an older event is not evidence about today's schedule, however new + // it is to this machine. + const bool log_covers_card = before.find(card.id) != before.end(); + if (!same_schedule(was, now) && + (log_covers_card || now.last_reviewed >= card.last_reviewed)) { + if (now.leitner_box != card.leitner_box) { + changes.push_back("box " + std::to_string(card.leitner_box) + " -> " + + std::to_string(now.leitner_box)); + } + if (now.due_date != card.due_date) { + changes.push_back("due " + format_date(now.due_date)); + } + card.leitner_box = now.leitner_box; + card.last_reviewed = now.last_reviewed; + card.due_date = now.due_date; + } + if (changes.empty()) continue; + + ++result.cards_updated; + out << " " + << pad_right(truncate(card.question, kQuestionWidth), kQuestionWidth) + << " " << join(changes) << "\n"; + } + + if (result.cards_updated > 0) { + if (!deck.save(&error)) { + errors << "FlashTerm: could not save " << deck.path() << ": " << error + << "\n"; + result.failed = true; + return result; + } + out << "Updated " + << count_label(result.cards_updated, "card", "cards") << " in " + << deck.path() << ".\n"; + } else { + out << "No card in this deck changed.\n"; + } + + // Not a problem to fix: a deck and its log are two files, and nothing makes + // them arrive in the same order. Saying so beats an unexplained gap between + // what the log records and what the deck counts. + if (result.unknown_cards > 0) { + out << count_label(result.unknown_cards, "card", "cards") + << " named by those events " + << ((result.unknown_cards == 1) ? "is" : "are") + << " not in this deck.\n" + << ((result.unknown_cards == 1) ? "Its" : "Their") + << " events stay in the log, and will count once the deck catches " + "up.\n"; + } + + // Left in place on purpose: absorbing again finds nothing new, so keeping + // them costs only disk, while deleting the wrong file costs a history that + // exists nowhere else. + out << "The " << ((result.copies == 1) ? "copy was" : "copies were") + << " left in place. Delete when you are happy with the result:\n"; + for (const auto& copy : copies) { + out << " rm " << copy << "\n"; + } + return result; +} +} // namespace FlashTerm diff --git a/src/sync.h b/src/sync.h new file mode 100644 index 0000000..4fe5e85 --- /dev/null +++ b/src/sync.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include + +#include "deck.h" + +namespace FlashTerm { + +// Absorbing the copies a file-sync client leaves behind when two machines both +// appended to the review log. +// +// This is the first thing that actually calls merge_events() and replay(), and +// it is deliberately the smallest thing that could: no --sync command, no git, +// no network. Syncthing and friends already move the files; when they cannot +// decide which version won they park the loser beside the winner and leave it +// for a human. For a deck that is exactly the wrong outcome and exactly the +// easy one to fix, because the log is append-only -- neither copy is wrong, +// they are two halves of one history. + +// True when `candidate` is a sync client's copy of `log_name`, both plain file +// names with no directory. Clients disagree about where the marker goes: +// Syncthing and Dropbox insert it before the final extension, so a copy of +// "spanish.txt.log" arrives as "spanish.txt.sync-conflict-20260817-101112-K3J.log" +// or "spanish.txt (laptop's conflicted copy 2026-08-17).log", while others +// simply append. Both shapes are accepted, and the inserted text has to +// contain the word "conflict" -- which is what keeps "spanish.txt.log.bak" and +// the ".tmp" of an interrupted rewrite out of it. +bool is_conflict_copy(const std::string& log_name, const std::string& candidate); + +// Conflict copies of `log_path` in the directory it lives in, as full paths in +// name order. Name order rather than discovery order because a directory +// listing has none worth relying on, and the run has to be reproducible. +std::vector find_conflict_copies(const std::string& log_path); + +struct AbsorbResult { + int copies = 0; // conflict files read + int absorbed = 0; // events the log did not already have + int cards_updated = 0; // cards whose counters or schedule moved + int unknown_cards = 0; // card ids the absorbed events name and the deck has not + bool failed = false; + + int exit_code() const { return failed ? 1 : 0; } +}; + +// Merges every conflict copy beside the deck's log into the log, rewrites it, +// and brings the deck's counters up to date with what the other machine did. +// +// How replayed state meets counters that predate the log -- the question that +// kept replay() unwired -- is answered by never trusting replay absolutely. +// The log is replayed twice, before the merge and after it, and only the +// *difference* is applied: counters gain what the other machine recorded, and +// a card's box and due date follow the merged log only when its last event is +// no older than what the deck already says. A deck whose history began before +// the log keeps it. +// +// The conflict copies are read and left alone. Deleting them is the user's +// call, and absorbing is idempotent -- a second run finds nothing new -- so +// leaving them costs only disk. +AbsorbResult absorb_conflicts(Deck& deck, std::ostream& out, + std::ostream& errors); +} // namespace FlashTerm diff --git a/tests/golden/cases/absorb-and-generate/args b/tests/golden/cases/absorb-and-generate/args new file mode 100644 index 0000000..0eebe40 --- /dev/null +++ b/tests/golden/cases/absorb-and-generate/args @@ -0,0 +1 @@ +deck.txt --generate-audio --absorb-conflicts diff --git a/tests/golden/cases/absorb-and-generate/expected b/tests/golden/cases/absorb-and-generate/expected new file mode 100644 index 0000000..6c14912 --- /dev/null +++ b/tests/golden/cases/absorb-and-generate/expected @@ -0,0 +1,71 @@ +--- output --- +FlashTerm: --generate-audio and --absorb-conflicts cannot be combined + +FlashTerm - spaced-repetition flashcards in your terminal. + +Usage: + FlashTerm [deck] Study `deck` (default: flashcards.txt). + FlashTerm -- Study a deck whose name starts with a dash. + FlashTerm --help Show this help and exit. + FlashTerm --version Show the version and exit. + + FlashTerm [deck] --generate-audio --voice [--force] + Render a sound file for every card's question into an + audio/ directory beside the deck, and record each one + in the deck's audio column, so that reviewing plays a + real voice instead of a robotic one. Cards that + already have a recording are skipped unless --force + is given. + + is a piper voice, such as fr_FR-siwis-medium. + 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. 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. + +Environment: + FLASHTERM_DECK Deck to study when none is named on the command line. Handy + when the deck lives in a synced directory. A deck given as + an argument still wins. + FLASHTERM_THEME Colour palette: default, ocean or sunset. + NO_COLOR Set to any value to disable coloured output, whatever the + theme says. + FLASHTERM_VOICES Extra directories to search for piper voices, separated by + colons like PATH. The usual places are searched anyway. + FLASHTERM_TTS_RENDER + A synthesiser other than piper for --generate-audio, which + it then uses instead of --voice: "{out}" is replaced with + the path to write and the text arrives on standard input. + espeak-ng -v fr --stdin -w {out} + FLASHTERM_TTS Command that speaks the text given as its last argument, for + cards with no recording of their own. Defaults to whichever + of espeak-ng, espeak, say or flite is installed. Add + arguments to pick a voice: "espeak-ng -v fr". + FLASHTERM_PLAYER Command that plays the audio file given as its last + argument. Defaults to whichever of mpv, ffplay, paplay, + pw-play, mpg123, afplay or aplay is installed. + +Audio is optional in every sense: nothing is linked against, nothing is +installed, and if none of the above is on the PATH then review simply does not +offer the key. Press "a" during a review to hear the card. +--- exit status --- +2 diff --git a/tests/golden/cases/absorb-and-generate/input b/tests/golden/cases/absorb-and-generate/input new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/cases/absorb-conflicts-no-deck/args b/tests/golden/cases/absorb-conflicts-no-deck/args new file mode 100644 index 0000000..f280afc --- /dev/null +++ b/tests/golden/cases/absorb-conflicts-no-deck/args @@ -0,0 +1 @@ +missing.txt --absorb-conflicts diff --git a/tests/golden/cases/absorb-conflicts-no-deck/expected b/tests/golden/cases/absorb-conflicts-no-deck/expected new file mode 100644 index 0000000..5f85f22 --- /dev/null +++ b/tests/golden/cases/absorb-conflicts-no-deck/expected @@ -0,0 +1,4 @@ +--- output --- +FlashTerm: no deck at missing.txt +--- exit status --- +2 diff --git a/tests/golden/cases/absorb-conflicts-no-deck/input b/tests/golden/cases/absorb-conflicts-no-deck/input new file mode 100644 index 0000000..e69de29 diff --git a/tests/golden/cases/absorb-conflicts-none/args b/tests/golden/cases/absorb-conflicts-none/args new file mode 100644 index 0000000..e7d6293 --- /dev/null +++ b/tests/golden/cases/absorb-conflicts-none/args @@ -0,0 +1 @@ +deck.txt --absorb-conflicts diff --git a/tests/golden/cases/absorb-conflicts-none/deck.txt b/tests/golden/cases/absorb-conflicts-none/deck.txt new file mode 100644 index 0000000..f2cb428 --- /dev/null +++ b/tests/golden/cases/absorb-conflicts-none/deck.txt @@ -0,0 +1 @@ +el perro,the dog,animals,1,0,2,2026-08-17,2026-08-18,card0000000000001, diff --git a/tests/golden/cases/absorb-conflicts-none/expected b/tests/golden/cases/absorb-conflicts-none/expected new file mode 100644 index 0000000..d632bb5 --- /dev/null +++ b/tests/golden/cases/absorb-conflicts-none/expected @@ -0,0 +1,10 @@ +--- output --- +No sync-conflict copies of deck.txt.log to absorb. +--- exit status --- +0 +--- file deck.txt --- +el perro,the dog,animals,1,0,2,,,card0000000000001, +--- file deck.txt.log --- +,card0000000000001,