A mobile-first flashcard web app backed by a Google Sheet. Practice any deck with simple Leitner spaced repetition, on your phone, tablet, or laptop. Card text supports Markdown. No server to host and no database to run — the Google Sheet is the database, and Google Apps Script serves the app.
- Backend: a Google Sheet (
cardstab). Add/edit cards from anywhere. - App: a single Apps Script web app (mobile-first, add-to-home-screen friendly).
- Scheduling: Leitner boxes (1→5), with immediate write-back of your progress.
- Speech: tap 🔊 to hear a word or example in the language you're studying — your browser's own voices, no API key, no cost.
- 🎧 Soak: keep whole sentences in a
sentencestab and have the app read them aloud one after another, with a silence between — for absorbing tempo, tense and clause order rather than testing yourself. - In-app tools: edit a card, flag it for later, or exclude a bad card — all saved straight to the Sheet.
This repo is a template — deploy your own copy against your own Google account and Sheet (see below). It ships with a small Dutch A1/A2 starter deck, but the app is language-agnostic: set
target_languagein theconfigtab and it's a Chinese (or French, or Japanese) app instead. Use it for anything.
📋 Example Sheet — make a copy to get your own (Dutch A1/A2 starter deck, app code included). Copying it is the fastest way to start: you get the Sheet and the app in one click, no CLI. See Deploy your own.
- Lightweight — it's just a web page. Nothing to install or update; it runs in the browser you already have, on any device (phone, tablet, laptop). No app-store download, no account for yet another service, no sync engine to trust.
- No ads, no upsells — ever. Because you self-host it (your own Apps Script web app + your own Google Sheet), there's no company in the middle with a reason to show you ads, gate features behind a subscription, or mine your study data. It's yours end to end.
- Your browser's superpowers come for free. Since it's a normal web page, you can select any word to look it up, translate, or search, use right-click / long-press actions, share a card, or use any browser extension or accessibility feature you rely on — none of which a locked-down native app lets you do.
- Flexible, dead-simple editing. Your cards live in a Google Sheet, so you can bulk-import a deck, tweak wording, fix a typo, add rows, or reorganize columns whenever you want — from the Sheet on any device, or from inside the app while you study. No proprietary format, no export/re-import dance; the spreadsheet is the database.
- Quiet by design — no notifications, no gamification. It will never buzz your phone with reminders you're already drowning in, and there are no streaks, badges, XP, or leaderboards engineered to keep you hooked. It simply shows what's due and gets out of the way. It runs on your motivation — not manufactured guilt or dopamine loops.
Two ways to get your own copy. Either way it ends up entirely yours — your Sheet, your script, your Google account. Nothing reports back here except an optional, anonymous once-a-day version check — see Updates for exactly what it sends and how to turn it off.
A Google Sheet copy includes its bound Apps Script project, so this gets you the deck and the app in one step. You need only a Google account.
- Make a copy of the example Sheet — this lands a Flashcards Sheet in your own Drive, starter deck and code included.
- Make the copy yours: in the
configtab, clear thewebapp_urlcell so the app resolves to your own deployment instead of the original's. (Optional: to wipe the starter progress and study from scratch, clear thebox,due,last_seen,right,wrongandflagcolumns in thecardstab.) - Open Extensions → Apps Script.
- Click Deploy → New deployment, choose type Web app, set Execute as Me and Who has access Only myself, then Deploy.
- Authorize when prompted. It's your own script now, so the "Google hasn't verified this app" warning is expected — click Advanced → Go to Flashcards.
- Open the web-app URL it gives you. On iOS, tap Share → Add to Home Screen for an app-like icon.
- Studying something other than Dutch? Set
target_languagein theconfigtab (see Settings).
The starter deck comes with the copy, so there is nothing to load. To start from
an empty deck instead, clear the rows under the header in the cards tab. (To
get the starter deck back later, import
decks/starter-dutch.csv — see
Adding your own cards.)
Choose this if you want the code in git and plan to change it.
Using a coding agent? See
AGENTS.md— it walks the same deploy split into agent-runnable commands and the three browser steps Google requires you (the human) to do:clasp login, enabling the Apps Script API, and the first-run authorization. The manual steps below are the human version.
You'll need Node.js and a Google account.
-
Install clasp (Google's Apps Script CLI) and log in:
npm install -g @google/clasp clasp login
Also enable the Apps Script API for your account at https://script.google.com/home/usersettings.
-
Create a Sheet-bound script project (do this in a clone of this repo):
clasp create --type sheets --title "Flashcards" --rootDir .
This creates a new Google Sheet + bound script and writes a local
.clasp.json. Note:clasp createoverwritesappsscript.jsonwith a default — restore this repo's version afterward (it sets the web-app access and timezone). -
Push the code and deploy as a web app:
clasp push --force clasp deploy --description "v1"The
deploycommand prints a deployment ID; your web-app URL ishttps://script.google.com/macros/s/<DEPLOYMENT_ID>/exec. -
Use it: open the web-app URL and authorize when prompted — it's your own script, so the "unverified" warning is expected. The first load creates the empty
cardsandconfigtabs. On iOS, tap Share → Add to Home Screen for an app-like icon. -
Load the starter deck (optional): in the Sheet, select the
cardstab, then File → Import → Upload →decks/starter-dutch.csvfrom your clone. Import location Replace current sheet (the CSV brings its own header row), and "Convert text to numbers, dates, and formulas" → No. Then 🎴 Flashcards → Check Sheet health confirms the import left the tab and column names intact.
To redeploy after changes, reuse the same deployment ID so the URL (and your Home Screen icon) stays stable:
clasp push --force
clasp redeploy <DEPLOYMENT_ID> --description "..."Set the web app's access in appsscript.json (webapp.access):
MYSELF (only you — every browser must be signed into the owning account),
ANYONE (anyone with a Google account), or ANYONE_ANONYMOUS (anyone with the
link). The app always runs as the deploying user and only ever touches that
user's own Sheet.
-
Home shows Due / New counts and the Leitner box breakdown, with the scheduled session on top and two free drills below it:
-
Start practice — the scheduled Leitner session (all due cards + up to 10 new ones). Grading saves your progress to the Sheet.
-
Weak cards — a free drill of the cards you keep missing (never-right ones first, then the shaky ones; drawn by weighted random, so the most urgent come up most often but the whole pool eventually cycles through).
-
Today's mistakes — the cards you got wrong in today's scheduled session, for a quick second pass while it's still fresh. It appears only on a day you have actually missed something.
-
🎧 Soak — not cards at all: your
sentencestab, played aloud. See Soak.
Both drills are schedule-neutral: grading does not change any card's box or due date — use them as often as you like without disturbing your schedule. Tap Keep going on the summary to pull a fresh batch of the same drill.
-
-
Each card shows the front first. Tap it (or Show answer) to reveal the back + any notes. Revealing also speaks the example aloud — see Hearing your cards.
-
Grade yourself:
- Normal card: Wrong (back to box 1) or Correct (up one box).
- Brand-new card: Didn't know (box 1) or Already knew (skip to box 4).
-
In a scheduled session, progress saves to the Sheet immediately after each grade. In either drill, nothing about the schedule is written — which also means a card stays on Today's mistakes for the rest of the day even after you re-drill it correctly.
-
The ✎ edit / ⚑ flag / 🚫 exclude tools save straight to the Sheet in both modes (they change a card's content, not its schedule).
Each card has four tools in the progress row (top right):
- 🔊 Speak — hear the card in the language you're studying. Works before or after revealing (after, it reads the example sentence too).
- ✎ Edit — edit the card's front / back / notes (Markdown) and Save to Sheet — written back immediately. Works on phone and laptop.
- ⚑ Flag — one tap marks the card (writes
⚑to itsflagcolumn) so you can fix it later without stopping to type. Tap again to un-flag. Filter theflagcolumn in the Sheet to find flagged cards. - 🚫 Exclude — drops a bad/low-quality card from all future practice
(soft-delete: writes
xto itsexcludecolumn) and skips to the next card. Reversible — clear theexcludecell to bring it back, or filter that column to bulk-delete those rows when cleaning up.
| column | meaning |
|---|---|
id |
any unique value — a label for you, never used by the app (cards are found by row) |
type |
optional tag/category (e.g. vocab, grammar) — shown on the card badge |
front_side |
front of the card — Markdown |
back_side |
back of the card (the answer) — Markdown |
notes |
optional hint shown under the answer — Markdown |
box |
Leitner box 1–5. Leave blank = new card |
due |
next review date YYYY-MM-DD (managed by the app) |
last_seen, right, wrong |
stats (managed by the app) |
added |
date reference (your own) |
flag |
holds ⚑ when flagged in the app for later editing |
exclude |
holds x when excluded from practice (soft-delete) |
The app self-heals the schema: on first load it adds any missing columns
(flag, exclude) without touching your data. It does the same for the
config and sentences
tabs, so all three appear on their own.
Self-healing only fills empty header cells — it never overwrites a wrong
label. If you suspect an import or edit broke the layout, run
🎴 Flashcards → Check Sheet health in the Sheet: it reports exactly which
tab or column names don't match what the app expects.
Deleting rows leaves gaps in id, and hand-added rows often have none at all.
🎴 Flashcards → Renumber id column rewrites the column as 1, 2, 3 … in row
order (it asks first). Progress is untouched — the app never looks at id — but
the old numbers are gone, so run it only when nothing outside the Sheet still
refers to a card by its id.
Add a row to the cards tab (laptop or phone). Fill front_side, back_side,
optionally type and notes. Leave box/due empty so it appears as a
new card. You can hand-tune proficiency any time by editing box and due.
To bulk-add a whole deck from a CSV, tap + Import cards from a file on the
home screen — it walks you through Google Sheets' own File → Import (a
laptop/desktop step; the Sheets mobile app can't import). The one setting that
matters there is “Convert text to numbers, dates, and formulas” → No, or
Google turns entries like 1/2 into dates.
Ready-made decks live in decks/: starter-dutch.csv (the deck the
example Sheet ships with), starter-chinese.csv (basic Mandarin vocab +
grammar), and starter-arabic.csv (first Modern Standard Arabic words, with
transliteration). After importing the Chinese or Arabic one, set
target_language to zh-CN or ar-001 in ⚙ Settings so the right voice
reads your cards.
front_side, back_side, and notes render Markdown: **bold**, _italic_,
==highlight==, `code`, # headings, - bullet lists, 1. numbered lists,
[links](https://…), and . A highlight adds no horizontal
space, so it can mark part of a word in a cursive script — ==ب==اب colours just
the first letter of باب without breaking the join. Put line breaks in a cell with Alt+Enter
(Option+Enter on Mac) inside the Google Sheet.
Most of these you can change right in the app — tap the ⚙ in the top-right, pick your language (the list is built from the voices your device actually has), speed and auto-speak, and Save. No need to touch the Sheet.
Under the hood they live in a second tab called config, as key / value
rows, so you can also edit a cell and reload. The tab is created automatically with
sensible defaults the first time the app runs, so there's nothing to set up.
| key | default | what it does |
|---|---|---|
target_language |
nl-NL |
The language you're studying. A BCP-47 tag — zh-CN, fr-FR, de-DE, es-ES, ja-JP… |
speech_rate |
0.9 |
Speaking speed. 0.5 = slow, 1 = normal. |
auto_speak |
yes |
Speak the example automatically when you reveal an answer? yes / no |
sentence_gap |
1.5 |
Seconds of silence after each sentence in 🎧 Soak. 0 = no pause. Also in ⚙ Settings. |
sentence_repeat |
1 |
How many times 🎧 Soak speaks each sentence before moving on. Also in ⚙ Settings. |
update_check |
yes |
Check for app updates on startup? Sends one anonymous ping a day — see Updates. Also in ⚙ Settings. yes / no |
webapp_url |
(blank) | The /exec link of your deployment, used by 🎴 Flashcards → Open the app ↗. Blank = auto-detect. |
Tap 🔊 in a card's tool row to hear it, and the example sentence plays
automatically when you reveal an answer (turn that off with auto_speak).
Speech uses your browser's built-in voices — nothing is sent anywhere, there's no
API key, and it costs nothing. Only the language you're studying is ever spoken:
the front_side, plus any italic example sentences in back_side / notes. Your
own-language gloss (the bold part) stays silent — you don't need it read to you.
So write cards with a bold gloss and an italic example and speech does the
right thing on its own.
Set
target_languagefirst. It ships asnl-NLbecause the starter deck is Dutch. If your cards are in another language, change it — a Dutch voice handed Chinese cards can't pronounce a single character and just says "punt" ("dot"). The home screen always shows what it's set to (🔊 Dutch (nl-NL)), and the app refuses to speak rather than produce nonsense if the two obviously disagree.
Voice quality is your device's, not the app's. iPhone and iPad are good out of the box, and Settings → Accessibility → Spoken Content → Voices lets you download a higher-quality voice for your language. On macOS, add voices in System Settings → Accessibility → Spoken Content. If nothing is installed for your language the app tells you instead of speaking it in the wrong voice.
Some sentences are worth hearing over and over even when you understand every word in them: that's how the tempo of a language, its tenses, and the order things go in a subordinate clause stop being rules and start being reflexes. Cards can't do that — they ask you a question. 🎧 Soak just plays.
Tap 🎧 Soak on the home screen and press ▶. It reads one sentence, goes quiet for a moment, reads the next, and keeps going. Nothing is revealed, graded, or written back — replay it as often as you like.
- ⏮ / ⏭ step between sentences; tap the sentence to hear it again.
- 🔀 shuffles, and toggles back to the order they sit in the Sheet.
- The pause length and how many times each sentence repeats are in
⚙ Settings (
sentence_gap/sentence_repeat).
Keep the app in front while it plays. Phones suspend browser speech the moment the screen locks or you switch apps — that's the platform, not the app. Soak keeps the screen awake while it runs, and if you do switch away it pauses itself so you come back to where it stopped instead of to silence. It is not background audio.
Created automatically on first load, next to cards and config. Add a row per
sentence:
| column | meaning |
|---|---|
id |
any unique value — a label for you, never used by the app |
text |
the sentence — the only column that is ever spoken, so it must be in the language you're studying — Markdown |
note |
your translation, or the thing to notice (a tense, a clause order). Shown on screen, never read aloud — Markdown |
tag |
optional label (perfectum, bijzin, …) — shown as a badge |
added |
date reference (your own) |
exclude |
holds x to drop the sentence from the player |
Soak speaks text in the same target_language your cards are read in —
there's one study language per Sheet, so there is nothing extra to set up.
- Boxes 1→5, review intervals 1, 2, 4, 8, 16 days.
- Correct → up one box. Wrong → back to box 1.
- Up to 10 new cards per scheduled session.
- Both drills — Weak cards and Today's mistakes — are schedule-neutral: they never change a card's box or due date. Up to 20 cards per weak drill, and up to 50 for a day's mistakes (a day rarely comes near that).
- Tune in
Code.js:BOX_INTERVALS,NEW_PER_SESSION,KNOWN_START_BOX,PRACTICE_LIMIT,ERROR_DRILL_LIMIT.
Your copy never updates itself — you own the code, and nothing changes unless
you change it. The version you're running is shown at the bottom of ⚙
Settings in the app, and in the Sheet's 🎴 Flashcards → About Flashcards
menu. Once a day the app asks this project's endpoint whether a newer version
exists. If so, the home screen shows a small
⬆️ Update available link to UPGRADE.md, which walks you
through updating in a couple of minutes (your cards and progress are never
touched).
That request is also the project's only usage signal — it's how the author knows roughly how many people use the app. Full disclosure of what it involves:
- What's sent: the app version and an unrecognizable fingerprint of your copy — a one-way hash of its spreadsheet id, so two pings can be recognized as the same copy but the copy itself can't be identified. That's all.
- What's not sent: nothing about you or your deck — no account, no email, no card contents, no study data. The endpoint is an Apps Script web app, which doesn't even expose callers' IP addresses to its owner.
- How to turn it off: untick Tell me when an update is available in
⚙ Settings (or set
update_checktonoin theconfigtab). You'll stop seeing update hints, and no ping is ever sent.
| file | purpose |
|---|---|
Code.js |
backend: doGet, Leitner logic, sheet I/O, the getSession/getWeakCards/gradeCard/updateCard API |
Index.html |
the entire UI — inline CSS/JS + a small Markdown renderer |
decks/ |
starter decks as plain CSV — load one via File → Import (see Adding your own cards) |
appsscript.json |
Apps Script manifest (timezone + web-app access) |
.claspignore |
limits what clasp pushes to the script |
UPGRADE.md |
how to update your copy when a new version is out |
.claspignore.public, push-public.sh |
maintainer-only: sync this repo to the example Sheet's script. Not needed to run or deploy the app — ignore them in your own copy. |
collector/ |
maintainer-only: the endpoint behind the version check. Never pushed to app copies — ignore it in your own copy. |
MIT.

