From ec09a98a24aba729a0b91abd9859c7b94d3bab53 Mon Sep 17 00:00:00 2001 From: Alex Kelly Date: Fri, 2 May 2025 16:37:45 -0400 Subject: [PATCH] initial checkin --- .gitignore | 1 + kana.py | 429 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 .gitignore create mode 100644 kana.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aaa372 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +kana_stats.db diff --git a/kana.py b/kana.py new file mode 100644 index 0000000..2ec8ae2 --- /dev/null +++ b/kana.py @@ -0,0 +1,429 @@ +import random +import time +import sqlite3 +from typing import Dict, List, Tuple +from wcwidth import wcswidth + +# Hiragana monographs (basic) +hiragana_monographs = { + 'あ': 'a', 'い': 'i', 'う': 'u', 'え': 'e', 'お': 'o', + 'か': 'ka', 'き': 'ki', 'く': 'ku', 'け': 'ke', 'こ': 'ko', + 'さ': 'sa', 'し': 'shi', 'す': 'su', 'せ': 'se', 'そ': 'so', + 'た': 'ta', 'ち': 'chi', 'つ': 'tsu', 'て': 'te', 'と': 'to', + 'な': 'na', 'に': 'ni', 'ぬ': 'nu', 'ね': 'ne', 'の': 'no', + 'は': 'ha', 'ひ': 'hi', 'ふ': 'fu', 'へ': 'he', 'ほ': 'ho', + 'ま': 'ma', 'み': 'mi', 'む': 'mu', 'め': 'me', 'も': 'mo', + 'や': 'ya', 'ゆ': 'yu', 'よ': 'yo', + 'ら': 'ra', 'り': 'ri', 'る': 'ru', 'れ': 're', 'ろ': 'ro', + 'わ': 'wa', 'を': 'wo', 'ん': 'n' +} +# Hiragana monographs with diacritics +hiragana_monographs_diacritics = { + 'が': 'ga', 'ぎ': 'gi', 'ぐ': 'gu', 'げ': 'ge', 'ご': 'go', + 'ざ': 'za', 'じ': 'ji', 'ず': 'zu', 'ぜ': 'ze', 'ぞ': 'zo', + 'だ': 'da', 'ぢ': 'ji', 'づ': 'zu', 'で': 'de', 'ど': 'do', + 'ば': 'ba', 'び': 'bi', 'ぶ': 'bu', 'べ': 'be', 'ぼ': 'bo', + 'ぱ': 'pa', 'ぴ': 'pi', 'ぷ': 'pu', 'ぺ': 'pe', 'ぽ': 'po' +} +# Hiragana digraphs (yōon) +hiragana_digraphs = { + 'きゃ': 'kya', 'きゅ': 'kyu', 'きょ': 'kyo', + 'しゃ': 'sha', 'しゅ': 'shu', 'しょ': 'sho', + 'ちゃ': 'cha', 'ちゅ': 'chu', 'ちょ': 'cho', + 'にゃ': 'nya', 'にゅ': 'nyu', 'にょ': 'nyo', + 'ひゃ': 'hya', 'ひゅ': 'hyu', 'ひょ': 'hyo', + 'みゃ': 'mya', 'みゅ': 'myu', 'みょ': 'myo', + 'りゃ': 'rya', 'りゅ': 'ryu', 'りょ': 'ryo', + 'ぎゃ': 'gya', 'ぎゅ': 'gyu', 'ぎょ': 'gyo', + 'じゃ': 'ja', 'じゅ': 'ju', 'じょ': 'jo', + 'びゃ': 'bya', 'びゅ': 'byu', 'びょ': 'byo', + 'ぴゃ': 'pya', 'ぴゅ': 'pyu', 'ぴょ': 'pyo', +} +# Hiragana digraphs with diacritics (subset already included above) +hiragana_digraphs_diacritics = { + 'ぎゃ': 'gya', 'ぎゅ': 'gyu', 'ぎょ': 'gyo', + 'じゃ': 'ja', 'じゅ': 'ju', 'じょ': 'jo', + 'ぢゃ': 'ja', 'ぢゅ': 'ju', 'ぢょ': 'jo', + 'びゃ': 'bya', 'びゅ': 'byu', 'びょ': 'byo', + 'ぴゃ': 'pya', 'ぴゅ': 'pyu', 'ぴょ': 'pyo', +} +# Katakana monographs (basic) +katakana_monographs = { + 'ア': 'a', 'イ': 'i', 'ウ': 'u', 'エ': 'e', 'オ': 'o', + 'カ': 'ka', 'キ': 'ki', 'ク': 'ku', 'ケ': 'ke', 'コ': 'ko', + 'サ': 'sa', 'シ': 'shi', 'ス': 'su', 'セ': 'se', 'ソ': 'so', + 'タ': 'ta', 'チ': 'chi', 'ツ': 'tsu', 'テ': 'te', 'ト': 'to', + 'ナ': 'na', 'ニ': 'ni', 'ヌ': 'nu', 'ネ': 'ne', 'ノ': 'no', + 'ハ': 'ha', 'ヒ': 'hi', 'フ': 'fu', 'ヘ': 'he', 'ホ': 'ho', + 'マ': 'ma', 'ミ': 'mi', 'ム': 'mu', 'メ': 'me', 'モ': 'mo', + 'ヤ': 'ya', 'ユ': 'yu', 'ヨ': 'yo', + 'ラ': 'ra', 'リ': 'ri', 'ル': 'ru', 'レ': 're', 'ロ': 'ro', + 'ワ': 'wa', 'ヲ': 'wo', 'ン': 'n' +} +# Katakana monographs with diacritics +katakana_monographs_diacritics = { + 'ガ': 'ga', 'ギ': 'gi', 'グ': 'gu', 'ゲ': 'ge', 'ゴ': 'go', + 'ザ': 'za', 'ジ': 'ji', 'ズ': 'zu', 'ゼ': 'ze', 'ゾ': 'zo', + 'ダ': 'da', 'ヂ': 'ji', 'ヅ': 'zu', 'デ': 'de', 'ド': 'do', + 'バ': 'ba', 'ビ': 'bi', 'ブ': 'bu', 'ベ': 'be', 'ボ': 'bo', + 'パ': 'pa', 'ピ': 'pi', 'プ': 'pu', 'ペ': 'pe', 'ポ': 'po' +} +# Katakana digraphs (yōon) +katakana_digraphs = { + 'キャ': 'kya', 'キュ': 'kyu', 'キョ': 'kyo', + 'シャ': 'sha', 'シュ': 'shu', 'ショ': 'sho', + 'チャ': 'cha', 'チュ': 'chu', 'チョ': 'cho', + 'ニャ': 'nya', 'ニュ': 'nyu', 'ニョ': 'nyo', + 'ヒャ': 'hya', 'ヒュ': 'hyu', 'ヒョ': 'hyo', + 'ミャ': 'mya', 'ミュ': 'myu', 'ミョ': 'myo', + 'リャ': 'rya', 'リュ': 'ryu', 'リョ': 'ryo', + 'ギャ': 'gya', 'ギュ': 'gyu', 'ギョ': 'gyo', + 'ジャ': 'ja', 'ジュ': 'ju', 'ジョ': 'jo', + 'ビャ': 'bya', 'ビュ': 'byu', 'ビョ': 'byo', + 'ピャ': 'pya', 'ピュ': 'pyu', 'ピョ': 'pyo', +} +# Katakana digraphs with diacritics (subset already included above) +katakana_digraphs_diacritics = { + 'ギャ': 'gya', 'ギュ': 'gyu', 'ギョ': 'gyo', + 'ジャ': 'ja', 'ジュ': 'ju', 'ジョ': 'jo', + 'ヂャ': 'ja', 'ヂュ': 'ju', 'ヂョ': 'jo', + 'ビャ': 'bya', 'ビュ': 'byu', 'ビョ': 'byo', + 'ピャ': 'pya', 'ピュ': 'pyu', 'ピョ': 'pyo', +} +# Extended Katakana for foreign sounds +katakana_extended = { + 'ウィ': 'wi', 'ウェ': 'we', 'ウォ': 'wo', + 'ヴァ': 'va', 'ヴィ': 'vi', 'ヴ': 'vu', 'ヴェ': 've', 'ヴォ': 'vo', + 'ファ': 'fa', 'フィ': 'fi', 'フェ': 'fe', 'フォ': 'fo', 'フュ': 'fyu', + 'ティ': 'ti', 'ディ': 'di', 'トゥ': 'tu', 'ドゥ': 'du', + 'チェ': 'che', 'シェ': 'she', 'ジェ': 'je', + 'ツァ': 'tsa', 'ツィ': 'tsi', 'ツェ': 'tse', 'ツォ': 'tso', + 'ティ': 'ti', 'ディ': 'di', + 'デュ': 'dyu', 'テュ': 'tyu', + 'イェ': 'ye', 'ウュ': 'wyu', + 'キェ': 'kye', 'ギェ': 'gye', + 'クァ': 'kwa', 'クィ': 'kwi', 'クェ': 'kwe', 'クォ': 'kwo', + 'グァ': 'gwa', 'グィ': 'gwi', 'グェ': 'gwe', 'グォ': 'gwo', + 'スィ': 'si', 'ズィ': 'zi', + 'ツュ': 'tyu', + 'ニェ': 'nye', 'ヒェ': 'hye', 'ビェ': 'bye', 'ピェ': 'pye', + 'ミェ': 'mye', 'リェ': 'rye', + 'フョ': 'fyo', + 'ホゥ': 'hu', + 'モゥ': 'mo', + 'ロゥ': 'ro', + 'シィ': 'syi', 'ジィ': 'jyi', + 'ティ': 'tyi', 'ディ': 'dyi', + 'トゥ': 'twu', 'ドゥ': 'dwu', + 'テャ': 'tya', 'テュ': 'tyu', 'テョ': 'tyo', + 'デャ': 'dya', 'デュ': 'dyu', 'デョ': 'dyo', + 'フャ': 'fya', 'フュ': 'fyu', 'フョ': 'fyo', + 'グァ': 'gwa', +} + +def choose_kana_sets() -> Dict[str, str]: + """Let user choose which kana sets to practice with. Supports multiple selections.""" + print("\nWhich kana would you like to practice? (You can select multiple, e.g. 1,3,5)") + print("1. Hiragana Monographs") + print("2. Hiragana Monographs with Diacritics") + print("3. Hiragana Digraphs") + print("4. Hiragana Digraphs with Diacritics") + print("5. Katakana Monographs") + print("6. Katakana Monographs with Diacritics") + print("7. Katakana Digraphs") + print("8. Katakana Digraphs with Diacritics") + print("9. Extended Katakana (foreign sounds)") + print("0. All of the above") + choice = input("Enter your choice(s) (0-9, comma-separated): ") + selected = [c.strip() for c in choice.split(',') if c.strip()] + if '0' in selected or choice.strip() == '0': + combined = {} + combined.update(hiragana_monographs) + combined.update(hiragana_monographs_diacritics) + combined.update(hiragana_digraphs) + combined.update(hiragana_digraphs_diacritics) + combined.update(katakana_monographs) + combined.update(katakana_monographs_diacritics) + combined.update(katakana_digraphs) + combined.update(katakana_digraphs_diacritics) + combined.update(katakana_extended) + return combined + group_map = { + '1': hiragana_monographs, + '2': hiragana_monographs_diacritics, + '3': hiragana_digraphs, + '4': hiragana_digraphs_diacritics, + '5': katakana_monographs, + '6': katakana_monographs_diacritics, + '7': katakana_digraphs, + '8': katakana_digraphs_diacritics, + '9': katakana_extended, + } + combined = {} + for sel in selected: + if sel in group_map: + combined.update(group_map[sel]) + if not combined: + print("No valid selection, defaulting to all groups.") + combined.update(hiragana_monographs) + combined.update(hiragana_monographs_diacritics) + combined.update(hiragana_digraphs) + combined.update(hiragana_digraphs_diacritics) + combined.update(katakana_monographs) + combined.update(katakana_monographs_diacritics) + combined.update(katakana_digraphs) + combined.update(katakana_digraphs_diacritics) + combined.update(katakana_extended) + return combined + +def update_kana_db(conn, kana, is_correct, response_time): + c = conn.cursor() + c.execute('SELECT right, wrong, total_time FROM kana_stats WHERE kana = ?', (kana,)) + row = c.fetchone() + if row is None: + right = 1 if is_correct else 0 + wrong = 0 if is_correct else 1 + c.execute('INSERT INTO kana_stats (kana, right, wrong, total_time) VALUES (?, ?, ?, ?)', + (kana, right, wrong, response_time)) + else: + right, wrong, total_time = row + if is_correct: + right += 1 + else: + wrong += 1 + total_time += response_time + c.execute('UPDATE kana_stats SET right = ?, wrong = ?, total_time = ? WHERE kana = ?', + (right, wrong, total_time, kana)) + conn.commit() + +def practice_kana(kana_dict: Dict[str, str], conn=None) -> Dict[str, Dict[str, List[float] or int]]: + """Practice kana and return per-character stats. Optionally update stats in DB.""" + stats = {kana: {'right': 0, 'wrong': 0, 'times': []} for kana in kana_dict} + kana_list = list(kana_dict.items()) + print("\nType 'quit' to end the practice session.") + print("Press Enter to start...") + input() + while True: + kana, romaji = random.choice(kana_list) + print(f"\nWhat is the romaji for: {kana}") + start_time = time.time() + answer = input("> ").lower().strip() + if answer == 'quit' or answer == '': + break + response_time = time.time() - start_time + is_correct = answer == romaji + if is_correct: + print("Correct!") + stats[kana]['right'] += 1 + else: + print(f"Incorrect. The correct answer is: {romaji}") + stats[kana]['wrong'] += 1 + stats[kana]['times'].append(response_time) + if conn is not None: + update_kana_db(conn, kana, is_correct, response_time) + return stats + +def fetch_overall_stats(conn, kana_list): + c = conn.cursor() + stats = {} + for kana in kana_list: + c.execute('SELECT right, wrong, total_time FROM kana_stats WHERE kana = ?', (kana,)) + row = c.fetchone() + if row: + right, wrong, total_time = row + stats[kana] = {'right': right, 'wrong': wrong, 'total_time': total_time} + else: + stats[kana] = {'right': 0, 'wrong': 0, 'total_time': 0.0} + return stats + +def display_results(session_stats: Dict[str, Dict[str, List[float] or int]], conn=None) -> None: + """Display per-character practice session and overall results.""" + if not session_stats or all((v['right'] + v['wrong']) == 0 for v in session_stats.values()): + print("\nNo results to display.") + return + kana_list = list(session_stats.keys()) + overall_stats = fetch_overall_stats(conn, kana_list) if conn else None + print("\n=== Per-Character Practice Results ===") + print(f"{'Kana':^6} | {'SessR':^5} | {'SessW':^5} | {'SessAcc':^8} | {'SessAvgT':^9} | {'AllR':^5} | {'AllW':^5} | {'AllAcc':^7} | {'AllAvgT':^8}") + print("-" * 74) + for kana in sorted(kana_list): + s = session_stats[kana] + sess_total = s['right'] + s['wrong'] + if sess_total == 0: + continue + sess_acc = (s['right'] / sess_total) * 100 + sess_avg_time = sum(s['times']) / sess_total if s['times'] else 0.0 + if overall_stats: + o = overall_stats[kana] + all_total = o['right'] + o['wrong'] + all_acc = (o['right'] / all_total) * 100 if all_total else 0.0 + all_avg_time = o['total_time'] / all_total if all_total else 0.0 + print(f"{kana:^6} | {s['right']:^5} | {s['wrong']:^5} | {sess_acc:7.1f}% | {sess_avg_time:8.2f} | {o['right']:^5} | {o['wrong']:^5} | {all_acc:6.1f}% | {all_avg_time:7.2f}") + else: + print(f"{kana:^6} | {s['right']:^5} | {s['wrong']:^5} | {sess_acc:7.1f}% | {sess_avg_time:8.2f}") + +def init_db(db_path: str = "kana_stats.db"): + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS kana_stats ( + kana TEXT PRIMARY KEY, + right INTEGER DEFAULT 0, + wrong INTEGER DEFAULT 0, + total_time REAL DEFAULT 0.0 + ) + ''') + conn.commit() + return conn + +def pad_display_width(s, width): + display_len = wcswidth(s) + if display_len >= width: + return s + return s + ' ' * (width - display_len) + +def show_all_stats(conn, columns=3): + # Define groupings + kana_groups = [ + ("Hiragana Monographs", hiragana_monographs), + ("Hiragana Monographs with Diacritics", hiragana_monographs_diacritics), + ("Hiragana Digraphs", hiragana_digraphs), + ("Hiragana Digraphs with Diacritics", hiragana_digraphs_diacritics), + ("Katakana Monographs", katakana_monographs), + ("Katakana Monographs with Diacritics", katakana_monographs_diacritics), + ("Katakana Digraphs", katakana_digraphs), + ("Katakana Digraphs with Diacritics", katakana_digraphs_diacritics), + ("Extended Katakana (foreign sounds)", katakana_extended), + ] + c = conn.cursor() + kana_col_width = 8 + col_header = f"{'Kana':<{kana_col_width}} {'Right':>5} {'Wrong':>5} {'Total':>5} {'Acc%':>6} {'AvgT':>6}" + sep = ' ' + print("\n=== Overall Kana Stats ===") + for group_name, group_dict in kana_groups: + sorted_kana = sorted(group_dict.keys()) + if not sorted_kana: + continue + # Gather stats for this group + stats = {} + for kana in sorted_kana: + c.execute('SELECT right, wrong, total_time FROM kana_stats WHERE kana = ?', (kana,)) + row = c.fetchone() + if row: + right, wrong, total_time = row + else: + right, wrong, total_time = 0, 0, 0.0 + stats[kana] = {'right': right, 'wrong': wrong, 'total_time': total_time} + print(f"\n--- {group_name} ---") + print(sep.join([col_header]*columns)) + print(sep.join(['-'*35]*columns)) + # Prepare rows + rows = [] + group_right = group_wrong = group_total = 0 + group_time = 0.0 + for kana in sorted_kana: + s = stats[kana] + total = s['right'] + s['wrong'] + accuracy = (s['right'] / total) * 100 if total else 0.0 + avg_time = s['total_time'] / total if total else 0.0 + kana_disp = pad_display_width(kana, kana_col_width) + row = f"{kana_disp}{s['right']:>5} {s['wrong']:>5} {total:>5} {accuracy:6.1f} {avg_time:6.2f}" + rows.append(row) + group_right += s['right'] + group_wrong += s['wrong'] + group_total += total + group_time += s['total_time'] + # Print in columns + for i in range(0, len(rows), columns): + print(sep.join(rows[i:i+columns])) + # Print summary under the last column + group_acc = (group_right / group_total) * 100 if group_total else 0.0 + group_avg_time = group_time / group_total if group_total else 0.0 + last_row_len = len(rows) % columns if len(rows) % columns != 0 else columns + empty_col = ' ' * 35 + print(sep.join([empty_col] * (last_row_len - 1)), end='' if last_row_len > 1 else '') + summary = f"{'[Summary]':<{kana_col_width}}{group_right:>5} {group_wrong:>5} {group_total:>5} {group_acc:6.1f} {group_avg_time:6.2f}" + print(summary) + +def show_most_wrong_kana(conn, top_n=10): + # Gather all kana from all groups + all_kana = {} + all_kana.update(hiragana_monographs) + all_kana.update(hiragana_monographs_diacritics) + all_kana.update(hiragana_digraphs) + all_kana.update(hiragana_digraphs_diacritics) + all_kana.update(katakana_monographs) + all_kana.update(katakana_monographs_diacritics) + all_kana.update(katakana_digraphs) + all_kana.update(katakana_digraphs_diacritics) + all_kana.update(katakana_extended) + c = conn.cursor() + stats = [] + for kana in all_kana: + c.execute('SELECT right, wrong, total_time FROM kana_stats WHERE kana = ?', (kana,)) + row = c.fetchone() + if row: + right, wrong, total_time = row + else: + right, wrong, total_time = 0, 0, 0.0 + stats.append((kana, right, wrong, total_time)) + # Sort by wrong answers descending, then by kana + stats.sort(key=lambda x: (-x[2], x[0])) + print(f"\n=== Kana with Most Wrong Answers (Top {top_n}) ===") + print(f"{'Kana':^6} | {'Wrong':^5} | {'Right':^5} | {'Total':^5} | {'Accuracy':^8} | {'Avg Time (s)':^12}") + print("-" * 56) + shown = 0 + for kana, right, wrong, total_time in stats: + total = right + wrong + if wrong == 0: + continue + accuracy = (right / total) * 100 if total else 0.0 + avg_time = total_time / total if total else 0.0 + print(f"{kana:^6} | {wrong:^5} | {right:^5} | {total:^5} | {accuracy:7.1f}% | {avg_time:11.2f}") + shown += 1 + if shown >= top_n: + break + if shown == 0: + print("No wrong answers yet! Great job!") + +def stats_menu(conn): + while True: + print("\n=== Stats Menu ===") + print("1. Show all stats") + print("2. Show most wrong kana") + print("q. Return to main menu") + choice = input("Enter your choice (1, 2, or q): ").strip().lower() + if choice == '1': + show_all_stats(conn) + input("\nPress Enter to return to the stats menu...") + elif choice == '2': + show_most_wrong_kana(conn) + input("\nPress Enter to return to the stats menu...") + elif choice == 'q': + break + else: + print("Invalid choice. Please enter 1, 2, or q.") + +def main_menu(): + conn = init_db() + while True: + print("\n=== Kana Practice Main Menu ===") + print("1. Practice kana") + print("2. Show stats") + print("q. Quit") + choice = input("Enter your choice (1, 2, or q): ").strip().lower() + if choice == '1': + kana_dict = choose_kana_sets() + stats = practice_kana(kana_dict, conn) + display_results(stats, conn) + input("\nPress Enter to return to the main menu...") + elif choice == '2': + stats_menu(conn) + elif choice == 'q': + print("Thanks for practicing! さようなら!") + break + else: + print("Invalid choice. Please enter 1, 2, or q.") + +if __name__ == "__main__": + main_menu()