plover_engine.py (25450B)
1 """Plover dictionary engine: loads JSON + Python dictionaries from plover.cfg.""" 2 3 import configparser 4 import importlib 5 import importlib.util 6 import json 7 import os 8 import random 9 import re 10 import sys 11 12 STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ" 13 _DISPLAY_OVERRIDES = {"i": "I"} 14 15 _CAP_COMMANDS = { 16 # Old-style 17 "{-|}": "cap_next", 18 "{^}{-|}": "cap_next_attach", 19 "{*-|}": "cap_retro", 20 "{*<}": "upper_retro", 21 "{<}": "upper_next", 22 "{>}": "lower_next", 23 "{*>}": "lower_retro", 24 # New-style (standalone) 25 "{:case:cap_first_word}": "cap_next", 26 "{:case:upper_first_word}": "upper_next", 27 "{:case:lower_first_char}": "lower_next", 28 "{:retro_case:cap_first_word}": "cap_retro", 29 "{:retro_case:upper_first_word}": "upper_retro", 30 "{:retro_case:lower_first_char}": "lower_retro", 31 # Compound: {} + command (space-aware cap) 32 "{}{-|}": "cap_next", 33 "{}{:case:cap_first_word}": "cap_next", 34 # Compound: {^} + command (attach + cap) 35 "{^}{:case:cap_first_word}": "cap_next_attach", 36 "{^}{:case:upper_first_word}": "upper_next_attach", 37 } 38 39 40 class PloverEngine: 41 def __init__(self, config_path=None): 42 self.config_path = config_path 43 self.config_dir = os.path.dirname(self.config_path) if self.config_path else "" 44 self.stroke_to_word = {} 45 self.word_to_strokes = {} 46 self.phrase_to_strokes = {} 47 self._phrase_display = {} 48 self.punctuation_to_strokes = {} 49 self.cap_strokes = {} 50 self._practice_words = [] 51 self._single_stroke_words = [] 52 self._python_dicts = [] 53 self._stroke_priority = {} 54 self._stroke_labels = {} 55 self._stroke_context = {} 56 self._ready = False 57 self.load_error = None 58 59 def _reset(self): 60 self.stroke_to_word.clear() 61 self.word_to_strokes.clear() 62 self.phrase_to_strokes.clear() 63 self._phrase_display.clear() 64 self.punctuation_to_strokes.clear() 65 self.cap_strokes.clear() 66 self._practice_words.clear() 67 self._single_stroke_words.clear() 68 self._python_dicts.clear() 69 self._stroke_priority.clear() 70 self._stroke_labels.clear() 71 self._stroke_context.clear() 72 self._ready = False 73 self.load_error = None 74 75 def load(self): 76 self._reset() 77 78 if not self.config_path or not os.path.exists(self.config_path): 79 self.load_error = f"Plover config not found: {self.config_path}" 80 print(f"Warning: {self.load_error}") 81 self._ready = True 82 return 83 84 config = configparser.ConfigParser() 85 config.read(self.config_path) 86 87 section = "System: English Stenotype" 88 if not config.has_section(section): 89 self.load_error = f"Section [{section}] not found in plover.cfg" 90 print(f"Warning: {self.load_error}") 91 self._ready = True 92 return 93 94 dicts_json = config.get(section, "dictionaries", fallback="[]") 95 dict_entries = json.loads(dicts_json) 96 97 json_count = 0 98 py_count = 0 99 100 for priority, entry in enumerate(dict_entries): 101 if not entry.get("enabled", False): 102 continue 103 104 path = entry["path"] 105 full_path = os.path.join(self.config_dir, path) 106 107 if path.endswith(".json"): 108 if self._load_json_dict(full_path, path, priority): 109 json_count += 1 110 elif path.endswith(".py"): 111 if self._load_python_dict(full_path, path, priority): 112 py_count += 1 113 114 print(f" JSON dictionaries: {json_count}") 115 print(f" Python dictionaries: {py_count}") 116 print(f" Total stroke entries: {len(self.stroke_to_word)}") 117 118 self._build_indexes() 119 self._ready = True 120 121 def load_from_uploaded(self, dict_dir, dict_entries): 122 """Load dictionaries from user-uploaded files. 123 124 dict_entries: list of dicts with keys: filename, enabled, dict_order 125 """ 126 self._reset() 127 128 if not os.path.isdir(dict_dir): 129 self.load_error = "No dictionaries uploaded yet" 130 self._ready = True 131 return 132 133 sorted_entries = sorted(dict_entries, key=lambda e: e.get("dict_order", 0)) 134 json_count = 0 135 py_count = 0 136 137 for priority, entry in enumerate(sorted_entries): 138 if not entry.get("enabled", True): 139 continue 140 filename = entry["filename"] 141 full_path = os.path.join(dict_dir, filename) 142 143 if filename.endswith(".json"): 144 if self._load_json_dict(full_path, filename, priority): 145 json_count += 1 146 elif filename.endswith(".py"): 147 if self._load_python_dict(full_path, filename, priority): 148 py_count += 1 149 150 print(f" Uploaded JSON dictionaries: {json_count}") 151 print(f" Uploaded Python dictionaries: {py_count}") 152 print(f" Total stroke entries: {len(self.stroke_to_word)}") 153 154 self._build_indexes() 155 self._ready = True 156 157 @staticmethod 158 def parse_plover_config(cfg_content): 159 """Parse plover.cfg content and return the ordered list of dictionary entries.""" 160 config = configparser.ConfigParser() 161 config.read_string(cfg_content) 162 163 section = "System: English Stenotype" 164 if not config.has_section(section): 165 return [] 166 167 dicts_json = config.get(section, "dictionaries", fallback="[]") 168 raw = json.loads(dicts_json) 169 170 result = [] 171 for i, entry in enumerate(raw): 172 path = entry.get("path", "") 173 filename = os.path.basename(path) 174 if not filename: 175 continue 176 result.append({ 177 "filename": filename, 178 "original_path": path, 179 "enabled": entry.get("enabled", True), 180 "order": i, 181 }) 182 return result 183 184 def _load_json_dict(self, full_path, display_path, priority=0): 185 if not os.path.exists(full_path): 186 print(f" Warning: not found: {display_path}") 187 return False 188 try: 189 with open(full_path, "r", encoding="utf-8") as f: 190 data = json.load(f) 191 count = 0 192 for stroke, translation in data.items(): 193 if stroke not in self.stroke_to_word: 194 self.stroke_to_word[stroke] = translation 195 self._stroke_priority[stroke] = priority 196 count += 1 197 print(f" Loaded: {display_path} ({count} entries)") 198 return True 199 except (json.JSONDecodeError, OSError) as e: 200 print(f" Warning: failed to load {display_path}: {e}") 201 return False 202 203 def _load_python_dict(self, full_path, display_path, priority=0): 204 if not os.path.exists(full_path): 205 print(f" Warning: not found: {display_path}") 206 return False 207 try: 208 dict_dir = os.path.dirname(full_path) 209 module_name = os.path.splitext(os.path.basename(full_path))[0] 210 211 if dict_dir not in sys.path: 212 sys.path.insert(0, dict_dir) 213 214 safe_name = f"plover_pydict_{module_name.replace('-', '_')}" 215 spec = importlib.util.spec_from_file_location(safe_name, full_path) 216 mod = importlib.util.module_from_spec(spec) 217 spec.loader.exec_module(mod) 218 219 has_lookup = hasattr(mod, "lookup") 220 has_reverse = hasattr(mod, "reverse_lookup") 221 longest_key = getattr(mod, "LONGEST_KEY", 0) 222 223 if has_lookup: 224 self._python_dicts.append({ 225 "module": mod, 226 "name": module_name, 227 "path": display_path, 228 "has_reverse": has_reverse, 229 "longest_key": longest_key, 230 "priority": priority, 231 }) 232 label = "lookup+reverse" if has_reverse else "lookup only" 233 print(f" Loaded: {display_path} (python, {label})") 234 return True 235 else: 236 print(f" Skipped: {display_path} (no lookup function)") 237 return False 238 except Exception as e: 239 print(f" Warning: failed to load {display_path}: {e}") 240 return False 241 242 def _build_indexes(self): 243 raw = {} 244 origcase = {} 245 punct_raw = {} 246 for stroke, translation in self.stroke_to_word.items(): 247 stripped = translation.strip() 248 if _is_punctuation(stripped): 249 if stripped not in punct_raw: 250 punct_raw[stripped] = [] 251 punct_raw[stripped].append(stroke) 252 continue 253 plover_punct = _PLOVER_PUNCT.get(stripped) 254 if plover_punct: 255 if plover_punct not in punct_raw: 256 punct_raw[plover_punct] = [] 257 punct_raw[plover_punct].append(stroke) 258 continue 259 cap_type = _CAP_COMMANDS.get(stripped) 260 if cap_type: 261 if cap_type not in self.cap_strokes: 262 self.cap_strokes[cap_type] = [] 263 self.cap_strokes[cap_type].append(stroke) 264 continue 265 if not _is_practice_word(stripped): 266 continue 267 key = stripped.lower() 268 if key not in raw: 269 raw[key] = [] 270 if key != stripped and key not in origcase: 271 origcase[key] = stripped 272 raw[key].append(stroke) 273 274 prio = self._stroke_priority 275 for word, strokes in raw.items(): 276 strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s))) 277 if " " in word: 278 self.phrase_to_strokes[word] = strokes 279 if word not in self._phrase_display: 280 self._phrase_display[word] = origcase.get(word, word) 281 else: 282 self.word_to_strokes[word] = strokes 283 284 self._scan_python_dicts_for_punctuation(punct_raw) 285 286 for punct, strokes in punct_raw.items(): 287 strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s))) 288 self.punctuation_to_strokes[punct] = strokes 289 290 for cap_type, strokes in self.cap_strokes.items(): 291 strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s))) 292 293 if self.cap_strokes: 294 print(f" Capitalization commands: {sum(len(v) for v in self.cap_strokes.values())}") 295 296 self._practice_words = list(self.word_to_strokes.keys()) 297 self._single_stroke_words = [ 298 w for w, ss in self.word_to_strokes.items() 299 if any("/" not in s for s in ss) 300 ] 301 302 print(f" Practice words: {len(self._practice_words)}") 303 print(f" Phrases (from JSON): {len(self.phrase_to_strokes)}") 304 print(f" Punctuation entries: {len(self.punctuation_to_strokes)}") 305 print(f" Single-stroke words: {len(self._single_stroke_words)}") 306 307 def _scan_python_dicts_for_punctuation(self, punct_raw): 308 """Probe Python dicts to find strokes that produce punctuation.""" 309 target_chars = set(".,;:!?\"'()-[]{}…/") 310 found = 0 311 for pd in self._python_dicts: 312 mod = pd["module"] 313 sym_table = getattr(mod, "symbols", None) 314 if isinstance(sym_table, dict): 315 attach_method = getattr(mod, "attachmentMethod", "space") 316 found += self._scan_symbol_table( 317 sym_table, target_chars, punct_raw, 318 pd["priority"], attach_method) 319 if found: 320 print(f" Punctuation from Python dicts: {found}") 321 322 @staticmethod 323 def _emily_stroke_variants(starter, pattern, attachment_method): 324 """Generate attachment/capitalization variants for emily-style strokes. 325 Returns list of (stroke, label, context_tag) tuples.""" 326 def build(mid): 327 if mid: 328 return starter + mid + pattern 329 return starter + ("-" if pattern else "") + pattern 330 331 if attachment_method == "space": 332 return [ 333 (build(""), None, "no_space"), 334 (build("O"), "spc after", "space_after"), 335 (build("O*"), "spc + cap", "space_cap"), 336 (build("AO"), "spc both", "space_both"), 337 ] 338 else: 339 return [ 340 (build(""), None, "space_both"), 341 (build("A"), "attach left", "space_after"), 342 (build("A*"), "attach + cap", "space_cap"), 343 (build("AO"), "no spc", "no_space"), 344 ] 345 346 def _scan_symbol_table(self, sym_table, target_chars, punct_raw, 347 priority, attachment_method): 348 """Extract punctuation strokes from an emily-style symbols dict.""" 349 found = 0 350 for starter, patterns in sym_table.items(): 351 if not isinstance(patterns, dict): 352 continue 353 for pattern, variants in patterns.items(): 354 if isinstance(variants, str): 355 variants = [variants] 356 if not isinstance(variants, list): 357 continue 358 char = variants[0] if variants else "" 359 if len(char) == 1 and char in target_chars: 360 for stroke, label, ctx in self._emily_stroke_variants( 361 starter, pattern, attachment_method): 362 if char not in punct_raw: 363 punct_raw[char] = [] 364 if stroke not in punct_raw[char]: 365 punct_raw[char].append(stroke) 366 self._stroke_priority[stroke] = priority 367 self._stroke_context[stroke] = ctx 368 if label: 369 self._stroke_labels[stroke] = label 370 found += 1 371 return found 372 373 def get_strokes(self, text): 374 key = text.lower().strip() 375 result = list(self.word_to_strokes.get(key, [])) 376 if not result: 377 result = list(self.phrase_to_strokes.get(key, [])) 378 if not result: 379 result = self._python_reverse_lookup(text.strip()) 380 if not result and key != text.strip(): 381 result = self._python_reverse_lookup(key) 382 return result 383 384 def _python_reverse_lookup(self, text): 385 results = [] 386 for pd in self._python_dicts: 387 if not pd["has_reverse"]: 388 continue 389 try: 390 hits = pd["module"].reverse_lookup(text) 391 for stroke_tuple in hits: 392 if len(stroke_tuple) == 1: 393 results.append(stroke_tuple[0]) 394 except Exception: 395 pass 396 return results 397 398 def get_phrase_strokes(self, text): 399 key = text.lower().strip() 400 result = list(self.phrase_to_strokes.get(key, [])) 401 py_strokes = self._python_reverse_lookup(text.strip()) 402 if not py_strokes and key != text.strip(): 403 py_strokes = self._python_reverse_lookup(key) 404 for s in py_strokes: 405 if s not in result: 406 result.append(s) 407 return result 408 409 def detect_phrase_hints(self, words, current_index): 410 if current_index >= len(words): 411 return [] 412 hints = [] 413 max_len = min(8, len(words) - current_index + 1) 414 for length in range(2, max_len): 415 phrase = " ".join(words[current_index:current_index + length]) 416 strokes = self.get_phrase_strokes(phrase) 417 if strokes: 418 single = [s for s in strokes if "/" not in s] 419 best = single[:5] if single else strokes[:5] 420 hints.append({ 421 "phrase": phrase, 422 "words_covered": length, 423 "strokes": best, 424 "is_single_stroke": bool(single), 425 "keys": parse_stroke(best[0]) if best else [], 426 }) 427 hints.sort(key=lambda h: h["words_covered"], reverse=True) 428 return hints 429 430 def get_all_hints(self, word, context_words=None, context_index=None, 431 suppress_cap=False): 432 bare, trailing_punct = _strip_punctuation(word) 433 lookup_word = bare if bare else word 434 435 strokes = self.get_strokes(lookup_word) 436 all_hints = [] 437 for s in strokes: 438 sc = s.count("/") + 1 439 kc = sum(len(_parse_single_stroke(part)) for part in s.split("/")) 440 all_hints.append({ 441 "stroke": s, 442 "stroke_count": sc, 443 "key_count": kc, 444 "keys": parse_stroke(s), 445 }) 446 all_hints.sort(key=lambda h: (h["stroke_count"], h["key_count"])) 447 448 fingerspell = not all_hints and len(lookup_word) > 0 449 punct_hints = [] 450 for p in trailing_punct: 451 ps = self.punctuation_to_strokes.get(p, []) 452 if ps: 453 punct_hints.append({"char": p, "stroke": ps[0]}) 454 455 phrase_hints = [] 456 if context_words and context_index is not None: 457 phrase_hints = self.detect_phrase_hints(context_words, context_index) 458 459 cap_hints = [] 460 if (not suppress_cap and lookup_word and lookup_word[0].isupper() 461 and lookup_word not in _DISPLAY_OVERRIDES.values()): 462 for cap_type in ("cap_next", "cap_next_attach", "cap_retro"): 463 ss = self.cap_strokes.get(cap_type, []) 464 if ss: 465 cap_hints.append({"type": cap_type, "stroke": ss[0]}) 466 if (not suppress_cap and lookup_word and lookup_word.isupper() 467 and len(lookup_word) > 1): 468 for cap_type in ("upper_next", "upper_next_attach", "upper_retro"): 469 ss = self.cap_strokes.get(cap_type, []) 470 if ss: 471 cap_hints.append({"type": cap_type, "stroke": ss[0]}) 472 473 return { 474 "word_hints": all_hints[:10], 475 "phrase_hints": phrase_hints, 476 "fingerspell": fingerspell, 477 "punct_hints": punct_hints, 478 "cap_hints": cap_hints, 479 } 480 481 def get_punct_hints(self, char, limit=8, 482 space_after=False, cap_next=False): 483 """Build hint items for a punctuation character, sorted by context.""" 484 strokes = self.punctuation_to_strokes.get(char, []) 485 if space_after and cap_next: 486 target = "space_cap" 487 elif space_after: 488 target = "space_after" 489 else: 490 target = "no_space" 491 492 hints = [] 493 for s in strokes: 494 sc = s.count("/") + 1 495 kc = sum(len(_parse_single_stroke(part)) for part in s.split("/")) 496 ctx = self._stroke_context.get(s) 497 h = { 498 "stroke": s, 499 "stroke_count": sc, 500 "key_count": kc, 501 "keys": parse_stroke(s), 502 } 503 label = self._stroke_labels.get(s) 504 if label: 505 h["label"] = label 506 match_rank = 0 if ctx == target else 1 507 hints.append((match_rank, sc, kc, h)) 508 509 hints.sort(key=lambda t: (t[0], t[1], t[2])) 510 return [h for _, _, _, h in hints[:limit]] 511 512 def word_exists(self, word): 513 key = word.lower().strip() 514 if key in self.word_to_strokes or key in self.phrase_to_strokes: 515 return True 516 bare, _ = _strip_punctuation(key) 517 if bare and bare != key: 518 return bare in self.word_to_strokes or bare in self.phrase_to_strokes 519 return False 520 521 def display_form(self, word): 522 """Return the canonical display form (e.g. 'i' -> 'I').""" 523 return _DISPLAY_OVERRIDES.get(word.lower().strip(), word) 524 525 @property 526 def practice_words(self): 527 return list(self._practice_words) 528 529 @property 530 def single_stroke_words(self): 531 return list(self._single_stroke_words) 532 533 @property 534 def all_phrases(self): 535 result = [] 536 for key in self.phrase_to_strokes: 537 display = self._phrase_display.get(key, key) 538 if _phrase_has_proper_noun(display): 539 continue 540 result.append(display) 541 return result 542 543 def generate_phrasing_phrases(self, count=200): 544 """Generate random phrases from Python phrasing dicts (e.g. jeff-phrasing).""" 545 _V1 = ["", "A", "O", "AO"] 546 _STAR = ["", "*"] 547 _V2 = ["", "E", "U", "EU"] 548 _F = ["", "F"] 549 550 all_phrases = [] 551 for pd in self._python_dicts: 552 mod = pd["module"] 553 starters = getattr(mod, "STARTERS", None) 554 enders = getattr(mod, "ENDERS", None) 555 simple_starters = getattr(mod, "SIMPLE_STARTERS", None) 556 simple_pronouns = getattr(mod, "SIMPLE_PRONOUNS", None) 557 if not starters or not enders: 558 continue 559 560 starter_keys = list(starters.keys()) 561 ender_keys = list(enders.keys()) 562 ss_keys = list(simple_starters.keys()) if simple_starters else [] 563 sp_keys = list(simple_pronouns.keys()) if simple_pronouns else [] 564 565 seen = set() 566 attempts = 0 567 while len(all_phrases) < count and attempts < count * 30: 568 attempts += 1 569 use_simple = ss_keys and sp_keys and random.random() < 0.3 570 if use_simple: 571 stroke = (random.choice(ss_keys) 572 + random.choice(sp_keys) 573 + random.choice(_F) 574 + random.choice(ender_keys)) 575 else: 576 stroke = (random.choice(starter_keys) 577 + random.choice(_V1) 578 + random.choice(_STAR) 579 + random.choice(_V2) 580 + random.choice(_F) 581 + random.choice(ender_keys)) 582 try: 583 out = mod.lookup((stroke,)) 584 except (KeyError, Exception): 585 continue 586 out = out.strip() 587 if not out or " " not in out: 588 continue 589 wc = len(out.split()) 590 if wc < 2 or wc > 5: 591 continue 592 if out in seen: 593 continue 594 seen.add(out) 595 all_phrases.append(out) 596 597 return all_phrases 598 599 600 _PUNCTUATION_CHARS = set(".,;:!?\"'()-[]{}…/") 601 602 _PLOVER_PUNCT = { 603 # Old-style 604 "{.}": ".", "{,}": ",", "{!}": "!", "{?}": "?", 605 "{;}": ";", "{:}": ":", 606 # New-style 607 "{:stop:.}": ".", "{:stop:?}": "?", "{:stop:!}": "!", 608 "{:comma:,}": ",", "{:comma:;}": ";", "{:comma::}": ":", 609 } 610 611 612 def _strip_punctuation(word): 613 """Split trailing punctuation from a word. Returns (bare_word, [punct_chars]).""" 614 trailing = [] 615 while word and word[-1] in _PUNCTUATION_CHARS: 616 trailing.append(word[-1]) 617 word = word[:-1] 618 trailing.reverse() 619 return word, trailing 620 621 622 def _is_punctuation(text): 623 return len(text) == 1 and text in _PUNCTUATION_CHARS 624 625 626 def _phrase_has_proper_noun(phrase): 627 """True if phrase contains capitalized words other than 'I' and its contractions.""" 628 return any( 629 w[0].isupper() and w != "I" and not w.startswith("I'") 630 for w in phrase.split() if w 631 ) 632 633 634 def _is_practice_word(translation): 635 if not translation or not translation.strip(): 636 return False 637 if "{" in translation or "}" in translation: 638 return False 639 if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation): 640 return False 641 stripped = translation.strip() 642 if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper(): 643 return False 644 return True 645 646 647 def parse_stroke(stroke): 648 if "/" in stroke: 649 return [_parse_single_stroke(s) for s in stroke.split("/")] 650 return [_parse_single_stroke(stroke)] 651 652 653 def _parse_single_stroke(stroke): 654 keys = [] 655 if stroke.startswith("#"): 656 keys.append("#") 657 stroke = stroke[1:] 658 if not stroke: 659 return keys 660 661 left_keys = "STKPWHR" 662 center_keys = "AO*EU" 663 right_keys = "FRPBLGTSDZ" 664 665 if "-" in stroke: 666 left_part, right_part = stroke.split("-", 1) 667 for ch in left_part: 668 if ch in left_keys: 669 keys.append(f"{ch}-") 670 elif ch in center_keys: 671 keys.append(ch) 672 for ch in right_part: 673 if ch in right_keys: 674 keys.append(f"-{ch}") 675 elif ch in center_keys: 676 keys.append(ch) 677 else: 678 pos = 0 679 for ch in stroke: 680 for i in range(pos, len(STENO_ORDER)): 681 if STENO_ORDER[i] == ch: 682 pos = i + 1 683 if i == 0: 684 keys.append("#") 685 elif i <= 7: 686 keys.append(f"{ch}-") 687 elif i <= 12: 688 keys.append(ch) 689 else: 690 keys.append(f"-{ch}") 691 break 692 return keys