leitner
v0.1.3
Published
Terminal UI for spaced-repetition review of Flashcard Markdown decks.
Maintainers
Readme
leitner
Terminal UI for reviewing markdown flashcards from ~/notes/flashcards.
Named after the Leitner system (Sebastian Leitner, 1972): boxes of cards that move forward when you get them right and back when you don't — the canonical spaced-repetition method this scheduler is a variant of.
Each ## heading in a markdown file is one card: the heading is the question,
everything until the next ## is the answer, and a *** splits a longer front
from the back. Review state is kept outside the notes folder; markdown files are
never written to.
The deck format is Flashcard Markdown
1.0, which leitner implements as a consumer — it parses anything the
format calls valid and never refuses a file over one bad card. Its conformance
corpus runs in this project's test suite. docs/format.md
covers what the format leaves to an implementation, and the card-identity scheme
that decides when an edit costs you review history.
Install
Needs Node 24 or newer.
pnpm add -g leitnerFrom a clone instead — which is what the development launcher below assumes:
pnpm install
pnpm buildUsage
leitner init [dir...] # record where your flashcards live
leitner list [dir...] # decks and card counts
leitner cards [dir...] # every card's reference and title
leitner note <ref> # add a note to a card, or list the notes it has
leitner notes [dir...] # every note, orphaned ones included
leitner stats [dir...] # totals, due/new/suspended, parse warnings
leitner review [dir...] # interactive review session
leitner export [dir...] # review state as a portable JSON bundle
leitner import <file> # merge a bundle into local statedir defaults to sourceDirs from ~/.config/leitner/config.json.
Naming a card
leitner cards prints one line per card: the card's reference, then its
title. A reference is deckSlug#headingSlug — spanish-verbs#ser-vs-estar —
and it is how a card is named outside the file it lives in: readable enough to
say out loud, and stable enough to paste somewhere and come back to.
leitner cards --deck spanish-verbs
leitner cards | grep estarThe listing honours --deck, --type and --untyped, and reads no review
state — which cards are due is stats's question, not this one.
Two headings in a file that slugify alike take an ordinal (~1, ~2), and the
file is reported as a warning, because deleting one of them shifts the other's
number. docs/format.md has the derivation and what edits a
reference survives.
Notes on a card
A thought about a card that is not about your memory of it — this back
conflates two rules, this front gives the answer away, these two cards are the
same card — is a note. Notes are keyed by reference and stored in
.leitner-notes.json at the root of the source directory, so they travel with
the deck rather than with your schedule.
leitner cards | grep estar # find the card
leitner note spanish-verbs#ser-vs-estar 'the back conflates two rules'
leitner note spanish-verbs#ser-vs-estar # list its notes, numbered
leitner note spanish-verbs#ser-vs-estar --rm 1
leitner notes # every note in the collection
leitner notes --orphans # only the ones whose card is goneText is optional: leitner note <ref> with nothing after it lists that card's
notes with the numbers --rm takes. The reference can be shortened as long as
it stays unambiguous — spanish-verbs#ser is enough unless two cards answer to
it, and then the candidates are printed for you to pick from. A note is added to
a card that exists; nothing is filed against a reference that matches none.
Notes accumulate per card rather than replacing one another, and stats counts
them alongside the card totals.
Orphans. Rename a heading or move a deck file and the reference breaks, so
its notes point at nothing. They are shown, marked as orphaned and carrying the
card's title and path as they were when the note was written — never deleted for
you. Removing one is leitner note <ref> --rm <n> on the old reference, which
still works precisely so an orphan can be cleared. A card filter (--deck,
--type, --untyped) hides orphans, since there is no card left for it to
match them on.
docs/notes.md has the file format and the reasoning.
More than one directory
Give as many directories as you like and they are read as one collection, in
the order written. The arguments replace the configured directories rather than
adding to them, so a one-off leitner list ~/work/cards studies that directory
alone without touching the config.
They may not contain one another: a file under two of them would be counted as
two cards with two separate review histories, so that is refused rather than
merged. Two directories holding the same relative path (spanish.md in both)
are allowed, but those two cards then share one review record and grading one
schedules the other; list and stats print a warning naming both files.
--deck matches a source path as well as a deck slug, so it doubles as a way
to study one of the directories: leitner review --deck ~/work/cards.
First run
The first command that needs your flashcards and cannot find that setting asks for the directory and writes the config file, reporting how many cards it found there so a typo does not pass for an empty collection. It then carries on with the command you asked for.
init is the same question on demand — run it to move a collection, or give it
the directories (leitner init ~/notes/cards ~/work/cards) to answer without
being asked. Asked interactively it keeps offering Another directory? until
the answer is blank. leitner init ~/work/cards --add adds to the configured
directories instead of replacing them. Every other key keeps its value.
Nothing is written into the notes tree, then or ever. Passing dir skips the
question, and so does any run whose stdin or stdout is not a terminal: with no
directory configured, those fail with a message rather than block on a prompt
nobody can see.
Local command
bin/leitner is the development launcher: it rebuilds only when sources
changed and writes build output to stderr so list/stats/export stdout
stays parseable. It execs node from wherever it was called, so a relative
dir argument means what it looks like it means. Expose it as a command with a
thin wrapper:
cat > ~/.local/bin/leitner <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec /path/to/leitner/bin/leitner "$@"
EOF
chmod +x ~/.local/bin/leitnerFlags
--add init: add the directories instead of replacing them
--deck <slug-or-path> only decks matching slug or source path (skips the picker)
--type <type> frontmatter type, matched verbatim (the format defines no set)
--untyped only cards whose file declares no type
--due only due cards
--new only new cards
--limit <n> cap queue size (review defaults to dailyLimit, 50)
--state <path> state file (default ~/.local/share/leitner/review-state.json)
--images enable inline image previews (kitty graphics protocol)
--out <path> export: write here instead of stdout
--prune export: drop records whose cards no longer exist
--rm <n> note: remove the card's note number n (counting from 1)
--orphans notes: only notes whose card no longer exists
--merge <strategy> import: newer (default) | theirs | ours
--dry-run import: report what would change without writing
-h, --help show this helpReview keys
review opens a deck picker first, unless the session is already narrowed to
one deck — by --deck, or by defaultDeckFilter in the config, which sets the
same option. Pick All decks to study everything.
When a deck runs out, the completion screen offers the picker again, so one
review can cover several decks; q ends the whole session and prints the
total. A session narrowed by --deck has no picker to return to, so it offers
q and a practice pass over the deck it was given.
A collection with nothing due still opens the picker, showing 0 due against
every deck. Only a collection with no cards in it at all exits first.
enter/space select deck (picker) · reveal answer, then next card (review)
back to decks (done)
p practise the whole deck (picker, done screen)
1 2 3 4 grade: again / hard / good / easy
j/k, arrows move selection / scroll body
/ filter decks (picker) · search cards (review)
. show the hidden decks, greyed (picker)
H hide the deck under the cursor, or show it again (picker)
s suspend card
u undo last grade
n note the card: write one, or look at the ones it has
e edit card in $EDITOR
i image preview (needs --images)
b back to the deck picker (done screen)
esc clear an active search
q quitThe session runs on the terminal's alternate screen, like vim or less, so
it leaves the scrollback untouched. The counts of cards reviewed and practised
are printed on the normal screen once it exits.
Noting a card mid-session
n opens a small composer under the card: the notes it already has, and a line
to write another. Enter saves and closes, enter on an empty line just closes —
so n is also how you read them — and esc cancels. Grading keys do nothing
while it is open.
The note goes to .leitner-notes.json in the source directory the card came
from, the same file leitner note writes, and a card that has notes shows a
📝 count beside its title. Notes can be written during a practice pass: a
practice pass schedules nothing, and a note is not scheduling.
If the file cannot be written the session says so and keeps the note for the rest of the session rather than dropping it on the floor.
Renaming a heading through e carries the card's notes to its new reference,
the way it already carries the review record. A rename made in your own editor,
outside a session, orphans both.
Practising a deck
p opens a deck outside the schedule: every card in it, in deck order,
suspended ones aside, however far out the next review is. It is there for the
exam on Tuesday and the deck that says "not for three weeks". Press it on a row
in the picker, or on the completion screen to reopen the deck just finished,
which is the way in when --deck skipped the picker.
A practice pass schedules nothing. There is no grading, no suspending, and
nothing is written to the state file, so an evening of last-minute revision
leaves the schedule exactly as it found it. space or enter reveals a card
and the same key moves to the next one. Practised cards are counted apart from
reviewed ones, on screen and in the totals printed at the end.
e still edits, because a renamed heading has to keep its review record.
docs/scheduling.md has the reasoning.
Hiding a deck
H in the deck picker hides the deck under the cursor: an archive, a deck
someone else maintains, one collection of several you rarely study. The header
says how many are out — 3 decks · +2 hidden — rather than letting them vanish,
. shows them again, greyed, and H on a revealed deck puts it back in the
list. The reveal lasts as long as the picker is on screen; leaving it for a deck
and coming back starts from the config again.
A hidden deck is out of All decks too, because that row means the decks on
screen — press . first to study everything. Hiding is a picker setting and
nothing else: list, stats and export still see every deck, --deck opens
a hidden one directly, and no card's schedule changes. Suspending (s) is what
stops a card coming round; this only stops a deck being offered.
H writes hiddenDecks in the config, adding the deck's source path and
removing it again. The rest of the file is left exactly as it was, so a key with
a config open in another window costs you nothing but that one line. The list
can also be written by hand:
{ "hiddenDecks": ["german-verbs", "~/notes/flashcards/archive"] }Entries are matched the way --deck is: a deck slug, or any part of a source
path, so one entry can stand for a whole directory. H only ever takes back an
entry that is the deck's own path — pressed on a deck hidden by a directory
entry it says which entry that is and changes nothing, because dropping it would
show every other deck under it too.
Editing a card
e hands the terminal to editor from the config, or failing that $VISUAL,
$EDITOR, or vi, opened on the card's ## heading in its source file. The
config key is a command line, the way the variables are, so a setting of
"editor": "code --wait" works. Editors whose line syntax is known get taken
there directly (+N for the vi and emacs families, path:line for helix,
--goto for VS Code); anything else opens at the top of the file. Editors that
detach by default are launched with --wait so the session waits for the file
rather than reparsing it unedited.
On return the file is reread and the session picks up where it left off, with
the card's new text. Because a card's id hashes its heading text and position
(so ids survive across machines without an index file), renaming a ##
heading or inserting a card above one would otherwise orphan the review
history. The reread pairs the cards before and after the edit and carries the
records across; when two or more headings were renamed in one pass the pairing
is ambiguous, so those cards are left to come back as new rather than risk
attaching history to the wrong card.
Scheduling
A minimal SM-2-style algorithm: again comes back in 10 minutes and counts a
lapse, hard grows the interval slowly and lowers ease, good multiplies the
interval by ease, easy grows faster and raises ease. Anki compatibility is not
a goal, and there is no .apkg on either side.
docs/scheduling.md has the exact transitions, the queue
order, the state file, and the config keys.
Image previews
--images renders attachments as real pixels using the kitty graphics
protocol, so it needs kitty or ghostty. Only PNG is transmitted as pixels —
the protocol's direct file transmission is defined for PNG, and files are
checked by magic bytes rather than extension. Everything else stays a text
attachment line.
Inside tmux the escapes must be forwarded:
tmux set -g allow-passthrough onreview --images reports when passthrough is off rather than drawing nothing.
Moving state between machines
leitner export --out review-state.json # on the first machine
leitner import review-state.json # on the secondimport accepts an export bundle or a raw state file. The default newer
strategy keeps whichever copy of a card was reviewed most recently; theirs
always takes the incoming record, ours only fills in unseen cards. Pair with
--dry-run to see the effect first.
Development
pnpm test # vitest
pnpm typecheck # tsc --noEmit over src and tests
pnpm lint # oxlint
pnpm format:check # oxfmt, read-only (pnpm format writes)
pnpm build # tsc -p tsconfig.build.json, into a cleared dist/tsconfig.json is the checking config and covers tests/ too; pnpm build
uses tsconfig.build.json, which narrows the input to src so only library
code is emitted to dist.
pnpm install runs prepare, which installs the git hooks in lefthook.yml.
The only one is a shared commit-msg rule fetched from
lefthook-rules; the checks above
are not wired to a hook, so run them yourself before a commit.
Layout: src/deck.ts (Flashcard Markdown → parsed deck; the conformance
surface, pure and free of I/O), src/tags.ts and src/diagnostics.ts (the
format's tag grammar and its closed code list), src/parser.ts (files → cards:
ids, resolved image paths), src/render.ts (markdown →
styled terminal lines), src/scheduler.ts (grading), src/state.ts (JSON
store), src/transfer.ts (import/export merge), src/queue.ts (due/new
ordering and deck summaries), src/images.ts (kitty graphics), src/editor.ts
($EDITOR invocation), src/edit.ts (carrying records across re-ids),
src/cli.ts (commands), src/tui/ (Ink deck picker and review screen).
