From ad5904f3aef6ab4afe3b85044e81b89a09363a51 Mon Sep 17 00:00:00 2001 From: "Christopher T. Johnson" Date: Tue, 9 Apr 2024 11:45:56 -0400 Subject: [PATCH] checkpoint --- lib/utils.py | 75 +++++++++- lib/words.py | 144 ++++++------------- plugins/merriam-webster.py | 275 ++++++++++++++++++++++++++----------- 3 files changed, 312 insertions(+), 182 deletions(-) diff --git a/lib/utils.py b/lib/utils.py index 80eced1..429c130 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -1,7 +1,9 @@ """Utility Functions.""" -from typing import NoReturn +from typing import NoReturn, Self -from PyQt6.QtCore import QCoreApplication +from PyQt6.QtCore import QCoreApplication, QDir, QStandardPaths, Qt +from PyQt6.QtGui import QColor, QFont, QFontDatabase +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkDiskCache from PyQt6.QtSql import QSqlQuery translate = QCoreApplication.translate @@ -19,3 +21,72 @@ def query_error(query: QSqlQuery) -> NoReturn: ) ) raise Exception(translate("MainWindow", "SQL Error")) + +class Resources: + _instance = None + nam = QNetworkAccessManager() + headerFont: QFont + labelFont: QFont + boldFont: QFont + textFont: QFont + italicFont: QFont + capsFont: QFont + smallCapsFont: QFont + phonicFont: QFont + + baseColor: QColor + linkColor: QColor + subduedColor: QColor + + def __new__(cls: type[Self]) -> Self: + if cls._instance: + return cls._instance + cls._instance = super(Resources, cls).__new__(cls) + return cls._instance + + def __init__(self) -> None: + super(Resources, self).__init__() + # + # Fonts + # + self.headerFont = QFontDatabase.font("OpenDyslexic", None, 10) + self.headerFont.setPixelSize(48) + self.labelFont = QFont(self.headerFont) + self.labelFont.setPixelSize(30) + self.boldFont = QFont(self.headerFont) + self.boldFont.setPixelSize(20) + self.textFont = QFont(self.boldFont) + self.italicFont = QFont(self.boldFont) + self.capsFont = QFont(self.boldFont) + self.smallCapsFont = QFont(self.boldFont) + + self.headerFont.setWeight(QFont.Weight.Bold) + self.boldFont.setBold(True) + self.italicFont.setItalic(True) + self.capsFont.setCapitalization(QFont.Capitalization.AllUppercase) + self.smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps) + + self.phonicFont = QFontDatabase.font("Gentium", None, 10) + self.phonicFont.setPixelSize(20) + + # + # colors + # + self.baseColor = QColor(Qt.GlobalColor.white) + self.linkColor = QColor("#4a7d95") + self.subduedColor = QColor(Qt.GlobalColor.gray) + + # + # Setup the Network Manager + # + cacheDir = QDir( + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.GenericCacheLocation + ) + ) + cacheDir.mkdir("Troglodite") + cacheDir = QDir(cacheDir.path() + QDir.separator() + "Troglodite") + netCache = QNetworkDiskCache() + netCache.setCacheDirectory(cacheDir.path()) + self.nam.setCache(netCache) + return diff --git a/lib/words.py b/lib/words.py index f57df96..123f6af 100644 --- a/lib/words.py +++ b/lib/words.py @@ -2,22 +2,20 @@ import importlib import pkgutil import json import re -from typing import Any, Dict, cast +from typing import Any, TypedDict, cast from PyQt6.QtCore import ( + QUrl, Qt, pyqtSlot, ) from PyQt6.QtGui import ( QColor, - QFont, - QFontDatabase, ) -from PyQt6.QtNetwork import QNetworkAccessManager from PyQt6.QtSql import QSqlQuery from PyQt6.QtWidgets import QScrollArea -from lib import query_error +from lib.utils import query_error, Resources from lib.sounds import SoundOff from lib.definition import Definition, Line, Fragment @@ -32,20 +30,22 @@ discovered_plugins = { API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}" - +class WordType(TypedDict): + word: str + source: str + definition: str + class Word: """All processing of a dictionary word.""" - _words: dict[str, Any] = {} - _resources: Dict[str, Any] = {} - _nam = QNetworkAccessManager() + _words: dict[str, WordType] = {} + def __init__(self, word: str) -> None: - Word.set_resources() # # Have we already retrieved this word? # try: - self.current = json.loads(Word._words[word]) + self.current = Word._words[word] return except KeyError: pass @@ -82,50 +82,6 @@ class Word: query_error(query) return - @classmethod - def set_resources(cls) -> None: - if len(cls._resources.keys()) > 0: - return - # - # Colors we used - # - headerFont = QFontDatabase.font("OpenDyslexic", None, 10) - headerFont.setPixelSize(48) - labelFont = QFont(headerFont) - labelFont.setPixelSize(30) - boldFont = QFont(headerFont) - boldFont.setPixelSize(20) - textFont = QFont(boldFont) - italicFont = QFont(boldFont) - capsFont = QFont(boldFont) - smallCapsFont = QFont(boldFont) - - headerFont.setWeight(QFont.Weight.Bold) - boldFont.setBold(True) - italicFont.setItalic(True) - capsFont.setCapitalization(QFont.Capitalization.AllUppercase) - smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps) - - phonicFont = QFontDatabase.font("Gentium", None, 10) - phonicFont.setPixelSize(20) - - cls._resources = { - "colors": { - "base": QColor(Qt.GlobalColor.white), - "link": QColor("#4a7d95"), - "subdued": QColor(Qt.GlobalColor.gray), - }, - "fonts": { - "header": headerFont, - "label": labelFont, - "phonic": phonicFont, - "bold": boldFont, - "italic": italicFont, - "text": textFont, - "caps": capsFont, - "smallCaps": smallCapsFont, - }, - } @pyqtSlot() def playSound(self) -> None: url = discovered_plugins[self.current['source']].getFirstSound(self.current['definition']) @@ -145,23 +101,15 @@ class Word: raise Exception(f"Unknown source: {src}") def get_def(self) -> list[Line]: - if len(self._lines) > 0: - return self._lines src = self.current['source'] try: - return discovered_plugins[src].getDef(self.current) + lines = discovered_plugins[src].getDef(self.current["definition"]) + return lines except KeyError: raise Exception(f"Unknown source: {self.current['source']}") - def mw_def(self) -> list[Line]: - lines: list[Line] = [] - # print(json.dumps(self.current,indent=2)) - for entry in self.current["definition"]: - lines += self.mw_def_entry(entry) - self._lines = lines - return lines - def mw_seq(self, seq: list[Any]) -> list[Line]: + r=Resources() lines: list[Line] = [] outer = " " inner = " " @@ -189,15 +137,15 @@ class Word: line = Line() frag = Fragment( f"{outer} {inner} ", - self._resources["fonts"]["bold"], - color=self._resources["colors"]["base"], + r.boldFont, + color=r.baseColor ) outer = " " line.addFragment(frag) frag = Fragment( text, - self._resources["fonts"]["italic"], - color=self._resources["colors"]["base"], + r.italicFont, + color=r.baseColor ) frag.setLeft(30) line.addFragment(frag) @@ -209,16 +157,16 @@ class Word: line = Line() frag = Fragment( f"{outer} {inner} ", - self._resources["fonts"]["bold"], - color=self._resources["colors"]["base"], + r.boldFont, + color=r.baseColor ) outer = " " frag.setLeft(10) line.addFragment(frag) frag = Fragment( dt[1], - self._resources["fonts"]["text"], - color=self._resources["colors"]["base"], + r.textFont, + color=r.baseColor ) frag.setLeft(30) line.addFragment(frag) @@ -228,14 +176,14 @@ class Word: line = Line() frag = Fragment( f" ", - self._resources["fonts"]["bold"], + r.boldFont ) frag.setLeft(45) line.addFragment(frag) line.addFragment( Fragment( vis["t"], - self._resources["fonts"]["text"], + r.textFont, color=QColor("#aaa"), ) ) @@ -250,8 +198,8 @@ class Word: line = Line() frag = Fragment( "\u27F6 " + seg[1], - self._resources["fonts"]["text"], - color=self._resources["colors"]["base"], + r.textFont, + color=r.baseColor ) frag.setLeft(30) line.addFragment(frag) @@ -265,19 +213,17 @@ class Word: return lines def mw_def_entry(self, entry: dict[str, Any]) -> list[Line]: + r = Resources() # # Easy reference to colors # - base = self._resources["colors"]["base"] - blue = self._resources["colors"]["blue"] - lines: list[Line] = [] line = Line() hw = re.sub(r"\*", "", entry["hwi"]["hw"]) - frag = Fragment(hw, self._resources["fonts"]["header"], color=base) + frag = Fragment(hw, r.headerFont, color=r.baseColor) line.addFragment(frag) frag = Fragment( - " " + entry["fl"], self._resources["fonts"]["label"], color=blue + " " + entry["fl"], r.labelFont, color=r.linkColor ) line.addFragment(frag) lines.append(line) @@ -288,8 +234,8 @@ class Word: for vrs in entry["vrs"]: frag = Fragment( space + vrs["va"], - self._resources["fonts"]["label"], - color=base, + r.labelFont, + color=r.baseColor ) space = " " line.addFragment(frag) @@ -298,16 +244,16 @@ class Word: line = Line() frag = Fragment( entry["hwi"]["hw"] + " ", - self._resources["fonts"]["phonic"], - color=base, + r.phonicFont, + color=r.baseColor, ) line.addFragment(frag) for prs in entry["hwi"]["prs"]: - audio = self.mw_sound_url(prs) + audio = None if audio is None: audio = "" frag = Fragment( - prs["mw"], self._resources["fonts"]["phonic"], color=blue + prs["mw"], r.phonicFont, color=r.linkColor ) frag.setAudio(audio) line.addFragment(frag) @@ -318,7 +264,7 @@ class Word: for ins in entry["ins"]: try: frag = Fragment( - ins["il"], self._resources["fonts"]["text"], color=base + ins["il"], r.textFont, color=r.baseColor ) line.addFragment(frag) space = " " @@ -326,8 +272,8 @@ class Word: pass frag = Fragment( space + ins["if"], - self._resources["fonts"]["bold"], - color=base, + r.boldFont, + color=r.baseColor ) line.addFragment(frag) space = "; " @@ -336,8 +282,8 @@ class Word: line = Line() frag = Fragment( "; ".join(entry["lbs"]), - self._resources["fonts"]["bold"], - color=base, + r.boldFont, + color=r.baseColor ) line.addFragment(frag) lines.append(line) @@ -345,13 +291,13 @@ class Word: for k, v in value.items(): if k == "sseq": # has multiple 'senses' for seq in v: - r = self.mw_seq(seq) - lines += r + rr = self.mw_seq(seq) + lines += rr elif k == "vd": line = Line() line.addFragment( Fragment( - v, self._resources["fonts"]["italic"], color=blue + v, r.italicFont, color=r.linkColor ) ) lines.append(line) @@ -361,7 +307,7 @@ class Word: # # Create the header, base word and its label # - word = self.current["hwi"]["hw"] + word = self.current['definition']["hwi"]["hw"] label = self.current["fl"] html = f'

