diff --git a/CHANGELOG.md b/CHANGELOG.md index 3813d75..ba0c730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,43 @@ Notable changes per release. Dates are the release date; the PR numbers link the detail, which is where the reasoning lives. +## Unreleased + +Three ways a deck file could be damaged or a path mistyped, all of them found +by pointing the app at files nobody had thought to point it at. + +### Fixed + +- **Lines that are not cards are no longer deleted.** Anything in a deck file + that did not parse as a card was dropped on load, and the next save — after + a single answered card — wrote the deck back without it. A heading, a note to + yourself, a line with a typo in it: gone, silently, with nothing on screen to + say so. + + The worst version was a mistyped path. `FlashTerm ~/notes.txt` opened + happily, reported "Loaded 0 flashcards", and replaced the file's entire + contents with the first card you added to it. + + Non-card lines are now carried through load and save untouched, anchored to + the card they sat above so headings stay above their section and notes stay + at the bottom. Opening a deck that has some now says so once, on the way in, + which is also what tells you the file was never a deck. + + Blank lines are carried the same way, so a deck with sections in it round + trips byte for byte and a save that changes nothing still writes nothing. + +- **A deck path that cannot hold a deck is refused, before the menu.** Naming a + directory loaded an empty deck and opened as normal; so did naming a file + under a directory that does not exist. Either way the problem only surfaced + as a failed save, after a card had been typed in. Both are checked up front + now, for every mode, and exit 2 with the reason. + +- **CRLF decks load clean.** A deck written on Windows, or exported by a + spreadsheet, kept the carriage return as part of each answer. It was + invisible on screen, but it went back to disk as a quoted `"hello\r"` and + stayed there. The line ending is now stripped where the line is parsed, which + covers imports as well as decks, and writing normalises to `\n`. + ## 0.3.1 — 2026-08-20 Two fixes and a second platform. Nothing here changes what FlashTerm does or diff --git a/README.md b/README.md index 5a0d94d..31e30eb 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,28 @@ the whole file in conflict. Trailing columns only: a card with a picture and no recording still writes the empty audio column, because position is what names a field in a CSV. +**A line that is not a card is kept, not dropped.** Blank lines, headings and +notes to yourself survive being loaded and saved, in the position they were +written: + +``` +Chapter one: greetings +Bonjour,Hello,french + +TODO: add the numbers +Au revoir,Goodbye,french +``` + +A deck is a text file you are meant to edit by hand, and a tool that quietly +deletes the lines it does not understand is not one you can leave a file with. +The same rule is what protects you from a mistyped path: point FlashTerm at +something that was never a deck and it says so on the way in, and whatever else +it holds is still there afterwards. + +Line endings are normalised to `\n` when the deck is next written, so a deck +authored on Windows or exported from a spreadsheet does not end up with a +stray carriage return glued to the end of every answer. + ### Review Log Format Alongside `mydeck.txt`, FlashTerm keeps `mydeck.txt.log`: one CSV record per diff --git a/src/deck.cpp b/src/deck.cpp index f9d62c3..8af4537 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -1,5 +1,7 @@ #include "deck.h" +#include + #include #include #include @@ -28,8 +30,49 @@ int parse_int_or(const std::vector& fields, size_t index, std::string errno_message() { return std::strerror(errno); } + +// The line without the carriage return a CRLF file leaves on the end of it. +// One only: a "\r" anywhere else is content, and a field really holding one +// went through the CSV quoting like anything else would. +std::string without_cr(const std::string& line) { + if (!line.empty() && line.back() == '\r') return line.substr(0, line.size() - 1); + return line; +} + +// The directory a file would be created in. "." for a bare name, and "/" for +// a path directly beneath the root, both of which the naive substr gets wrong. +std::string parent_directory(const std::string& path) { + const std::size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) return "."; + if (slash == 0) return "/"; + return path.substr(0, slash); +} } // namespace +std::string deck_path_error(const std::string& path) { + struct stat info; + if (stat(path.c_str(), &info) == 0) { + if (S_ISDIR(info.st_mode)) { + return path + " is a directory, not a deck file"; + } + // Anything else that exists is worth trying to read. Whether it can be + // read or written is a question for the read and the write, which report + // it far better than a guess here would. + return ""; + } + + // Not there yet, which is how every deck starts -- but only if there is + // somewhere to put it. + const std::string directory = parent_directory(path); + if (stat(directory.c_str(), &info) != 0) { + return "cannot create " + path + ": there is no directory " + directory; + } + if (!S_ISDIR(info.st_mode)) { + return "cannot create " + path + ": " + directory + " is not a directory"; + } + return ""; +} + std::string card_to_csv(const Flashcard& card) { const std::string columns[] = { escape_csv_field(card.question), @@ -86,7 +129,7 @@ std::string card_to_csv(const Flashcard& card) { } bool card_from_csv(const std::string& line, Flashcard* out) { - std::vector fields = parse_csv_line(line); + std::vector fields = parse_csv_line(without_cr(line)); if (fields.size() < 2) return false; // needs at least a question and answer const std::string tags_str = (fields.size() >= 3) ? fields[2] : ""; @@ -126,6 +169,7 @@ std::string Deck::resolve(const std::string& relative) const { bool Deck::load() { cards_.clear(); + foreign_.clear(); // A deck with no log yet is the normal starting state, so its absence is not // reported: an empty log and existing counters is a valid deck. log_.load(); @@ -150,11 +194,16 @@ bool Deck::load() { std::istringstream lines(on_disk_); std::string line; while (std::getline(lines, line)) { - if (trim(line).empty()) continue; + line = without_cr(line); Flashcard card("", ""); - if (card_from_csv(line, &card)) { + if (!trim(line).empty() && card_from_csv(line, &card)) { cards_.push_back(card); + continue; } + // Not a card, so it is somebody's comment, heading or blank separator -- + // or a sign that this file was never a deck. Either way it is kept and + // written back rather than dropped; see ForeignLine. + foreign_.push_back({cards_.size(), line}); } // Note that ids are deliberately *not* minted here; see Deck::ensure_id. @@ -166,10 +215,34 @@ bool Deck::load() { bool Deck::save(std::string* error) const { std::string content; - for (const auto& card : cards_) { - content += card_to_csv(card); + std::size_t next_foreign = 0; + + // Everything that was not a card goes back where it was, ahead of the card + // it sat above. Written first for each position, so a file that has not been + // touched comes back out byte for byte -- which is what keeps the no-op + // check below working on a deck with comments in it. + const auto emit_foreign_before = [&](std::size_t card_index) { + while (next_foreign < foreign_.size() && + foreign_[next_foreign].before_card <= card_index) { + content += foreign_[next_foreign].text; + content += "\n"; + ++next_foreign; + } + }; + + for (std::size_t i = 0; i < cards_.size(); ++i) { + emit_foreign_before(i); + content += card_to_csv(cards_[i]); content += "\n"; } + // Everything still left, unconditionally: notes at the bottom of the file + // anchor past the last card, and so does every one of them once enough cards + // have been deleted that no anchor can still be reached. + while (next_foreign < foreign_.size()) { + content += foreign_[next_foreign].text; + content += "\n"; + ++next_foreign; + } // A write that would reproduce the file exactly is not a save. Every call // site saves unconditionally -- after each answer, after each edit, and on @@ -213,6 +286,14 @@ bool Deck::save(std::string* error) const { return true; } +int Deck::foreign_lines() const { + int count = 0; + for (const auto& line : foreign_) { + if (!trim(line.text).empty()) ++count; + } + return count; +} + void Deck::add(const Flashcard& card) { cards_.push_back(card); if (cards_.back().id.empty()) { diff --git a/src/deck.h b/src/deck.h index 95bff8a..986fd56 100644 --- a/src/deck.h +++ b/src/deck.h @@ -16,8 +16,22 @@ std::string card_to_csv(const Flashcard& card); // Returns false for records too short to be a card. Missing trailing fields // fall back to their defaults, so a bare "question,answer" line still loads // and a pre-scheduling six-field deck simply comes back due immediately. +// +// A trailing carriage return is dropped before anything else, so a deck +// written on Windows or exported by a spreadsheet does not end up with a "\r" +// glued to the end of every answer -- where it would be invisible on screen, +// survive into the file as a quoted "answer\r", and be there forever. bool card_from_csv(const std::string& line, Flashcard* out); +// Why `path` cannot be used as a deck file, or an empty string when it can. +// +// Checked before the deck is opened rather than at the first save. Both of the +// failures it catches -- the path naming a directory, and the directory that +// would hold a new deck not existing -- are mistyped paths, and the app used +// to accept them, show an empty deck, and only report the problem after the +// user had typed a card into it. +std::string deck_path_error(const std::string& path); + struct DeckStats { int total_cards = 0; int total_correct = 0; @@ -57,6 +71,13 @@ class Deck { const std::string& path() const { return path_; } + // How many lines of the file were not cards and are being carried through + // untouched -- blank lines excluded, since a blank line is not something + // anyone needs telling about. Non-zero is worth reporting: it is either a + // deck with comments in it, which is fine, or the wrong file entirely, which + // is not. + int foreign_lines() const; + // Resolves a path stored in the deck against the deck's own directory rather // than the working directory, so that a deck and the files beside it survive // being moved, synced or studied from elsewhere. An absolute path is taken as @@ -128,8 +149,29 @@ class Deck { const std::string& ensure_id(Flashcard& card); private: + // A line of the deck file that is not a card: a comment, a heading, a blank + // separator, or something that simply did not parse. + // + // Kept, because a deck is a plain text file that people edit by hand and a + // tool which silently deletes the lines it does not understand is not one + // you can trust with a file. Dropping them on load was invisible until the + // next save wrote the deck back without them -- and pointing FlashTerm at a + // file that was never a deck at all replaced its entire contents with one + // card. + // + // `before_card` is how many cards preceded the line in the file, which is + // what anchors it on the way out: a heading stays above the cards it heads, + // and trailing notes stay at the bottom. Cards added or deleted in between + // move the anchor's meaning, which is the price of not tracking edits the + // deck does not otherwise care about; nothing is ever lost either way. + struct ForeignLine { + std::size_t before_card = 0; + std::string text; // exactly as read, minus the line ending + }; + std::string path_; std::vector cards_; + std::vector foreign_; EventLog log_; // What the file holds as of the last successful read or write, so that diff --git a/src/main.cpp b/src/main.cpp index 6d704fc..592aa12 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -120,6 +120,16 @@ int main(int argc, char* argv[]) { std::setlocale(LC_ALL, ""); color::detect(); + // Before anything opens the deck, and for every action, since all three of + // them save it. A path that cannot hold a deck is a mistyped path, and the + // moment to say so is now rather than after a card has been typed into a + // deck that was never going to be written. + if (const std::string problem = deck_path_error(options.deck_path); + !problem.empty()) { + std::cerr << "FlashTerm: " << problem << "\n"; + return 2; + } + if (options.action == CliAction::AbsorbConflicts) { Deck deck(options.deck_path); // Same rule as --generate-audio below: these operate on a deck that is @@ -154,7 +164,23 @@ int main(int argc, char* argv[]) { << deck.path() << color::reset << "\n\n"; } else { std::cout << color::green << "Loaded " << deck.size() - << " flashcards from " << deck.path() << color::reset << "\n\n"; + << " flashcards from " << deck.path() << color::reset << "\n"; + // Said once, on the way in. A deck with a heading or two in it is a + // perfectly good deck and this is just a note; a file where almost nothing + // is a card is the wrong file, and this is the only moment anyone would + // notice before studying it. + const int foreign = deck.foreign_lines(); + if (foreign > 0) { + std::cout << color::yellow << count_label(foreign, "line", "lines") + << (foreign == 1 + ? " is not a flashcard. It is kept exactly as it is" + : " are not flashcards. They are kept exactly as they " + "are") + << " and written\nback untouched — if you meant a different " + "file, this is what that looks like.\n" + << color::reset; + } + std::cout << "\n"; } try { diff --git a/tests/golden/cases/deck-foreign-lines/deck.txt b/tests/golden/cases/deck-foreign-lines/deck.txt new file mode 100644 index 0000000..fe3d10e --- /dev/null +++ b/tests/golden/cases/deck-foreign-lines/deck.txt @@ -0,0 +1,6 @@ +Chapter one: greetings +Bonjour,Hello,french + +TODO: add the numbers +Au revoir,Goodbye,french +notes at the bottom diff --git a/tests/golden/cases/deck-foreign-lines/expected b/tests/golden/cases/deck-foreign-lines/expected new file mode 100644 index 0000000..0baedce --- /dev/null +++ b/tests/golden/cases/deck-foreign-lines/expected @@ -0,0 +1,88 @@ +--- output --- +Loaded 2 flashcards from deck.txt +3 lines are not flashcards. They are kept exactly as they are and written +back untouched — if you meant a different file, this is what that looks like. + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (2 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 (2 cards) +[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: [██████████░░░░░░░░░░] 50% (1/2 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · french │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Bonjour │ +│ │ +└──────────────────────────────────────────────────────────────┘ + +[Enter] submit [a] play audio [?] hint [q] end session +Your answer: +✅ Correct! +Card promoted to Box 2! +Next review in 3 days (). + +[Enter] next card [a] hear the question [e] edit this card +[u] undo this answer [q] end session +> Progress: [████████████████████] 100% (2/2 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · french │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Au revoir │ +│ │ +└──────────────────────────────────────────────────────────────┘ + +[Enter] submit [a] play audio [?] hint [q] end session +Your answer: +❌ Incorrect! Correct answer: Goodbye +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: 1 + Incorrect: 1 + Total Reviewed: 2 + Success Rate: 50.00% + Still Due: 0 +================================================== + +[any key] back to the main menu +> +Input stream closed/EOF. Flashcards saved. Goodbye! +--- exit status --- +0 +--- file deck.txt --- +Chapter one: greetings +Bonjour,Hello,french,1,0,2,,, + +TODO: add the numbers +Au revoir,Goodbye,french,0,1,1,,, +notes at the bottom +--- file deck.txt.log --- +,,