anki-progress.sh and its embedded app.py assumed a family/kids use case
(dashboard title, ntfy topic default, Authelia warning text, comments)
that was never actually stated — nothing in this repo should assume who
the accounts belong to. Retitled to plain "Anki Progress" throughout,
default ntfy topic changed from family-anki to anki-progress, and every
"family member" reference reworded to "account".
Also adds tools/anki-deck-math.py, tools/anki-deck-periodic.py, and
tools/anki-deck-visual.py — the Anki deck-generation scripts developed
earlier in this session, now committed as standalone, self-documented
tools (same tools/*.{sh,py} convention as tools/dedupe-finder.py) rather
than living only in chat. Each script's own header docstring carries the
full one-time setup (venv, genanki + piper-tts, downloading a voice) and
usage — anki-deck-periodic.py and anki-deck-visual.py point back to
anki-deck-math.py's copy rather than repeating it three times. Content is
generic (multiplication/division/addition/subtraction/fractions/decimals,
the periodic table, shapes/clocks/coin-counting) — nothing here assumes
who's using it or why.
Re-verified after the rename: the embedded app.py still passes its full
logic test suite once written out by the installer, and all three
tools/anki-deck-*.py scripts still build correct decks under
--dry-run-tts after their docstrings were rewritten.
Adds a README.md section pointing at the three scripts, and updates the
anki-progress Services table entry to drop "family" from its wording.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
386 lines
15 KiB
Python
386 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg)
|
|
with Anki's built-in type-the-answer input and Piper (offline, local
|
|
neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring
|
|
for one-time setup (venv, genanki + piper-tts, downloading a voice) — same
|
|
steps apply here, this is a standalone, self-contained script otherwise.
|
|
|
|
Decks:
|
|
prehs symbol<->name, elements 1-36 (H through Kr)
|
|
hs symbol<->name plus number->symbol, all 118 elements
|
|
category element category as multiple choice (A/B/C/D shown as
|
|
plain text options — not a clickable UI, since that needs
|
|
a desktop-only Anki add-on and would break on
|
|
AnkiDroid/AnkiMobile), type the letter — only elements
|
|
with a confirmed category (excludes 8 very recent
|
|
superheavy elements whose category is still officially
|
|
unconfirmed)
|
|
|
|
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
|
|
maintained public dataset), fetched and spot-checked against known facts
|
|
before being embedded below — not typed from memory.
|
|
|
|
Usage (run with the venv from anki-deck-math.py's docstring activated):
|
|
python3 anki-deck-periodic.py --deck prehs
|
|
python3 anki-deck-periodic.py --deck hs
|
|
python3 anki-deck-periodic.py --deck category
|
|
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
|
|
automatically; --dry-run-tts to test the deck-building logic without any
|
|
voice model at all, using silent placeholder audio)
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import genanki
|
|
import os
|
|
import random
|
|
import subprocess
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
|
|
parser.add_argument("--voice", default="en_US-lessac-medium")
|
|
parser.add_argument("--model-path", default=None)
|
|
parser.add_argument("--dry-run-tts", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
SCRATCH = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
_CANDIDATES = [
|
|
args.model_path,
|
|
f"{args.voice}.onnx",
|
|
os.path.join(SCRATCH, f"{args.voice}.onnx"),
|
|
os.path.expanduser(f"~/{args.voice}.onnx"),
|
|
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
|
|
]
|
|
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
|
|
|
|
if VOICE_MODEL is None and not args.dry_run_tts:
|
|
raise SystemExit(
|
|
f"Voice model for '{args.voice}' not found. Checked:\n"
|
|
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
|
|
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
|
|
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
|
|
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
|
|
)
|
|
|
|
|
|
def piper_tts(text: str, out_path: str) -> None:
|
|
if args.dry_run_tts:
|
|
with open(out_path, "wb") as f:
|
|
f.write(
|
|
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
|
|
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
|
|
)
|
|
return
|
|
subprocess.run(
|
|
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
|
|
input=text.encode("utf-8"),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
|
|
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
|
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
|
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
|
|
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
|
"eighty", "ninety"]
|
|
|
|
|
|
def num2words(n):
|
|
if n < 20:
|
|
return ONES[n]
|
|
if n < 100:
|
|
t, o = divmod(n, 10)
|
|
return TENS[t] + ("-" + ONES[o] if o else "")
|
|
h, rem = divmod(n, 100)
|
|
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
|
|
|
|
|
|
assert num2words(1) == "one"
|
|
assert num2words(26) == "twenty-six"
|
|
assert num2words(118) == "one hundred eighteen"
|
|
|
|
# ─── Element data: (atomic_number, symbol, name, category-or-None) ─────────
|
|
# category is None for the 8 most recently synthesized superheavy elements
|
|
# whose chemical category is still officially unconfirmed (excluded from
|
|
# the category deck below, still included in prehs/hs symbol/name/number).
|
|
ELEMENTS = [
|
|
(1, 'H', 'Hydrogen', 'diatomic nonmetal'),
|
|
(2, 'He', 'Helium', 'noble gas'),
|
|
(3, 'Li', 'Lithium', 'alkali metal'),
|
|
(4, 'Be', 'Beryllium', 'alkaline earth metal'),
|
|
(5, 'B', 'Boron', 'metalloid'),
|
|
(6, 'C', 'Carbon', 'polyatomic nonmetal'),
|
|
(7, 'N', 'Nitrogen', 'diatomic nonmetal'),
|
|
(8, 'O', 'Oxygen', 'diatomic nonmetal'),
|
|
(9, 'F', 'Fluorine', 'diatomic nonmetal'),
|
|
(10, 'Ne', 'Neon', 'noble gas'),
|
|
(11, 'Na', 'Sodium', 'alkali metal'),
|
|
(12, 'Mg', 'Magnesium', 'alkaline earth metal'),
|
|
(13, 'Al', 'Aluminium', 'post-transition metal'),
|
|
(14, 'Si', 'Silicon', 'metalloid'),
|
|
(15, 'P', 'Phosphorus', 'polyatomic nonmetal'),
|
|
(16, 'S', 'Sulfur', 'polyatomic nonmetal'),
|
|
(17, 'Cl', 'Chlorine', 'diatomic nonmetal'),
|
|
(18, 'Ar', 'Argon', 'noble gas'),
|
|
(19, 'K', 'Potassium', 'alkali metal'),
|
|
(20, 'Ca', 'Calcium', 'alkaline earth metal'),
|
|
(21, 'Sc', 'Scandium', 'transition metal'),
|
|
(22, 'Ti', 'Titanium', 'transition metal'),
|
|
(23, 'V', 'Vanadium', 'transition metal'),
|
|
(24, 'Cr', 'Chromium', 'transition metal'),
|
|
(25, 'Mn', 'Manganese', 'transition metal'),
|
|
(26, 'Fe', 'Iron', 'transition metal'),
|
|
(27, 'Co', 'Cobalt', 'transition metal'),
|
|
(28, 'Ni', 'Nickel', 'transition metal'),
|
|
(29, 'Cu', 'Copper', 'transition metal'),
|
|
(30, 'Zn', 'Zinc', 'transition metal'),
|
|
(31, 'Ga', 'Gallium', 'post-transition metal'),
|
|
(32, 'Ge', 'Germanium', 'metalloid'),
|
|
(33, 'As', 'Arsenic', 'metalloid'),
|
|
(34, 'Se', 'Selenium', 'polyatomic nonmetal'),
|
|
(35, 'Br', 'Bromine', 'diatomic nonmetal'),
|
|
(36, 'Kr', 'Krypton', 'noble gas'),
|
|
(37, 'Rb', 'Rubidium', 'alkali metal'),
|
|
(38, 'Sr', 'Strontium', 'alkaline earth metal'),
|
|
(39, 'Y', 'Yttrium', 'transition metal'),
|
|
(40, 'Zr', 'Zirconium', 'transition metal'),
|
|
(41, 'Nb', 'Niobium', 'transition metal'),
|
|
(42, 'Mo', 'Molybdenum', 'transition metal'),
|
|
(43, 'Tc', 'Technetium', 'transition metal'),
|
|
(44, 'Ru', 'Ruthenium', 'transition metal'),
|
|
(45, 'Rh', 'Rhodium', 'transition metal'),
|
|
(46, 'Pd', 'Palladium', 'transition metal'),
|
|
(47, 'Ag', 'Silver', 'transition metal'),
|
|
(48, 'Cd', 'Cadmium', 'transition metal'),
|
|
(49, 'In', 'Indium', 'post-transition metal'),
|
|
(50, 'Sn', 'Tin', 'post-transition metal'),
|
|
(51, 'Sb', 'Antimony', 'metalloid'),
|
|
(52, 'Te', 'Tellurium', 'metalloid'),
|
|
(53, 'I', 'Iodine', 'diatomic nonmetal'),
|
|
(54, 'Xe', 'Xenon', 'noble gas'),
|
|
(55, 'Cs', 'Cesium', 'alkali metal'),
|
|
(56, 'Ba', 'Barium', 'alkaline earth metal'),
|
|
(57, 'La', 'Lanthanum', 'lanthanide'),
|
|
(58, 'Ce', 'Cerium', 'lanthanide'),
|
|
(59, 'Pr', 'Praseodymium', 'lanthanide'),
|
|
(60, 'Nd', 'Neodymium', 'lanthanide'),
|
|
(61, 'Pm', 'Promethium', 'lanthanide'),
|
|
(62, 'Sm', 'Samarium', 'lanthanide'),
|
|
(63, 'Eu', 'Europium', 'lanthanide'),
|
|
(64, 'Gd', 'Gadolinium', 'lanthanide'),
|
|
(65, 'Tb', 'Terbium', 'lanthanide'),
|
|
(66, 'Dy', 'Dysprosium', 'lanthanide'),
|
|
(67, 'Ho', 'Holmium', 'lanthanide'),
|
|
(68, 'Er', 'Erbium', 'lanthanide'),
|
|
(69, 'Tm', 'Thulium', 'lanthanide'),
|
|
(70, 'Yb', 'Ytterbium', 'lanthanide'),
|
|
(71, 'Lu', 'Lutetium', 'lanthanide'),
|
|
(72, 'Hf', 'Hafnium', 'transition metal'),
|
|
(73, 'Ta', 'Tantalum', 'transition metal'),
|
|
(74, 'W', 'Tungsten', 'transition metal'),
|
|
(75, 'Re', 'Rhenium', 'transition metal'),
|
|
(76, 'Os', 'Osmium', 'transition metal'),
|
|
(77, 'Ir', 'Iridium', 'transition metal'),
|
|
(78, 'Pt', 'Platinum', 'transition metal'),
|
|
(79, 'Au', 'Gold', 'transition metal'),
|
|
(80, 'Hg', 'Mercury', 'transition metal'),
|
|
(81, 'Tl', 'Thallium', 'post-transition metal'),
|
|
(82, 'Pb', 'Lead', 'post-transition metal'),
|
|
(83, 'Bi', 'Bismuth', 'post-transition metal'),
|
|
(84, 'Po', 'Polonium', 'post-transition metal'),
|
|
(85, 'At', 'Astatine', 'diatomic nonmetal'),
|
|
(86, 'Rn', 'Radon', 'noble gas'),
|
|
(87, 'Fr', 'Francium', 'alkali metal'),
|
|
(88, 'Ra', 'Radium', 'alkaline earth metal'),
|
|
(89, 'Ac', 'Actinium', 'actinide'),
|
|
(90, 'Th', 'Thorium', 'actinide'),
|
|
(91, 'Pa', 'Protactinium', 'actinide'),
|
|
(92, 'U', 'Uranium', 'actinide'),
|
|
(93, 'Np', 'Neptunium', 'actinide'),
|
|
(94, 'Pu', 'Plutonium', 'actinide'),
|
|
(95, 'Am', 'Americium', 'actinide'),
|
|
(96, 'Cm', 'Curium', 'actinide'),
|
|
(97, 'Bk', 'Berkelium', 'actinide'),
|
|
(98, 'Cf', 'Californium', 'actinide'),
|
|
(99, 'Es', 'Einsteinium', 'actinide'),
|
|
(100, 'Fm', 'Fermium', 'actinide'),
|
|
(101, 'Md', 'Mendelevium', 'actinide'),
|
|
(102, 'No', 'Nobelium', 'actinide'),
|
|
(103, 'Lr', 'Lawrencium', 'actinide'),
|
|
(104, 'Rf', 'Rutherfordium', 'transition metal'),
|
|
(105, 'Db', 'Dubnium', 'transition metal'),
|
|
(106, 'Sg', 'Seaborgium', 'transition metal'),
|
|
(107, 'Bh', 'Bohrium', 'transition metal'),
|
|
(108, 'Hs', 'Hassium', 'transition metal'),
|
|
(109, 'Mt', 'Meitnerium', None),
|
|
(110, 'Ds', 'Darmstadtium', None),
|
|
(111, 'Rg', 'Roentgenium', None),
|
|
(112, 'Cn', 'Copernicium', None),
|
|
(113, 'Nh', 'Nihonium', 'post-transition metal'),
|
|
(114, 'Fl', 'Flerovium', 'post-transition metal'),
|
|
(115, 'Mc', 'Moscovium', None),
|
|
(116, 'Lv', 'Livermorium', None),
|
|
(117, 'Ts', 'Tennessine', None),
|
|
(118, 'Og', 'Oganesson', None),
|
|
]
|
|
assert len(ELEMENTS) == 118
|
|
assert [e[0] for e in ELEMENTS] == list(range(1, 119))
|
|
assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal')
|
|
assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal')
|
|
assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None)
|
|
|
|
ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None})
|
|
|
|
|
|
def build_model(deck_key):
|
|
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
|
model_id = 1_700_000_000 + (voice_hash % 90_000_000)
|
|
return model_id, genanki.Model(
|
|
model_id,
|
|
f"Periodic Table ({deck_key}, {args.voice})",
|
|
fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
|
|
templates=[{
|
|
"name": "Card",
|
|
"qfmt": """
|
|
<div class="prompt">{{Prompt}}</div>
|
|
{{QSound}}
|
|
{{type:Answer}}
|
|
""",
|
|
"afmt": """
|
|
<div class="prompt">{{Prompt}}</div>
|
|
<hr id="answer">
|
|
{{type:Answer}}
|
|
{{ASound}}
|
|
""",
|
|
}],
|
|
css="""
|
|
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
|
|
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
|
|
""",
|
|
)
|
|
|
|
|
|
def build_deck(deck_key, deck_title):
|
|
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
|
deck_id = 2_100_000_000 + (voice_hash % 90_000_000)
|
|
return genanki.Deck(deck_id, deck_title)
|
|
|
|
|
|
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
|
|
qfile = f"q_{tag}.wav"
|
|
afile = f"a_{tag}.wav"
|
|
qpath = os.path.join(MEDIA_DIR, qfile)
|
|
apath = os.path.join(MEDIA_DIR, afile)
|
|
piper_tts(qtext, qpath)
|
|
piper_tts(atext, apath)
|
|
media_files += [qpath, apath]
|
|
deck.add_note(genanki.Note(
|
|
model=model,
|
|
fields=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
|
|
))
|
|
|
|
|
|
def gen_prehs():
|
|
deck_key = "periodic_prehs"
|
|
model_id, model = build_model(deck_key)
|
|
deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)")
|
|
media_files = []
|
|
subset = [e for e in ELEMENTS if e[0] <= 36]
|
|
cards = []
|
|
for number, symbol, name, category in subset:
|
|
cards.append(("symbol_to_name", number, symbol, name))
|
|
cards.append(("name_to_symbol", number, symbol, name))
|
|
random.seed(50)
|
|
random.shuffle(cards)
|
|
for kind, number, symbol, name in cards:
|
|
if kind == "symbol_to_name":
|
|
add_note(deck, model, symbol, name,
|
|
f"What element has the symbol {symbol}?", name,
|
|
media_files, f"prehs_s2n_{number}")
|
|
else:
|
|
add_note(deck, model, name, symbol,
|
|
f"What is the symbol for {name}?", symbol,
|
|
media_files, f"prehs_n2s_{number}")
|
|
return deck, media_files, len(cards)
|
|
|
|
|
|
def gen_hs():
|
|
deck_key = "periodic_hs"
|
|
model_id, model = build_model(deck_key)
|
|
deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)")
|
|
media_files = []
|
|
cards = []
|
|
for number, symbol, name, category in ELEMENTS:
|
|
cards.append(("symbol_to_name", number, symbol, name))
|
|
cards.append(("name_to_symbol", number, symbol, name))
|
|
cards.append(("number_to_symbol", number, symbol, name))
|
|
random.seed(51)
|
|
random.shuffle(cards)
|
|
for kind, number, symbol, name in cards:
|
|
if kind == "symbol_to_name":
|
|
add_note(deck, model, symbol, name,
|
|
f"What element has the symbol {symbol}?", name,
|
|
media_files, f"hs_s2n_{number}")
|
|
elif kind == "name_to_symbol":
|
|
add_note(deck, model, name, symbol,
|
|
f"What is the symbol for {name}?", symbol,
|
|
media_files, f"hs_n2s_{number}")
|
|
else:
|
|
add_note(deck, model, f"Element #{number}", symbol,
|
|
f"What is the symbol for element number {num2words(number)}?", symbol,
|
|
media_files, f"hs_num2s_{number}")
|
|
return deck, media_files, len(cards)
|
|
|
|
|
|
def gen_category():
|
|
deck_key = "periodic_category"
|
|
model_id, model = build_model(deck_key)
|
|
deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)")
|
|
media_files = []
|
|
subset = [e for e in ELEMENTS if e[3] is not None]
|
|
random.seed(52)
|
|
shuffled = subset[:]
|
|
random.shuffle(shuffled)
|
|
|
|
letters = ["A", "B", "C", "D"]
|
|
for number, symbol, name, category in shuffled:
|
|
distractor_pool = [c for c in ALL_CATEGORIES if c != category]
|
|
distractors = random.sample(distractor_pool, 3)
|
|
choices = distractors + [category]
|
|
random.shuffle(choices)
|
|
correct_letter = letters[choices.index(category)]
|
|
|
|
prompt_lines = [f"{name} ({symbol})", ""]
|
|
for letter, choice in zip(letters, choices):
|
|
prompt_lines.append(f"{letter}) {choice}")
|
|
prompt = "\n".join(prompt_lines)
|
|
|
|
qtext = f"What category is {name}?"
|
|
atext = f"{category}"
|
|
add_note(deck, model, prompt, correct_letter, qtext, atext,
|
|
media_files, f"cat_{number}")
|
|
return deck, media_files, len(subset)
|
|
|
|
|
|
if args.deck == "prehs":
|
|
deck_key = "periodic_prehs"
|
|
elif args.deck == "hs":
|
|
deck_key = "periodic_hs"
|
|
else:
|
|
deck_key = "periodic_category"
|
|
|
|
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
|
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
|
|
|
GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category}
|
|
deck, media_files, count = GENERATORS[args.deck]()
|
|
|
|
package = genanki.Package(deck)
|
|
package.media_files = media_files
|
|
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
|
|
package.write_to_file(out_path)
|
|
|
|
size_mb = os.path.getsize(out_path) / (1024 * 1024)
|
|
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
|