From 0d924512717e2079210ed291c5b43d0293d57e87 Mon Sep 17 00:00:00 2001 From: Sochen Date: Wed, 11 Mar 2026 01:34:14 +0000 Subject: [PATCH] Sprint 16: collapsible card details + related words table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All secondary fields (shoresh, PoS, ktiv male, plural, related words) behind a "מידע נוסף" toggle button using HTML
/ - Conjugation back: English meaning, binyan also behind toggle - Related words: table format with word + meaning, sorted by frequency - Hebrew words not bold, English meanings 24px gray (#555) - "מִילִים קְשׁוּרוֹת" sub-header with nikkud inside toggle - "אֵיךְ אוֹמְרִים" prompt centered using hint class - New CSS: .more-toggle, .more-header, .related-header, .rw-word, .rw-meaning - Dark mode support for all new classes - Bump to v0.18 Co-Authored-By: Claude Opus 4.6 --- apkg_builder.py | 141 ++++++++++++++++++++++++++---------- card_preview.html | 110 ++++++++++++++++++++++++++++ card_preview_conj.html | 114 +++++++++++++++++++++++++++++ tests/test_detail_scrape.py | 38 ++++++++++ 4 files changed, 363 insertions(+), 40 deletions(-) create mode 100644 card_preview.html create mode 100644 card_preview_conj.html diff --git a/apkg_builder.py b/apkg_builder.py index 2aa03fe..4a2240a 100644 --- a/apkg_builder.py +++ b/apkg_builder.py @@ -35,7 +35,7 @@ COMPLETE_PLURAL_DECK_ID = 1_234_567_903 # Release version tag added to all notes so users can identify which release # their cards come from (visible in Anki's Browse view and card info). -RELEASE_TAG = "v0.17" +RELEASE_TAG = "v0.18" # Regex for extracting emoji and Hebrew prepositions from meaning strings EMOJI_RE = re.compile(r"[\U0001F000-\U0001FFFF\u2600-\u27FF\u2300-\u23FF\uFE00-\uFE0F]+") @@ -152,12 +152,6 @@ CARD_CSS = """ direction: rtl; text-align: center; } -.root-info { - font-size: 26px; - color: #222; - margin-top: 6px; - direction: rtl; -} .example { font-size: 24px; color: #222; @@ -218,17 +212,54 @@ CARD_CSS = """ direction: rtl; text-align: center; } +.more-toggle { + text-align: center; + direction: rtl; + margin-top: 8px; +} +.more-header { + display: inline-block; + font-size: 18px; + color: #555; + cursor: pointer; + list-style: none; + border: 1px solid #ccc; + border-radius: 16px; + padding: 4px 16px; + margin: 4px 0; + background: #f8f8f8; +} +.more-header::-webkit-details-marker { display: none; } +.more-header::before { content: "○ "; font-size: 14px; } +details[open] > .more-header::before { content: "● "; } +.related-header { + font-size: 22px; + color: #555; + text-align: center; + margin: 4px 0; +} +.rw-word { + display: table-cell; + font-size: 28px; + color: #222; + font-weight: normal; + text-align: right; + padding: 2px 0 2px 8px; + white-space: nowrap; +} +.rw-meaning { + display: table-cell; + font-size: 24px; + color: #555; + text-align: left; + direction: ltr; + padding: 2px 0; +} .conf-entry { margin: 8px 0; font-size: 28px; direction: rtl; } -.related-group { - direction: rtl; - text-align: center; - margin: 2px 0; - font-size: 26px; -} .emoji-img { font-size: 3.5em; text-align: center; @@ -244,7 +275,6 @@ CARD_CSS = """ .hebrew { color: #f0f0f0; } .hebrew-sm { color: #e0e0e0; } .meaning { color: #82b0ff; } - .root-info { color: #e0e0e0; } .sec-label { color: #e0e0e0; } .sec-key { color: #e0e0e0; } .sec-val { color: #e0e0e0; } @@ -254,6 +284,10 @@ CARD_CSS = """ .example { color: #e0e0e0; border-right-color: #555; } .divider { border-top-color: #333; } .freq-badge { color: #888; border-color: #444; } + .more-header { color: #bbb; background: #2a2a2e; border-color: #555; } + .related-header { color: #999; } + .rw-word { color: #e0e0e0; } + .rw-meaning { color: #999; } } """ @@ -272,6 +306,7 @@ VOCAB_BACK_HEB = """
{{Meaning}}
{{#Emoji}}
{{Emoji}}
{{/Emoji}} {{^Emoji}}{{#Image}}
{{/Image}}{{/Emoji}} +
מידע נוסף
{{#WordNoNikkud}}
לְלֹא נִיקּוּד:{{WordNoNikkud}}
{{/WordNoNikkud}} {{#Root}}
שֹׁרֶשׁ:{{Root}}
{{/Root}} @@ -280,9 +315,10 @@ VOCAB_BACK_HEB = """
{{#SharedRoots}}
-
מִילִים קְשׁוּרוֹת:
-
{{SharedRoots}}
+ +
{{SharedRoots}}
{{/SharedRoots}} +
""" VOCAB_FRONT_ENG = """ @@ -297,6 +333,7 @@ VOCAB_BACK_ENG = """
{{Word}}{{#Prep}} {{Prep}}{{/Prep}}
{{#Audio}}
{{Audio}}
{{/Audio}} +
מידע נוסף
{{#WordNoNikkud}}
לְלֹא נִיקּוּד:{{WordNoNikkud}}
{{/WordNoNikkud}} {{#Root}}
שֹׁרֶשׁ:{{Root}}
{{/Root}} @@ -305,9 +342,10 @@ VOCAB_BACK_ENG = """
{{#SharedRoots}}
-
מִילִים קְשׁוּרוֹת:
-
{{SharedRoots}}
+ +
{{SharedRoots}}
{{/SharedRoots}} +
""" VOCAB_FRONT_CLOZE = """ @@ -373,7 +411,7 @@ VOCAB_MODEL = genanki.Model( # ────────────────────────────────────────────────────────────────────────────── CONJ_FRONT = """ -
אֵיךְ אוֹמְרִים
+
אֵיךְ אוֹמְרִים
{{Pronoun}}
{{Infinitive}}{{#Prep}} ({{Prep}}){{/Prep}}{{#Voice}} ({{Voice}}){{/Voice}}
{{Tense}}
@@ -383,6 +421,7 @@ CONJ_BACK = """ {{FrontSide}}
{{ConjugatedForm}}{{#Prep}} ({{Prep}}){{/Prep}}
{{#Audio}}
{{Audio}}
{{/Audio}} +
מידע נוסף {{#Meaning}}
{{Meaning}}
{{/Meaning}}
שֹׁרֶשׁ:{{Root}}
@@ -390,9 +429,10 @@ CONJ_BACK = """
{{#RelatedVocab}}
-
מִילִים קְשׁוּרוֹת:
-
{{RelatedVocab}}
+ +
{{RelatedVocab}}
{{/RelatedVocab}} +
""" CONJ_CSS = CARD_CSS @@ -925,28 +965,33 @@ def build_vocab_deck( if pos_cat == "Verb" and pos_heb: cloze_hint = f"{meaning} ({pos_heb})" - # Related words (shared roots) grouped by PoS category + # Related words (shared roots) as a table: word — meaning, sorted by frequency related_html = "" if shared_roots_keys: - groups: dict[str, list[str]] = {} + rw_items: list[tuple[int, str, str]] = [] # (sort_key, nikkud, meaning) for rw_key in shared_roots_keys: rw_entry = words.get(rw_key) if rw_entry: rw_nikkud = rw_entry["word"]["nikkud"] - cat = _categorize_pos(rw_entry.get("pos", "")) + rw_meaning = rw_entry.get("meaning") or "" + if len(rw_meaning) > 40: + rw_meaning = rw_meaning[:37] + "…" + rw_freq = rw_entry.get("frequency") or 999999 else: - # Key not found: use the key itself as display text rw_nikkud = rw_key - cat = "Other" - groups.setdefault(cat, []).append(rw_nikkud) - parts = [] - for cat, rw_words in groups.items(): - if cat == "Other": - parts.append(f'') - else: - label = POS_CATEGORY_LABELS.get(cat, cat) - parts.append(f'') - related_html = "\n".join(parts) + rw_meaning = "" + rw_freq = 999999 + rw_items.append((rw_freq, rw_nikkud, rw_meaning)) + rw_items.sort(key=lambda x: x[0]) + rows_html: list[str] = [] + for _freq, rw_nikkud, rw_meaning in rw_items: + rows_html.append( + f'
' + f'{rw_nikkud}' + f'{rw_meaning}' + f"
" + ) + related_html = "\n".join(rows_html) # Plural form and gender (nouns only) plural_str = "" @@ -1042,13 +1087,17 @@ def build_conj_deck( note_count = 0 verb_count = 0 - # Build root → [related word nikkud] lookup for cross-linking - root_words: dict[str, list[str]] = {} + # Build root → [(freq, nikkud, meaning)] lookup for cross-linking + root_words: dict[str, list[tuple[int, str, str]]] = {} for entry in words.values(): root_list = entry.get("root") or [] root_key = " ".join(root_list) if root_key: - root_words.setdefault(root_key, []).append(entry["word"]["nikkud"]) + rw_meaning = entry.get("meaning") or "" + if len(rw_meaning) > 40: + rw_meaning = rw_meaning[:37] + "…" + rw_freq = entry.get("frequency") or 999999 + root_words.setdefault(root_key, []).append((rw_freq, entry["word"]["nikkud"], rw_meaning)) for _unique_key, entry in words.items(): conj = entry.get("conjugation") @@ -1089,8 +1138,20 @@ def build_conj_deck( # Clean up double spaces and trailing commas meaning = re.sub(r"\s{2,}", " ", meaning).strip(", ") - related = [w for w in root_words.get(root, []) if w != infinitive] - related_str = " ".join(related[:8]) if related else "" + related = [(f, w, m) for f, w, m in root_words.get(root, []) if w != infinitive] + if related: + related.sort(key=lambda x: x[0]) + related_rows = [] + for _freq, rw_nikkud, rw_meaning in related[:8]: + related_rows.append( + f'
' + f'{rw_nikkud}' + f'{rw_meaning}' + f"
" + ) + related_str = "\n".join(related_rows) + else: + related_str = "" forms = _forms_list_to_dict(active_forms_list) diff --git a/card_preview.html b/card_preview.html new file mode 100644 index 0000000..773f89c --- /dev/null +++ b/card_preview.html @@ -0,0 +1,110 @@ + + + + + + + + +

Vocab: English → Hebrew (BACK) — collapsed

+
+
English → Hebrew — Back (default: collapsed)
+
+ +
time (occasion), time round; once (when used as an adverb)
+
📍
+
+
פַּעַם
+ +
מידע נוסף +
+
לְלֹא נִיקּוּד:פעם
+
שֹׁרֶשׁ:פ.ע.ם
+
חֵלֶק דִּיבּוּר:שֵׁם עֶצֶם, נְקֵבָה
+
רַבִּים:פְּעָמִים
+
+
+ +
+
פַּעְמַיִםtwice, two times
+
לְפַעֵםto surge (feeling, emotion)
+
פַּעֲמוֹןbell
+
פְּעִימָהheartbeat; beat; stroke (technolo…
+
לִפְעֹםto beat, to pulse, to throb
+
לְהִתְפַּעֵםto be excited (emotionally)
+
לְהַפְעִיםto excite, to agitate (lit.)
+
לְהִפָּעֵםto be excited, to be thrilled
+
+
+ +
+
+ +

Same card — EXPANDED

+
+
English → Hebrew — Back (expanded)
+
+ +
time (occasion), time round; once (when used as an adverb)
+
📍
+
+
פַּעַם
+ +
מידע נוסף +
+
לְלֹא נִיקּוּד:פעם
+
שֹׁרֶשׁ:פ.ע.ם
+
חֵלֶק דִּיבּוּר:שֵׁם עֶצֶם, נְקֵבָה
+
רַבִּים:פְּעָמִים
+
+
+ +
+
פַּעְמַיִםtwice, two times
+
לְפַעֵםto surge (feeling, emotion)
+
פַּעֲמוֹןbell
+
פְּעִימָהheartbeat; beat; stroke (technolo…
+
לִפְעֹםto beat, to pulse, to throb
+
לְהִתְפַּעֵםto be excited (emotionally)
+
לְהַפְעִיםto excite, to agitate (lit.)
+
לְהִפָּעֵםto be excited, to be thrilled
+
+
+ +
+
+ + + diff --git a/card_preview_conj.html b/card_preview_conj.html new file mode 100644 index 0000000..ed26511 --- /dev/null +++ b/card_preview_conj.html @@ -0,0 +1,114 @@ + + + + + + + + +

Conjugation Card — FRONT

+
+
Front
+
+ +
אֵיךְ אוֹמְרִים
+
אַתָּה
+
לִשְׁמֹר (על)
+
בַּהוֹוֶה
+ +
+
+ +

Conjugation Card — BACK (collapsed)

+
+
Back — default state
+
+ +
אֵיךְ אוֹמְרִים
+
אַתָּה
+
לִשְׁמֹר (על)
+
בַּהוֹוֶה
+
+
שׁוֹמֵר (על)
+ +
מידע נוסף +
to guard; to keep, to maintain
+
+
שֹׁרֶשׁ:שׁ.מ.ר
+
בִּנְיָן:פָּעַל
+
+
+ +
+
מִשְׁמָרguard, watch; shift
+
שׁוֹמֵרguard, watchman
+
שְׁמִירָהguarding, watching
+
לְהִשָּׁמֵרto beware, to be careful
+
+
+ +
+
+ +

Conjugation Card — BACK (expanded)

+
+
Back — expanded
+
+ +
אֵיךְ אוֹמְרִים
+
אַתָּה
+
לִשְׁמֹר (על)
+
בַּהוֹוֶה
+
+
שׁוֹמֵר (על)
+ +
מידע נוסף +
to guard; to keep, to maintain
+
+
שֹׁרֶשׁ:שׁ.מ.ר
+
בִּנְיָן:פָּעַל
+
+
+ +
+
מִשְׁמָרguard, watch; shift
+
שׁוֹמֵרguard, watchman
+
שְׁמִירָהguarding, watching
+
לְהִשָּׁמֵרto beware, to be careful
+
+
+ +
+
+ + + diff --git a/tests/test_detail_scrape.py b/tests/test_detail_scrape.py index 8a040c5..ef4fd6d 100644 --- a/tests/test_detail_scrape.py +++ b/tests/test_detail_scrape.py @@ -484,3 +484,41 @@ class TestScrapePrepositionDetail: def test_empty_on_no_table(self) -> None: result = _scrape_preposition_detail("missing", "", "") assert result == {} + + +# --------------------------------------------------------------------------- +# Tests for _parse_noun_gender_mishkal mishkal extraction +# --------------------------------------------------------------------------- + +from bs4 import BeautifulSoup # noqa: E402 + +from pealim_detail_scrape import _parse_noun_gender_mishkal # noqa: E402 + + +class TestNounGenderMishkal: + def test_noun_with_mishkal(self): + html = '

Noun – ketel pattern, masculine

' + soup = BeautifulSoup(html, "html.parser") + gender, mishkal = _parse_noun_gender_mishkal(soup) + assert gender == "masculine" + assert mishkal == "ketel" + + def test_noun_without_mishkal(self): + html = "

Noun – masculine

" + soup = BeautifulSoup(html, "html.parser") + gender, mishkal = _parse_noun_gender_mishkal(soup) + assert gender == "masculine" + assert mishkal == "" + + def test_adjective_mishkal(self): + html = '

Adjective – katul pattern

' + soup = BeautifulSoup(html, "html.parser") + _, mishkal = _parse_noun_gender_mishkal(soup) + assert mishkal == "katul" + + def test_feminine_noun(self): + html = '

Noun – ketel pattern, feminine

' + soup = BeautifulSoup(html, "html.parser") + gender, mishkal = _parse_noun_gender_mishkal(soup) + assert gender == "feminine" + assert mishkal == "ketel"