from importlib.abc import InspectLoader from PyQt6.QtGui import QColor from trycast import trycast import json import re from typing import Any, Literal, NotRequired, TypedDict, cast from PyQt6.QtCore import QEventLoop, QUrl, Qt from PyQt6.QtNetwork import QNetworkRequest from lib.utils import Resources from lib.definition import Line, Fragment registration = { 'source': 'mw', 'name': 'Merriam-Webster', } API = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}?key={key}" key = "51d9df34-ee13-489e-8656-478c215e846c" class Meta(TypedDict): id: str uuid: str sort: str src: str section: str stems: list[str] offensive: bool class Sound(TypedDict): audio: str ref: str stat: str class Pronunciation(TypedDict): mw: str l: NotRequired[str] l2: NotRequired[str] pun: NotRequired[str] sound: NotRequired[Sound] class SubSource(TypedDict): source: NotRequired[str] aqdate: NotRequired[str] class AttributionOfQuote(TypedDict): auth: NotRequired[str] source: NotRequired[str] aqdate: NotRequired[str] subsource: NotRequired[SubSource] class VerbalIllustration(TypedDict): t: str aq: NotRequired[AttributionOfQuote] class HeadWordInformation(TypedDict): hw: str prs: NotRequired[list[Pronunciation]] class AlternanteHeadword(TypedDict): hw: str psl: NotRequired[str] class Variant(TypedDict): va: str vl: NotRequired[str] prs: NotRequired[list[Pronunciation]] spl: NotRequired[str] Inflection =TypedDict('Inflection', { 'if': NotRequired[str], 'ifc': NotRequired[str], 'il': NotRequired[str], 'prs': NotRequired[list[Pronunciation]], 'spl': NotRequired[str] }) class CrossReferenceTarget(TypedDict): cxl: str cxr: NotRequired[str] cxt: str cxn: NotRequired[str] class CognateCrossRef(TypedDict): cxl: str cxtis: list[CrossReferenceTarget] class Pair(TypedDict): objType: str obj: Any class DividedSense(TypedDict): sd: str dt: list[list[Pair]] et: NotRequired[list[Pair]] ins: NotRequired[list[Inflection]] lbs: NotRequired[list[str]] prs: NotRequired[list[Pronunciation]] sgram: NotRequired[str] sls: NotRequired[list[str]] vrs: NotRequired[list[Variant]] class Sense(TypedDict): dt: list[list[Pair]] et: NotRequired[list[Pair]] 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 TruncatedSense(Sense): pass class BindingSubstitutePair(TypedDict): objType: Literal['bs'] obj: Sense class SensePair(TypedDict): objType: Literal['sense'] obj: Sense class DefinitionSection(TypedDict): vd: NotRequired[str] sls: NotRequired[list[str]] sseq: Any # list[list[Pair]] Definition =TypedDict('Definition', { 'meta': Meta, 'hom': NotRequired[int], 'hwi': HeadWordInformation, 'ahws': NotRequired[list[AlternanteHeadword]], 'vrs': NotRequired[list[Variant]], 'fl': str, 'lbs': NotRequired[list[str]], 'sls': NotRequired[list[str]], 'ins': NotRequired[list[Inflection]], 'cxs': NotRequired[list[CognateCrossRef]], 'def': list[DefinitionSection], }) def make_pairs(src: list[Any]) -> list[Pair]: result:list[Pair] = [] iters = [iter(src)]*2 for entry in zip(*iters): pair0 = { 'objType': entry[0], 'obj': entry[1], } if isinstance(pair0['obj'], list): result.append(cast(Pair,pair0)) continue pair1 = trycast(Pair, pair0) if pair1 is None: print(pair0['objType'], type(pair0['obj']), json.dumps(pair0['obj'],indent=2) ) assert pair1 is not None result.append(pair1) return result Elements = [ 'dt', 'sen', 'bs', 'pseq', 'snot', 't', 'text', 'vis', 'sens', 'uns', 'sense' ] def restructure(obj: Any) -> Any: if isinstance(obj, list): if len(obj) == 0: return [] if isinstance(obj[0], str) and obj[0] in Elements: pairs = make_pairs(obj) result = [] for pair in pairs: if isinstance(pair['obj'], list): r2 = [] for item in pair['obj']: r2.append(restructure(item)) pair['obj'] = r2 elif isinstance(pair['obj'], dict): r2 = {} for k,v in pair['obj'].items(): r2[k] = restructure(v) pair['obj'] = r2 result.append(pair) return result result = [] for v in obj: result.append(restructure(v)) return result elif isinstance(obj, dict): obj2 = cast(dict, obj) result = {} for k,v in obj2.items(): result[k] = restructure(v) return result else: return obj class WordType(TypedDict): word: str source: str definition: Any def fetch(word:str) -> WordType: request = QNetworkRequest() url = QUrl(API.format(word=word, key=key)) request.setUrl(url) request.setTransferTimeout(3000) reply = Resources.nam.get(request) assert reply is not None loop = QEventLoop() reply.finished.connect(loop.quit) loop.exec() content = reply.readAll() data = json.loads(content.data().decode('utf-8')) return { 'word': word, 'source': 'mw', 'definition': data, } def soundUrl(sound:Sound, fmt='ogg') -> QUrl: """Create a URL from a PRS structure.""" base = f"https://media.merriam-webster.com/audio/prons/en/us/{fmt}" audio = sound['audio'] m = re.match(r"(bix|gg|[a-zA-Z])", audio) if m: url = base + f"/{m.group(1)}/" else: url = base + "/number/" url += audio + f".{fmt}" return QUrl(url) def getFirstSound(definition: Any) -> QUrl: # ahws, cats, dros, hwi, ins, ri, sdsense, sen, sense, uros, vrs for entry in definition: for v in entry.values(): hwi = v # trycast if hwi is None: continue if 'prs' in hwi: for pr in hwi['prs']: if 'sound' in pr: url = soundUrl(pr['sound']) if url.isValid(): return url return QUrl() def do_prs(hwi: Any) -> list[Fragment]: r = Resources() frags: list[Fragment] = [] font = r.labelFont linkColor = r.linkColor subduedColor = r.subduedColor if 'prs' not in hwi: return [] for pr in hwi['prs']: if 'pun' in pr: pun = pr['pun'] else: pun = ' ' if 'l' in pr: frags.append( Fragment(pr['l'] + pun, font, color=subduedColor) ) 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( Fragment(pun + pr['l2'], font, color=subduedColor) ) return frags def do_aq(aq: AttributionOfQuote|None) -> list[Line]: assert aq is not None return [] def do_vis(vis: list[VerbalIllustration]|None,indent=0) -> list[Line]: assert vis is not None r = Resources() lines: list[Line] = [] for vi in vis: line = Line() frag = Fragment(vi['t'], r.textFont, color=r.baseColor) if indent > 0: frag.setIndent(indent) line.addFragment(frag) lines.append(line) if 'aq' in vi: lines += do_aq(trycast(AttributionOfQuote, vi['aq'])) return [] def do_uns(uns: list[list[list[Pair]]]|None, indent:int) -> list[Line]: assert uns is not None r = Resources() lines:list[Line] = [] for note in uns: for entry in note: for pair in entry: if pair['objType'] == 'text': frag = Fragment(' \u2014'+pair['obj'], r.textFont, color=r.baseColor) frag.setIndent(indent) line = Line() line.addFragment(frag) lines.append(line) elif pair['objType'] == 'vis': lines += do_vis(trycast(list[VerbalIllustration], pair['obj']), indent) elif pair['objType'] == 'ri': raise NotImplementedError("NO ri") return lines def do_dt(dt: list[list[Pair]]|None, indent: int) -> tuple[list[Fragment], list[Line]]: assert dt is not None frags: list[Fragment] = [] lines: list[Line] = [] r = Resources() first = True for entry in dt: for pair in entry: if pair['objType'] == 'text': frag = Fragment(pair['obj'], r.textFont, color=r.baseColor) frag.setIndent(indent) if first: frags.append(frag) else: line = Line() line.addFragment(frag) lines.append(line) elif pair['objType'] == 'vis': lines += do_vis(trycast(list[VerbalIllustration], pair['obj'])) elif pair['objType'] == 'uns': newLines = do_uns(trycast(list[list[list[Pair]]], pair['obj']),indent) lines += newLines else: print(json.dumps(pair, indent=2)) raise NotImplementedError(f"Unknown or unimplimented element {pair['objType']}") first = False return (frags, lines) def do_sense(sense: Sense|None, indent:int=3) -> tuple[list[Fragment], list[Line]]: if sense is None: return ([],[]) lines: list[Line] = [] frags: list[Fragment] = [] r = Resources() dt = sense['dt'] (newFrags, newLines) = do_dt(trycast(list[list[Pair]], dt),indent) frags += newFrags lines += newLines for k,v in sense.items(): print(k) if k == 'dt' or k == 'sn': continue elif k == 'sdsense': # XXX - This needs to expand to handle et, ins, lbs, prs, sgram, sls, vrs sdsense = trycast(DividedSense, v) assert sdsense is not None frag = Fragment(sdsense['sd']+' ', r.italicFont, color=r.baseColor) frag.setIndent(indent) line = Line() line.addFragment(frag) (newFrags, newLines) = do_dt(trycast(list[list[Pair]], sdsense['dt']), indent=indent) for frag in newFrags: line.addFragment(frag) lines.append(line) lines += newLines elif k == 'sls': labels = trycast(list[str], v) assert labels is not None frag = Fragment(", ".join(labels)+' ', r.textFont, color=r.subduedColor) frags.append(frag) else: print(k,v) raise NotImplementedError(f"Unknown or unimplimented element {k}") return (frags, lines) def do_pseq(outer: int, inner: int, pseq: list[Any] ) -> tuple[list[Fragment], list[Line]]: lines: list[Line] = [] frags: list[Fragment] = [] count = 1 r = Resources() first = True for entry in pseq: for pair in entry: if pair['objType'] == 'bs': # TODO - bs has to be more than just a wrapper for Sense sense = pair['obj']['sense'] (newFrags, newLines) = do_sense(trycast(Sense, sense)) if first: frags += newFrags first = False else: line = Line() for frag in newFrags: line.addFragment(frag) lines.append(line) lines += newLines elif pair['objType'] == 'sense': if first: frag = Fragment(f"({count})", r.textFont, color=r.baseColor) frag.setIndent(3) frags.append(frag) (newFrags, newLines) = do_sense(trycast(Sense, pair['obj']), indent=4) frags += newFrags first = False else: line = Line() frag = Fragment(f"({count})", r.textFont, color=r.baseColor) frag.setIndent(3) line.addFragment(frag) (newFrags, newLines) = do_sense(trycast(Sense, pair['obj']), indent=4) for frag in newFrags: line.addFragment(frag) lines.append(line) lines += newLines count += 1 else: raise NotImplementedError(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() frag =Fragment(str(outer+1), r.boldFont, color=r.baseColor) frag.setIndent(1) line.addFragment(frag) for inner, item_i in enumerate(item_o): frag =Fragment(chr(ord('a')+inner), r.boldFont, color=r.baseColor) frag.setIndent(2) line.addFragment(frag) for pair in item_i: 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) line = Line() lines += newlines elif objType == 'sen': raise NotImplementedError(f"sen unimplimented") elif objType == 'pseq': (frags, newlines) = do_pseq(inner, outer, pair['obj']) for frag in frags: line.addFragment(frag) lines.append(line) line = Line() lines += newlines elif objType == 'bs': raise NotImplementedError(f"bs unimplimented") else: raise NotImplementedError(f"Unknown object[{objType}] for \n{json.dumps(pair['obj'],indent=2)}") return lines def do_def(entry: DefinitionSection) -> list[Line]: assert entry is not None r = Resources() lines: list[Line] = [] 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(defines: Any) -> list[Line]: workList = restructure(defines) workList = trycast(list[Definition], workList) assert workList is not None r = Resources() lines:list[Line] = [] # # No need to figure it out each time it is used # entries = 0 id = workList[0]['meta']['id'].lower().split(':')[0] uses: dict[str,int] = {} for entry in workList: testId = entry['meta']['id'].lower().split(':')[0] if testId == id: entries += 1 # # If there is a Functional Lable, then we are going # to capture the count of each FL # try: uses[entry['fl']] = uses.get(entry['fl'], 0) + 1 except KeyError: pass del(entry) used: dict[str, int] = {} for k in uses.keys(): used[k] = 0 for count, work in enumerate(workList): testId = work['meta']['id'].lower().split(':')[0] # # Skip entries which are not part of the primary definition # if testId != id: continue # # Create the First line from the hwi, [ahws] and fl # line = Line() hwi = trycast(HeadWordInformation, work['hwi']) assert hwi is not None hw = re.sub(r'\*', '', hwi['hw']) line.addFragment(Fragment(hw, r.headerFont, color=r.baseColor)) if 'ahws' in work: ahws = trycast(list[AlternanteHeadword], work['ahws']) assert ahws is not None for ahw in ahws: hw = re.sub(r'\*', '', ahw['hw']) line.addFragment(Fragment(', ' + hw, r.headerFont, color=r.baseColor)) if entries > 1: frag = Fragment(f" {count + 1} of {entries} ", r.textFont, color= r.subduedColor) # XXX - Use a resource color!!! frag.setBackground(QColor(Qt.GlobalColor.gray)) line.addFragment(frag) if 'fl' in work: text = work['fl'] used[text] += 1 if uses[text] > 1: text += f' ({used[text]})' line.addFragment(Fragment(text, r.labelFont, color=r.baseColor)) lines.append(line) # # Next is the pronunciation. # While 'prs' is optional, the headword is not. This gets us what we want. # line = Line() if hwi['hw'].find('*') >= 0: hw = re.sub(r'\*', '\u00b7', hwi['hw']) line.addFragment(Fragment(hw + ' ', r.textFont, color=r.subduedColor)) for frag in do_prs(hwi): line.addFragment(frag) if len(line.getLine()) > 0: lines.append(line) defines = trycast(list[DefinitionSection], work['def']) assert defines is not None for define in defines: try: lines += do_def(define) except NotImplementedError as e: print(e) return lines