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