{word} {label}

\n' @@ -383,7 +329,7 @@ class Word: if "prs" in self.current["hwi"].keys(): tmp = [] for prs in self.current["hwi"]["prs"]: - url = self.mw_sound_url(prs) + url = QUrl() how = prs["mw"] if url: tmp.append(f'\\{how}\\') diff --git a/plugins/merriam-webster.py b/plugins/merriam-webster.py index 323fab9..a71c296 100644 --- a/plugins/merriam-webster.py +++ b/plugins/merriam-webster.py @@ -1,12 +1,12 @@ +from PyQt6.QtGui import QColor from trycast import trycast import json import re -from typing import Any, Literal, NamedTuple, NotRequired, TypedDict, cast +from typing import Any, NamedTuple, NotRequired, TypedDict from PyQt6.QtCore import QEventLoop, QUrl, Qt -from PyQt6.QtGui import QColor, QFont from PyQt6.QtNetwork import QNetworkRequest -from lib.words import Word +from lib.utils import Resources from lib.definition import Line, Fragment registration = { @@ -27,10 +27,6 @@ class VerbalIllustration(TypedDict): t: str aq: str -class VerbalIllustrationTuple(NamedTuple): - type_: str # 'vis' - data: list[VerbalIllustration] - class Sound(TypedDict): audio: str ref: str @@ -38,12 +34,10 @@ class Sound(TypedDict): class Pronunciation(TypedDict): mw: str - l: str - l2: str - pun: str - sound: Sound - - + l: NotRequired[str] + l2: NotRequired[str] + pun: NotRequired[str] + sound: NotRequired[Sound] class Meta(TypedDict): id: str @@ -56,12 +50,12 @@ class Meta(TypedDict): class HeadWordInfo(TypedDict): hw: str - prs: list[Pronunciation] + prs: NotRequired[list[Pronunciation]] class HeadWord(TypedDict): hw: str - prs: list[Pronunciation] - psl: str + prs: NotRequired[list[Pronunciation]] + psl: NotRequired[str] class Variant(TypedDict): va: str @@ -109,34 +103,26 @@ class RunInWrap(TypedDict): text: str vrs: list[Variant] -class Sense: - dt: list[str] # not full - et: list[str] # not full - ins: list[Inflection] - lbs: list[str] - prs: list[Pronunciation] - sdsense: DividedSense - sgram: str - sls: list[str] - sn: str - vrs: list[Variant] - -class SenseSequence(TypedDict): - sense: Sense - sen: Sense +class Sense(TypedDict): + dt: list[list] # not full + et: NotRequired[list[str]] + ins: NotRequired[list[Inflection]] + lbs: NotRequired[list[str]] + prs: NotRequired[list[Pronunciation]] + sdsense: NotRequired[DividedSense] + sgram: NotRequired[str] + sls: NotRequired[list[str]] + sn: NotRequired[str] + vrs: NotRequired[list[Variant]] class Definition(TypedDict): - sseq: list[SenseSequence] - vd: str + sseq: list[list[list[Any]]] + vd: NotRequired[str] + +class Pair(TypedDict): + objType: str + obj: list[Sense]|Sense|str|list[VerbalIllustration]|list[Any] -class EntryX(TypedDict): - meta: Meta - hom: NotRequired[str] - hwi: HeadWordInfo - ahws: NotRequired[list[HeadWord]] - vrs: NotRequired[list[Variant]] - fl: str - def_: list[Definition] Entry = TypedDict( 'Entry', { @@ -149,13 +135,29 @@ Entry = TypedDict( 'def': list[Definition], } ) +class WordType(TypedDict): + word: str + source: str + definition: dict[str, Any] -def fetch(word:str) -> dict[str, Any]: +def make_pairs(src: list[Any]) -> list[Pair]: + result:list[Pair] = [] + iters = [iter(src)]*2 + for entry in zip(*iters): + pair = { 'objType': entry[0], + 'obj': entry[1], + } + pair = trycast(Pair, pair) + assert pair is not None + result.append(pair) + return result + +def fetch(word:str) -> WordType: request = QNetworkRequest() url = QUrl(API.format(word=word, key=key)) request.setUrl(url) request.setTransferTimeout(3000) - reply = Word._nam.get(request) + reply = Resources.nam.get(request) assert reply is not None loop = QEventLoop() reply.finished.connect(loop.quit) @@ -195,16 +197,16 @@ def getFirstSound(definition: list[Entry]) -> QUrl: return url return QUrl() -def do_prs(prs: list[Pronunciation]) -> list[Fragment]: +def do_prs(hwi: HeadWordInfo) -> list[Fragment]: + r = Resources() frags: list[Fragment] = [] - font = trycast(QFont, Word._resources['fonts']['label']) - assert font is not None - linkColor = trycast(QColor, Word._resources['colors']['link']) - assert linkColor is not None - subduedColor = trycast(QColor, Word._resources['colors']['subdued']) - assert subduedColor is not None + font = r.labelFont + linkColor = r.linkColor + subduedColor = r.subduedColor - for pr in prs: + if 'prs' not in hwi: + return [] + for pr in hwi['prs']: if 'pun' in pr: pun = pr['pun'] else: @@ -216,6 +218,7 @@ def do_prs(prs: list[Pronunciation]) -> list[Fragment]: frag = Fragment(pr['mw'], font, color=subduedColor) if 'sound' in pr: frag.setAudio(soundUrl(pr['sound'])) + frag.setColor(linkColor) frags.append(frag) if 'l2' in pr: frags.append( @@ -223,38 +226,141 @@ def do_prs(prs: list[Pronunciation]) -> list[Fragment]: ) return frags +def do_sense(sense: Sense|None) -> tuple[list[Fragment], list[Line]]: + if sense is None: + return ([],[]) + lines: list[Line] = [] + frags: list[Fragment] = [] + r = Resources() + if 'sn' in sense: + sn = sense['sn'] + else: + sn = '' + print(f'{sn}\n\n',json.dumps(sense['dt'], indent=2)) + iters = [iter(sense['dt'])]*2 + for pair in zip(*iters): + pair = trycast(tuple[str, Any], pair) + assert pair is not None + print(pair[0]) + if pair[0] == 'text': + line = Line() + line.addFragment( + Fragment(pair[1], r.textFont, color=r.baseColor) + ) + lines.append(line) + return (frags, lines) + +def do_pseq(outer: int, + inner: int, + pseq: list[list[Pair]]| None ) -> tuple[list[Fragment], list[Line]]: + assert pseq is not None + lines: list[Line] = [] + frags: list[Fragment] = [] + for entry in pseq: + pairs = make_pairs(entry) + for pair in pairs: + if pair['objType'] == 'bs': + (newFrags, newLines) = do_sense(trycast(Sense, pair['obj'])) + frags += newFrags + lines += newLines + elif pair['objType'] == 'sense': + (newFrags, newLines) = do_sense(trycast(Sense, pair['obj'])) + frags += newFrags + lines += newLines + else: + raise Exception(f"Unknown object type {pair['objType']}") + return (frags, lines) + +def do_sseq(sseq:list[list[list[Pair]]]) -> list[Line]: + lines: list[Line] = [] + r = Resources() + for outer, item_o in enumerate(sseq): + line = Line() + line.addFragment( + Fragment(str(outer+1), r.boldFont, color=r.baseColor) + ) + for inner, item_i in enumerate(item_o): + line.addFragment( + Fragment(chr(ord('a')+inner), r.boldFont, color=r.baseColor) + ) + pairs = make_pairs(item_i) + for pair in pairs: + objType = pair['objType'] + if objType == 'sense': + sense = trycast(Sense, pair['obj']) + (frags, newlines) = do_sense(sense) + for frag in frags: + line.addFragment(frag) + lines.append(line) + lines += newlines + elif objType == 'sen': + raise Exception(f"sen unimplimented") + elif objType == 'pseq': + pseq = trycast(list[list[Pair]], pair['obj']) + (frags, newlines) = do_pseq(inner, outer, trycast(list[list[Pair]], pair['obj'])) + for frag in frags: + line.addFragment(frag) + lines.append(line) + lines += newlines + elif objType == 'bs': + raise Exception(f"bs unimplimented") + else: + raise Exception(f"Unknown object[{objType}] for \n{json.dumps(pair['obj'],indent=2)}") + return lines + +def do_def(entry: Definition) -> list[Line]: + r = Resources() + lines: list[Line] = [] + assert trycast(Definition, entry) is not None + if 'vd' in entry: + line = Line() + line.addFragment( + Fragment(entry['vd'], r.italicFont, color = r.linkColor) + ) + lines.append(line) + # + # sseg is required + # + sseq = entry['sseq'] + lines += do_sseq(sseq) + return lines + def getDef(definition: list[Entry]) -> list[Line]: - lines = [] + r = Resources() + lines:list[Line] = [] # # Pull the fonts for ease of use # - headerFont = trycast(QFont, Word._resources['fonts']['header']) - assert headerFont is not None - textFont = trycast(QFont, Word._resources['fonts']['text']) - assert textFont is not None - labelFont = trycast(QFont, Word._resources['fonts']['label']) - assert labelFont is not None + headerFont = r.headerFont + textFont = r.textFont + labelFont = r.labelFont # # Pull the colors for ease of use # - baseColor = trycast(QColor, Word._resources['colors']['base']) - assert baseColor is not None - linkColor = trycast(QColor, Word._resources['colors']['link']) - assert linkColor is not None - subduedColor = trycast(QColor, Word._resources['colors']['subdued']) - assert subduedColor is not None + baseColor = r.baseColor + linkColor = r.linkColor + subduedColor = r.subduedColor # # No need to figure it out each time it is used # entries = 0 - id = definition[0]['meta']['id'] - id = ':'.split(id)[0].lower() + id = definition[0]['meta']['id'].lower().split(':')[0] + uses: dict[str,int] = {} for entry in definition: - if entry['meta']['id'].lower() == id: + testId = entry['meta']['id'].lower().split(':')[0] + if testId == id: entries += 1 + try: + uses[entry['fl']] = uses.get(entry['fl'], 0) + 1 + except KeyError: + pass + used: dict[str, int] = {} + for k in uses.keys(): + used[k] = 0 for count, entry in enumerate(definition): - if entry['meta']['id'].lower() != id: + testId = entry['meta']['id'].lower().split(':')[0] + if testId != id: continue # # Create the First line from the hwi, [ahws] and fl @@ -270,13 +376,16 @@ def getDef(definition: list[Entry]) -> list[Line]: for ahw in ahws: hw = re.sub(r'\*', '', ahw['hw']) line.addFragment(Fragment(', ' + hw, headerFont, color=baseColor)) - if 'hom' in entry: - + if entries > 1: + frag = Fragment(f" {count + 1} of {entries} ", textFont, color= subduedColor) + frag.setBackground(QColor(Qt.GlobalColor.gray)) + line.addFragment(frag) if 'fl' in entry: - frag = Fragment(f"{count} of {entries} ", textFont, color= - frag.setBackground(QColor(Qt.GlobalColor.gray)) - line.addFragment(frag) - line.addFragment(Fragment(entry['fl'], labelFont, color=baseColor)) + text = entry['fl'] + used[text] += 1 + if uses[text] > 1: + text += f' ({used[text]})' + line.addFragment(Fragment(text, labelFont, color=baseColor)) lines.append(line) # @@ -284,11 +393,15 @@ def getDef(definition: list[Entry]) -> list[Line]: # While 'prs' is optional, the headword is not. This gets us what we want. # line = Line() - hw = re.sub(r'\*', '\u00b7', hwi['hw']) - line.addFragment(Fragment(hw + ' ', textFont, color=subduedColor)) - for frag in do_prs(hwi['prs']): + if hwi['hw'].find('*') >= 0: + hw = re.sub(r'\*', '\u00b7', hwi['hw']) + line.addFragment(Fragment(hw + ' ', textFont, color=subduedColor)) + for frag in do_prs(hwi): line.addFragment(frag) - - # - # Try for - return [Line()] + if len(line.getLine()) > 0: + lines.append(line) + defines = trycast(list[Definition], entry['def']) + assert defines is not None + for define in defines: + lines += do_def(define) + return lines