—————–THIS IS EXPLORATORY AND NOT FINAL OR A FINISHED PRODUCT—————————-
A spaced-repetition Albanian course built the way a kanji course is built: around roots and word families rather than around characters.
You already know how Albanian is pronounced and how it is written. What is missing is the morphology - the fact that so many words are one root wearing different clothes. This course teaches them together: the root, then everything built on it, then the descendants.
Scope: text only. No audio, no pronunciation drills, no recordings. That
is a deliberate decision, not an oversight - see PLAN.md.
| Words | 4,133 |
| Levels | 765: a 348-level main course, then 417 optional extended levels |
| Word families | 3,058 |
| Families with more than one member | 519 |
| Words containing ë / ç | 1,653 |
| Words with an example sentence | 3,759 (91.0%) |
| Words with a cognate hook | 125 |
(These figures were stale for several PRs – the deck had already been
rebuilt twice. They now come from data/words.json; check with
python3 tools/measure_learnability.py.)
The first 23 levels are hand-curated by theme: greetings and yes/no,
pronouns, jam and kam in the present, the little words (nuk, dhe,
në, me, për), numbers 1-10, family, food and drink, time, question
words, the core verbs, everyday adjectives, home and work, the conjunctions
(e, por, ose, edhe) and the days of the week. They live in
tools/opening_levels.py, a small reviewed list the build checks (every
word in the deck, a family on one level only, a stem never after its word).
A family the opening only begins comes back later at its own frequency:
jam opens with its present tense, and ishte, qenë, isha… return as
“jam (continued)” on level 29. Glosses and examples for these first words
were reviewed in tools/gloss_overrides.py (pi is “to drink”, not the
Gheg preposition “from”; e is back, see RECOMMENDATIONS.md section 3.2).
The deck is built from Wiktionary, which has no entry at all for some core
words: pra (so), ndonjë (any), sikur, vërtet, sigurisht,
problem, mirupafshim (goodbye)… 41 of them are added by hand in
tools/added_words.py, each with a reviewed gloss, example and forms. The
build refuses an entry that would shadow a real Wiktionary card.
After the opening, levels are ordered by when a word family’s root becomes
common enough to teach, with grammar words held back and some pictureable
nouns pulled forward. The held-back grammar words (të, që, nga, kjo,
pa, deri…) are spread about one per level from L31 to L144, rather
than arriving as one block. That part is an approximation, not a hand-built
curriculum. The Review tab follows level order, and new cards are capped at
20 per day.
The main course is levels 1-348: 1,665 words, everything ranked up to
5,000 in the frequency list plus the opening and the stems those words are
built on. It covers 83% of the tokens in the frequency list, counting an
inflected form of a taught word as known (python3 tools/measure_coverage.py). The other 2,468 words, rank above
5,000, form an optional extended track (levels 349-765, marked
extended) that adds under 3 points more. The app introduces them only
after the learner turns the track on from the level map. See
RECOMMENDATIONS.md section 3.6.
A level is a session of whole word families, not a frequency slice:
do (rank 4) taught doemos (rank 30,516) in level 2 and pushed unë
(“I”) out of the first ten levels; 20 families now resume this way.për (23 words),
jam (22), kam (14), and dua, shkoj, vras and a (13 each). A
continuation always gets a level to itself, so a part label is never
misleading.Levels are titled after the root they teach, the way a Wanikani level is named after the radical it unlocks.
The same + equations give each card its breakdown: pa- + fund,
mund + -ësi. A breakdown is transparent when the parts predict the
meaning (hand-reviewed; 191 words) and historical otherwise (përdor is
për- + dorë “hand”; 577 words). Transparent ones are colour-coded with a
meaning under each part, and the first review of one, once its base is known,
asks you to guess the word from its parts.
Eight affixes are taught as patterns (-im, -je, -(ë)si, -shëm,
-tar, pa-, -isht, -ues), each on the level where you meet its third
clear example. Most of the vocabulary is opaque roots and loans, so this is a
layer on top of the deck, not its backbone. See tools/affixes.py and
RECOMMENDATIONS.md section 2.
Family membership requires an explicit derivation in the etymology text,
never a spelling resemblance or a Wiktionary link alone. A derivation is a
sentence that puts a + between base and affix:
From marr + -ës. marr (take) + -em. From gjithë + çka.
From atë (“father”) + dhe (“land”).
The base is the last content token to the left of that +, after stripping
reconstruction stars, parentheticals and quoted glosses. Requiring the + is
what separates a derivation from a mere mention.
Every sentence is scanned, not just the first: esëll names its base only in
the third — “Interpretible as e- + sille” — and a first-sentence rule split it
from its root e. A comparison is not a derivation, though. nuk carries
the aside “typologically compare Latin nōn (‘not’) … (< ne + ūnus ~ ūnum)”,
which describes Latin, not Albanian, so that sentence is skipped and nuk
stays its own root. So is a + that sits entirely inside parentheses.
This is stricter than it sounds, and deliberately so. Wiktionary’s structured fields cannot be trusted as a derivation graph:
parents is extracted from the etymology text, so it picks up English
gloss words. nuk has parents ["one", "not"], where that not is the
English word inside compare Latin nōn (“not”). The old build linked on it,
so the negator not, don’t sat in the same family as the noun swim,
swimming.derived is a co-occurrence list, not a derivation graph. bri (“rib”)
lists bori, an Ottoman loan meaning bugle; zbres (“descend”) lists
falas, falem, fale, falje, faltore.Cognates and homographs are excluded for the same reason: tmerr (“terror”) is
a cognate of marr, not a derivative, and marre (“shame”) and marrtë
(“twilight”) are homographs of marr with unrelated meanings. tools/
test_derivations.py pins all of these cases, in both directions.
The app is plain HTML, CSS and ES modules. No build step, no dependencies.
./run_tests.sh # every check, ~3 seconds
python3 -m http.server 8000
# then open http://localhost:8000/app/
It must be served over http, not opened as a file:// URL - the course data is fetched, and browsers block that on file://.
Entirely in localStorage, on the device. There is no account, no server, no telemetry. The Progress tab can export a JSON backup and re-import it, which is the only way to move progress between devices.
Clearing site data or using a private window loses progress, so export if you care about it.
app/ the study app (this is what GitHub Pages serves)
index.html
css/style.css
js/srs.js scheduler - SM-2 variant, no DOM, unit tested
js/store.js persistence - versioned, defensive against bad data
js/course.js data loading and card helpers
js/app.js the views and event wiring
data/ generated course data
index.json 765 level descriptors, loaded first
words.json flat card index, what a review session plays from
levels/*.json per-level full detail, fetched on demand
course.json whole-course manifest
tools/ build and test scripts (python3 + node, no deps)
run_tests.sh runs all of it
The end-to-end check drives a real browser. It runs against the deployed site by default, or a local one so a change can be verified before it is pushed:
python3 tools/e2e_live.py --url http://127.0.0.1:8000/app/
The two sources are committed as pinned snapshots in data/sources/
(the Kaikki Albanian dump of 2026-10-15, zipped, and the frequency list),
so a build needs no network and always gives the same deck:
python3 tools/fetch_sources.py # unpack and verify into data/raw/
python3 tools/build_course.py
python3 tools/build_words_index.py
CI (.github/workflows/rebuild-data.yml) rebuilds from the snapshots and
fails if the committed data/ differs. To move to a newer Kaikki dump, run
python3 tools/fetch_sources.py --refresh, update the hashes it prints,
rebuild, review the data diff, and commit the snapshot and data together.
build_course.py decides which sense of a word becomes its headline gloss, which is the part worth reading before changing anything there.
The whole course also ships as an Anki deck. The structure is preserved rather than flattened, because the structure is the point: one deck per level, named for the root that level teaches, and every note carries its root, family, family size, level and frequency rank as separate fields you can search and sort on.
pip3 install -r tools/requirements.txt
python3 tools/export_anki.py
That writes dist/albanian-roots-families.apkg (765 decks, 4,133 notes; the extended track’s decks are named “Albanian extended L…”). It is gitignored because it is a build artifact; regenerate it any time.
Import it in Anki with File > Import, and pick the single .apkg file. Notes carry a deterministic guid, so re-importing after a data rebuild updates the existing notes in place instead of duplicating them.
To check an export without opening Anki:
python3 tools/verify_anki.py
It unzips the package, opens the collection database, and counts decks, notes and level tags, then confirms the card templates only reference fields that actually exist. The test runner also runs a negative control, which corrupts the deck ids and asserts the verifier fails - a check that cannot fail is not a check.
A light SM-2 variant. Four grades (Again / Hard / Good / Easy), an ease factor per card, and an explicit learning ladder so a forgotten word returns the same day instead of drifting weeks out. Intervals are shown on the buttons before you commit, so the cost of each answer is never a surprise.
Glosses are editorial judgements made automatically; some are wrong. If a word looks mis-glossed, check the Wiktionary entry and open an issue.