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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 86 additions & 5 deletions src/deck.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#include "deck.h"

#include <sys/stat.h>

#include <algorithm>
#include <cerrno>
#include <cstdio>
Expand Down Expand Up @@ -28,8 +30,49 @@ int parse_int_or(const std::vector<std::string>& 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),
Expand Down Expand Up @@ -86,7 +129,7 @@ std::string card_to_csv(const Flashcard& card) {
}

bool card_from_csv(const std::string& line, Flashcard* out) {
std::vector<std::string> fields = parse_csv_line(line);
std::vector<std::string> 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] : "";
Expand Down Expand Up @@ -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();
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
42 changes: 42 additions & 0 deletions src/deck.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Flashcard> cards_;
std::vector<ForeignLine> foreign_;
EventLog log_;

// What the file holds as of the last successful read or write, so that
Expand Down
28 changes: 27 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions tests/golden/cases/deck-foreign-lines/deck.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Chapter one: greetings
Bonjour,Hello,french

TODO: add the numbers
Au revoir,Goodbye,french
notes at the bottom
Loading
Loading