stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

dictionary.py (15620B)


      1 """Plover dictionary loader and word selection for stenography practice."""
      2 
      3 import configparser
      4 import json
      5 import os
      6 import random
      7 import re
      8 from pathlib import Path
      9 
     10 
     11 # Top 500 most common English words for frequency ranking
     12 COMMON_WORDS = [
     13     "the", "be", "to", "of", "and", "a", "in", "that", "have", "i",
     14     "it", "for", "not", "on", "with", "he", "as", "you", "do", "at",
     15     "this", "but", "his", "by", "from", "they", "we", "her", "she", "or",
     16     "an", "will", "my", "one", "all", "would", "there", "their", "what", "so",
     17     "up", "out", "if", "about", "who", "get", "which", "go", "me", "when",
     18     "make", "can", "like", "time", "no", "just", "him", "know", "take", "people",
     19     "into", "year", "your", "good", "some", "could", "them", "see", "other", "than",
     20     "then", "now", "look", "only", "come", "its", "over", "think", "also", "back",
     21     "after", "use", "two", "how", "our", "work", "first", "well", "way", "even",
     22     "new", "want", "because", "any", "these", "give", "day", "most", "us", "great",
     23     "find", "here", "thing", "many", "still", "between", "long", "own", "say", "help",
     24     "through", "much", "before", "line", "right", "too", "mean", "old", "same", "tell",
     25     "boy", "did", "three", "call", "hand", "high", "keep", "last", "let", "begin",
     26     "seem", "country", "place", "never", "run", "read", "why", "while", "world", "little",
     27     "house", "city", "add", "story", "set", "change", "move", "play", "learn", "should",
     28     "home", "big", "end", "put", "ask", "under", "turn", "show", "try", "life",
     29     "off", "every", "near", "head", "food", "far", "thought", "few", "left", "school",
     30     "open", "next", "hard", "along", "both", "might", "must", "start", "point", "kind",
     31     "need", "children", "number", "each", "side", "part", "later", "word", "always", "those",
     32     "feel", "talk", "book", "early", "went", "got", "been", "name", "live", "page",
     33     "group", "carry", "took", "young", "often", "important", "until", "girl", "close", "night",
     34     "real", "almost", "light", "above", "man", "woman", "small", "water", "write", "bring",
     35     "once", "face", "family", "white", "air", "room", "mother", "area", "money", "state",
     36     "stop", "leave", "power", "door", "stand", "form", "car", "another", "problem", "child",
     37     "human", "service", "eye", "case", "public", "question", "look", "body", "become", "sure",
     38     "already", "several", "minute", "second", "war", "better", "during", "number", "course", "against",
     39     "different", "system", "father", "away", "moment", "company", "may", "level", "enough", "develop",
     40     "business", "study", "game", "sit", "yes", "nothing", "age", "government", "half", "large",
     41     "top", "social", "possible", "program", "short", "sense", "best", "full", "result", "idea",
     42     "among", "yet", "week", "hear", "love", "plan", "death", "such", "less", "done",
     43     "together", "hand", "high", "late", "hold", "free", "produce", "land", "heart", "reason",
     44     "cut", "since", "today", "force", "report", "believe", "person", "town", "build", "control",
     45     "student", "past", "clear", "true", "health", "cost", "able", "include", "accept", "allow",
     46     "drive", "process", "front", "law", "grow", "type", "value", "meet", "four", "ever",
     47     "main", "support", "view", "street", "fact", "lot", "pay", "art", "care", "simple",
     48     "deal", "voice", "act", "issue", "miss", "present", "red", "low", "month", "class",
     49     "offer", "rule", "step", "pass", "appear", "record", "table", "morning", "model", "interest",
     50     "bit", "fine", "oil", "stay", "bed", "order", "reach", "rest", "whole", "continue",
     51     "return", "effect", "happy", "dark", "increase", "quite", "single", "strong", "require", "rather",
     52     "create", "expect", "market", "term", "paper", "local", "effort", "test", "wrong", "common",
     53     "watch", "draw", "provide", "describe", "remember", "consider", "nation", "member", "break", "across",
     54     "note", "fish", "picture", "animal", "source", "rock", "field", "perhaps", "window", "nature",
     55     "horse", "material", "happen", "lost", "ball", "join", "five", "upon", "fall", "cover",
     56     "explain", "hot", "rate", "road", "plant", "black", "example", "check", "fire", "cold",
     57     "language", "sit", "star", "color", "decide", "space", "blue", "deep", "rock", "tree",
     58     "ground", "song", "final", "range", "building", "action", "sound", "river", "complete", "cause",
     59     "surface", "ocean", "object", "sleep", "feeling", "thus", "music", "million", "wind", "pain",
     60     "figure", "middle", "position", "outside", "wide", "alone", "glass", "store", "piece", "notice",
     61     "sign", "west", "science", "center", "test", "press", "travel", "green", "sort", "total",
     62     "garden", "chance", "south", "safe", "edge", "forget", "dinner", "gold", "chair",
     63 ]
     64 
     65 
     66 # Steno order for parsing strokes
     67 STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ"
     68 
     69 
     70 class PloverDictionary:
     71     """Loads and manages Plover steno dictionaries."""
     72 
     73     def __init__(self, config_path=None):
     74         if config_path is None:
     75             config_path = os.path.expanduser("~/.config/plover/plover.cfg")
     76 
     77         self.config_path = config_path
     78         self.config_dir = os.path.dirname(config_path)
     79         self.stroke_to_word = {}
     80         self.word_to_strokes = {}
     81         self._practice_words = []
     82         self._single_stroke_words = []
     83         self._common_word_set = set()
     84 
     85         self._load_dictionaries()
     86         self._build_word_index()
     87 
     88     def _load_dictionaries(self):
     89         """Load all enabled JSON dictionaries from plover.cfg."""
     90         config = configparser.ConfigParser()
     91         config.read(self.config_path)
     92 
     93         section = "System: English Stenotype"
     94         if not config.has_section(section):
     95             print(f"Warning: Section [{section}] not found in config")
     96             return
     97 
     98         dicts_json = config.get(section, "dictionaries", fallback="[]")
     99         dict_entries = json.loads(dicts_json)
    100 
    101         # Dictionaries listed first have higher priority
    102         # Process in order — first dict wins for stroke_to_word
    103         loaded_count = 0
    104         for entry in dict_entries:
    105             if not entry.get("enabled", False):
    106                 continue
    107 
    108             path = entry["path"]
    109 
    110             # Skip non-JSON dictionaries (Python plugins, etc.)
    111             if not path.endswith(".json"):
    112                 continue
    113 
    114             full_path = os.path.join(self.config_dir, path)
    115             if not os.path.exists(full_path):
    116                 print(f"Warning: Dictionary not found: {full_path}")
    117                 continue
    118 
    119             try:
    120                 with open(full_path, "r", encoding="utf-8") as f:
    121                     data = json.load(f)
    122 
    123                 dict_name = os.path.basename(path)
    124                 for stroke, translation in data.items():
    125                     # First dict wins for stroke_to_word
    126                     if stroke not in self.stroke_to_word:
    127                         self.stroke_to_word[stroke] = translation
    128 
    129                 loaded_count += 1
    130                 print(f"  Loaded: {path} ({len(data)} entries)")
    131 
    132             except (json.JSONDecodeError, OSError) as e:
    133                 print(f"Warning: Failed to load {path}: {e}")
    134 
    135         print(f"  Total dictionaries loaded: {loaded_count}")
    136         print(f"  Total stroke entries: {len(self.stroke_to_word)}")
    137 
    138     def _build_word_index(self):
    139         """Build word_to_strokes mapping and practice word lists."""
    140         # Build reverse mapping: word -> list of strokes
    141         raw_word_to_strokes = {}
    142 
    143         for stroke, translation in self.stroke_to_word.items():
    144             if not self.is_practice_word(translation):
    145                 continue
    146 
    147             word_key = translation.lower().strip()
    148             if word_key not in raw_word_to_strokes:
    149                 raw_word_to_strokes[word_key] = []
    150             raw_word_to_strokes[word_key].append(stroke)
    151 
    152         # Sort strokes by preference: fewer strokes first, then shorter string
    153         for word, strokes in raw_word_to_strokes.items():
    154             strokes.sort(key=lambda s: (s.count("/") + 1, len(s)))
    155             self.word_to_strokes[word] = strokes
    156 
    157         # Build practice word list
    158         self._practice_words = list(self.word_to_strokes.keys())
    159 
    160         # Build single-stroke words list
    161         self._single_stroke_words = [
    162             word for word, strokes in self.word_to_strokes.items()
    163             if any("/" not in s for s in strokes)
    164         ]
    165 
    166         # Build common words set (words in our frequency list that exist in dictionary)
    167         common_lower = [w.lower() for w in COMMON_WORDS]
    168         self._common_word_set = set(
    169             w for w in common_lower if w in self.word_to_strokes
    170         )
    171 
    172         print(f"  Practice words: {len(self._practice_words)}")
    173         print(f"  Single-stroke words: {len(self._single_stroke_words)}")
    174         print(f"  Common words (in dict): {len(self._common_word_set)}")
    175 
    176     @staticmethod
    177     def is_practice_word(translation):
    178         """
    179         Returns True only for plain English words/phrases suitable for practice.
    180 
    181         Filters out:
    182         - Plover commands/formatting (contains { or })
    183         - Non-alphabetic content (except spaces, apostrophes, hyphens)
    184         - Empty strings
    185         - All-uppercase abbreviations (except single chars like "I", "a")
    186         """
    187         if not translation or not translation.strip():
    188             return False
    189 
    190         # Filter out Plover commands and formatting
    191         if "{" in translation or "}" in translation:
    192             return False
    193 
    194         # Must contain only letters, spaces, apostrophes, hyphens
    195         if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation):
    196             return False
    197 
    198         # Filter out all-uppercase abbreviations (but allow single characters)
    199         stripped = translation.strip()
    200         if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper():
    201             return False
    202 
    203         return True
    204 
    205     @staticmethod
    206     def parse_stroke(stroke):
    207         """
    208         Parse a single stroke into individual key identifiers.
    209 
    210         Returns list of key identifiers like ["S-", "T-", "A", "-R"].
    211         For multi-stroke sequences (containing /), parses each stroke separately
    212         and returns a list of lists.
    213         """
    214         if "/" in stroke:
    215             return [PloverDictionary._parse_single_stroke(s) for s in stroke.split("/")]
    216         return [PloverDictionary._parse_single_stroke(stroke)]
    217 
    218     @staticmethod
    219     def _parse_single_stroke(stroke):
    220         """Parse a single stroke (no /) into key identifiers."""
    221         keys = []
    222 
    223         # Handle number bar
    224         if stroke.startswith("#"):
    225             keys.append("#")
    226             stroke = stroke[1:]
    227 
    228         if not stroke:
    229             return keys
    230 
    231         # Left-side keys (before vowels in steno order)
    232         left_keys = "STKPWHR"
    233         # Vowels/center
    234         center_keys = "AO*EU"
    235         # Right-side keys
    236         right_keys = "FRPBLGTSDZ"
    237 
    238         if "-" in stroke:
    239             # Explicit separator: left of - is left+center, right of - is right side
    240             left_part, right_part = stroke.split("-", 1)
    241             # Parse left part
    242             for ch in left_part:
    243                 if ch in left_keys:
    244                     keys.append(f"{ch}-")
    245                 elif ch in center_keys:
    246                     keys.append(ch)
    247             # Parse right part
    248             for ch in right_part:
    249                 if ch in right_keys:
    250                     keys.append(f"-{ch}")
    251                 elif ch in center_keys:
    252                     keys.append(ch)
    253         else:
    254             # No explicit separator — use steno order to disambiguate
    255             pos = 0
    256             for ch in stroke:
    257                 # Find this character at or after current position in steno order
    258                 found = False
    259                 for i in range(pos, len(STENO_ORDER)):
    260                     if STENO_ORDER[i] == ch:
    261                         pos = i + 1
    262                         # Determine which section this position falls in
    263                         # Steno order: #STKPWHRAO*EUFRPBLGTSDZ
    264                         # Indices:      0123456789...
    265                         # # = index 0
    266                         # S,T,K,P,W,H,R = indices 1-7 (left)
    267                         # A,O = indices 8-9 (center)
    268                         # * = index 10 (center)
    269                         # E,U = indices 11-12 (center)
    270                         # F,R,P,B,L,G,T,S,D,Z = indices 13-22 (right)
    271                         if i == 0:
    272                             keys.append("#")
    273                         elif i <= 7:
    274                             keys.append(f"{ch}-")
    275                         elif i <= 12:
    276                             keys.append(ch)
    277                         else:
    278                             keys.append(f"-{ch}")
    279                         found = True
    280                         break
    281 
    282                 if not found:
    283                     # Character not found in remaining steno order — skip
    284                     pass
    285 
    286         return keys
    287 
    288     def get_common_words(self, count=25):
    289         """Return common words that exist in the dictionary, in frequency order."""
    290         # Use the frequency list order, filtering to words in our dictionary
    291         result = []
    292         seen = set()
    293         for word in COMMON_WORDS:
    294             w = word.lower()
    295             if w in self.word_to_strokes and w not in seen:
    296                 result.append(w)
    297                 seen.add(w)
    298                 if len(result) >= count:
    299                     break
    300         return result
    301 
    302     def get_single_stroke_words(self, count=25):
    303         """Return random words achievable in a single stroke."""
    304         if len(self._single_stroke_words) <= count:
    305             return list(self._single_stroke_words)
    306         return random.sample(self._single_stroke_words, count)
    307 
    308     def get_weak_words(self, word_stats, count=25):
    309         """
    310         Return words with low accuracy from word_stats.
    311 
    312         word_stats: list of dicts with 'word', 'attempts', 'correct' keys
    313         """
    314         # Filter to words with at least 3 attempts, sort by accuracy ascending
    315         weak = []
    316         for ws in word_stats:
    317             if ws["attempts"] >= 3:
    318                 accuracy = ws["correct"] / ws["attempts"] if ws["attempts"] > 0 else 0
    319                 weak.append((accuracy, ws["word"]))
    320 
    321         weak.sort(key=lambda x: x[0])
    322         words = [w for _, w in weak[:count]]
    323 
    324         # If not enough weak words, pad with random words
    325         if len(words) < count:
    326             remaining = count - len(words)
    327             available = [w for w in self._practice_words if w not in set(words)]
    328             if available:
    329                 words.extend(random.sample(available, min(remaining, len(available))))
    330 
    331         return words
    332 
    333     def get_new_words(self, practiced_words, count=25):
    334         """Return words never practiced before."""
    335         new_words = [w for w in self._practice_words if w not in practiced_words]
    336         if len(new_words) <= count:
    337             return new_words
    338         return random.sample(new_words, count)
    339 
    340     def get_random_words(self, count=25):
    341         """Return a random sample from all practice words."""
    342         if len(self._practice_words) <= count:
    343             return list(self._practice_words)
    344         return random.sample(self._practice_words, count)
    345 
    346     def get_strokes_for_word(self, word):
    347         """Get all valid strokes for a word."""
    348         return self.word_to_strokes.get(word.lower().strip(), [])
    349 
    350     def get_best_stroke(self, word):
    351         """Get the best (shortest) stroke for a word."""
    352         strokes = self.get_strokes_for_word(word)
    353         return strokes[0] if strokes else None