From 9aa08db42b15ca3b545da06c781fbb180c7b5b45 Mon Sep 17 00:00:00 2001 From: hassanraza464 Date: Tue, 18 Aug 2026 03:06:37 +0500 Subject: [PATCH 1/3] Add selectable dictionary sources and fix unreliable lookups The Free Dictionary API intermittently returns 5xx errors, and returns its ordinary "No Definitions Found" 404 for words it has no entry for. That 404 is indistinguishable from a word not existing, so common words like "is" and "be" were reported as not being words at all. Reliability: * Retry 500/502/503/504 and connection errors up to three times with a short backoff. 404 still fails immediately, so unknown words stay responsive. * Speak a message for an exhausted 5xx rather than the raw urllib error. * Verify a 404 against Wiktionary before reporting a word as unknown, so "unable to find definition" is only spoken when both sources agree. Requires an "en" entry, so pages that exist for other languages are not mistaken for English words. Word selection: * Strip punctuation from the ends of a word before looking it up, so words adjacent to punctuation resolve. Internal apostrophes and hyphens are kept, and the unstripped form is tried as a fallback for words like 'tis. Text that is only punctuation no longer triggers a lookup. Type detection still runs on the original text, leaving IP, URL and ISBN handling unchanged. Sources: * Add a provider abstraction returning normalised entries, so the formatting and audio code is independent of where a definition came from. * Add Wiktionary as a selectable source, including HTML stripping, filtering to English entries and flattening its nested senses. Examples are allocated across each part of speech so a heading does not take the sentence belonging to the sense beneath it. * Add a settings panel to choose the dictionary, plus an optional fallback to the other source when the chosen one lacks a definition or pronunciation. Defaults leave existing behaviour unchanged. * Fall back to Wikimedia Commons pronunciations, using Wikimedia's mp3 transcodes so the existing player handles them, when a recording cannot be played. Report a message instead of only beeping when none can be. * Request Wikimedia responses gzipped, which reduces a large entry from 69KB to around 9KB. Also sync manifest.ini with the version already set in buildVars.py. --- .../text_information/__init__.py | 91 ++++- .../text_information/definitions.py | 231 ++++++++++-- .../text_information/providers.py | 357 ++++++++++++++++++ .../text_information/settings.py | 65 ++++ addon/manifest.ini | 2 +- buildVars.py | 2 + 6 files changed, 708 insertions(+), 40 deletions(-) create mode 100644 addon/globalPlugins/text_information/providers.py create mode 100644 addon/globalPlugins/text_information/settings.py diff --git a/addon/globalPlugins/text_information/__init__.py b/addon/globalPlugins/text_information/__init__.py index ec08ee6..5dc3021 100644 --- a/addon/globalPlugins/text_information/__init__.py +++ b/addon/globalPlugins/text_information/__init__.py @@ -15,6 +15,8 @@ addonHandler.initTranslation() import treeInterceptorHandler +import gui +import gui.settingsDialogs import scriptHandler import tones import threading @@ -26,10 +28,24 @@ sys.path.append(os.path.abspath(os.path.dirname(__file__))) import isbn import definitions +import providers +import settings from bs4 import BeautifulSoup sys.path.remove(sys.path[-1]) +settings.initialize() + + +def resolve_audio_fallback(word): + """Pronunciation urls to try when the source's own recording cannot be played.""" + if not settings.should_use_fallback(): + return [] + return [url for label, url in providers.get_commons_audio(word)] + + +definitions.audio_fallback_resolver = resolve_audio_fallback + # taken and partially modified from http://code.activestate.com/recipes/578019 def bytes2human(n): @@ -261,13 +277,32 @@ def get_book_info(isbn): ui.message(last) -def get_word_info(word): +def lookup_word(text): + """Looks `text` up, trying each form of the word against each configured source. + + Returns the normalised entry and the word that produced it, or (None, None). + Only the very last attempt reports its failure, so the user hears a single outcome. + """ + sources = [settings.get_definition_provider()] + if settings.should_use_fallback(): + fallback = providers.FALLBACK_PROVIDER + if fallback not in sources: + sources.append(fallback) + attempts = [(source, word) for source in sources for word in word_lookup_candidates(text)] + for index, (source, word) in enumerate(attempts): + entry = source.fetch(word, reportErrors=index == len(attempts) - 1) + if entry is not None: + return entry, word + return None, None + + +def get_word_info(text): global last, lastAudio, lastWord, lastIsWordDefinition - entry = definitions.fetch_word_entry(word) + entry, word = lookup_word(text) if entry is None: return fields = [] - phonetic = definitions.get_entry_phonetic(entry) + phonetic = entry.get("phonetic") if phonetic: # translators: label for the phonetic pronunciation of a word fields.append(_("pronunciation") + ": " + phonetic) @@ -277,7 +312,12 @@ def get_word_info(word): ui.message(_("unable to find definition for word")) return tones.beep(300, 200) - lastAudio = definitions.get_entry_audio(entry) + audio = entry.get("audio", []) + # The chosen dictionary may not carry pronunciation audio at all, in which case looking + # elsewhere for it is part of what the fallback option asks for + if not audio and settings.should_use_fallback(): + audio = providers.get_commons_audio(word) + lastAudio = audio lastWord = word lastIsWordDefinition = True last = ". ".join(fields) @@ -356,15 +396,50 @@ def word_count(string): return len(string.split()) +# Strips punctuation from the ends of a word only, so internal apostrophes and hyphens +# (let's, so-so) survive. \W is unicode aware, which also catches smart quotes and dashes. +SURROUNDING_PUNCTUATION = re.compile(r"^[\W_]+|[\W_]+$") + + +def strip_surrounding_punctuation(text): + return SURROUNDING_PUNCTUATION.sub("", text) + + +def word_lookup_candidates(text): + """Forms of `text` to look up, in order of preference. + + The stripped form comes first, since selected text usually carries adjacent punctuation. + The original is kept as a fallback for words where the punctuation is part of the + word itself, eg. 'tis or e.g. + """ + stripped = strip_surrounding_punctuation(text) + # Nothing but punctuation or symbols, so there is no word here to define + if not stripped: + return [] + candidates = [stripped] + if text != stripped: + candidates.append(text) + return candidates + + class GlobalPlugin(globalPluginHandler.GlobalPlugin): scriptCategory = _("Text Information") def __init__(self, *args, **kwargs): super(GlobalPlugin, self).__init__(*args, **kwargs) + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append( + settings.TextInformationSettingsPanel + ) def terminate(self, *args, **kwargs): definitions.shutdown_audio_worker() + try: + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove( + settings.TextInformationSettingsPanel + ) + except ValueError: + log.debug("Settings panel was already removed") super(GlobalPlugin, self).terminate(*args, **kwargs) def script_getClipInfo(self, gesture): @@ -416,7 +491,9 @@ def script_getLast(self, gesture): if lastIsWordDefinition: # translators: title of the dialog showing a word's definition and pronunciation audio buttons title = _("Definition for {0}").format(lastWord) - definitions.show_audio_browseable_message("\n".join(last.split(". ")), title, lastAudio) + definitions.show_audio_browseable_message( + "\n".join(last.split(". ")), title, lastAudio, lastWord + ) else: ui.browseableMessage("\n".join(last.split(". ")), "text information") else: @@ -459,10 +536,10 @@ def get_info(self, text): # translators: message spoken after selecting text that contains a URL final += _("URL, retrieving page information...") threading.Thread(target=get_url_info, args=(text,)).start() - elif w == 1: + elif w == 1 and word_lookup_candidates(text): # translators: message spoken after selecting text that contains a word (will be defined) final += _("retrieving word information...") - t = threading.Thread(target=get_word_info, args=(text,)).start() + threading.Thread(target=get_word_info, args=(text,)).start() if not final: final += "text contains " + str(w) + (" words" if w != 1 else " word") ui.message(final) diff --git a/addon/globalPlugins/text_information/definitions.py b/addon/globalPlugins/text_information/definitions.py index 818994f..92a5974 100644 --- a/addon/globalPlugins/text_information/definitions.py +++ b/addon/globalPlugins/text_information/definitions.py @@ -8,11 +8,13 @@ from urllib.error import HTTPError, URLError from logHandler import log +import gzip import json import ui import tones import threading import queue +import time import re import os import ctypes @@ -30,43 +32,156 @@ ) -def fetch_word_entry(word): +# dictionaryapi.dev intermittently returns 5xx (usually 502) for requests its backend never +# handled. Retrying an idempotent GET is safe and hides the hiccup, which is what a browser +# effectively does for the API's own demo page. +RETRYABLE_STATUS_CODES = frozenset((500, 502, 503, 504)) +RETRY_ATTEMPTS = 3 +RETRY_BACKOFF = 0.4 + + +# Wiktionary entries are large (69KB of markup for "be"), and Wikimedia serves them gzipped +# at about a seventh of that. urllib does not negotiate compression by itself. +GZIP_HEADERS = {"Accept-Encoding": "gzip"} + + +def wikimedia_headers(): + headers = {"User-Agent": WIKIMEDIA_UA} + headers.update(GZIP_HEADERS) + return headers + + +def read_response(response): + """Reads a response body, decompressing it when the server gzipped it.""" + data = response.read() + if response.headers.get("Content-Encoding", "").lower() != "gzip": + return data + try: + return gzip.decompress(data) + except Exception as e: + # Better to try parsing what arrived than to fail outright + log.error(f"Unable to decompress gzipped response: {e}") + return data + + +def report_failure(message, reportErrors=True, beep=True): + """Speaks a failure to the user, unless the caller asked to stay quiet. + + Callers trying several words or several sources use this to keep all but the final + attempt silent, so the user hears one outcome rather than one per attempt. + """ + if not reportErrors: + return + if beep: + tones.beep(150, 200) + ui.message(message) + + +def urlopen_with_retry(req, timeout=10, attempts=RETRY_ATTEMPTS): + """Like urlopen, retrying transient server and connection failures. + + Raises the final exception once all attempts are exhausted. + """ + for attempt in range(1, attempts + 1): + try: + return urlopen(req, timeout=timeout) + except HTTPError as h: + if h.code not in RETRYABLE_STATUS_CODES or attempt == attempts: + raise + log.debug(f"Attempt {attempt} of {attempts} failed with HTTP {h.code}, retrying") + except URLError as u: + if attempt == attempts: + raise + log.debug(f"Attempt {attempt} of {attempts} failed with {u}, retrying") + # Called from a background thread, so sleeping here never blocks speech or the UI + time.sleep(RETRY_BACKOFF * attempt) + + +# The dictionary API returns its ordinary "No Definitions Found" 404 for words it simply +# has no entry for, which is indistinguishable from a word that does not exist. Wiktionary +# is consulted to tell those two apart, so we never claim a real word isn't a word. +# Keyed by language, so an "en" key means an actual English entry, not merely a page that +# happens to exist for some other language (eg. "cane", Italian for dog). +WIKTIONARY_DEFINITION_URL = "https://en.wiktionary.org/api/rest_v1/page/definition/" +# Wikimedia's user agent policy asks for an identifiable agent with a contact address +WIKIMEDIA_UA = "textInformation-NVDA-addon (https://github.com/cartertemm/text_information)" + + +def wiktionary_has_english_entry(word, timeout=5): + """Whether Wiktionary lists an English entry for a word. + + Returns True, False, or None when the check itself could not be completed. + """ + try: + req = Request(WIKTIONARY_DEFINITION_URL + quote(word), headers=wikimedia_headers()) + response = read_response(urlopen(req, timeout=timeout)) + except HTTPError as h: + if h.code == 404: + return False + log.debug(f"HTTPError verifying {word!r} against Wiktionary: {h}") + return None + except Exception as e: + log.debug(f"Unable to verify {word!r} against Wiktionary: {e}") + return None + try: + return "en" in json.loads(response) + except (ValueError, TypeError) as e: + log.debug(f"Unexpected Wiktionary response for {word!r}: {e}") + return None + + +def fetch_word_entry(word, reportErrors=True): + """Looks a word up in the dictionary API, returning its first entry or None. + + With reportErrors False, failures are logged but not spoken, so a caller can try + another form of the word without the user hearing an error for each attempt. + """ # translators: error error = _("error") + + def report(message): + report_failure(message, reportErrors) + try: req = Request( "https://api.dictionaryapi.dev/api/v2/entries/en/" + quote(word), headers={"User-Agent": CHROME_UA}, ) - response = urlopen(req, timeout=10).read() + response = urlopen_with_retry(req, timeout=10).read() except HTTPError as h: if h.code == 404: log.debug(f"No dictionary entry for word: {word}") - tones.beep(150, 200) - # translators: The message spoken when a word was not found. - ui.message(_("unable to find definition for word")) + # Verifying costs a request, so only bother when the outcome will be spoken + if reportErrors and wiktionary_has_english_entry(word): + log.error(f"Dictionary API has no entry for {word!r}, which Wiktionary lists as a word") + # translators: message spoken when the word exists but the dictionary has no definition for it + report(_("definition unavailable")) + else: + # translators: The message spoken when a word was not found. + report(_("unable to find definition for word")) + elif h.code in RETRYABLE_STATUS_CODES: + log.error(f"Dictionary service unavailable for {word!r} after {RETRY_ATTEMPTS} attempts: {h}") + # translators: message spoken when the dictionary service is temporarily unreachable + report(_("dictionary service unavailable, please try again")) else: log.error(f"HTTPError fetching definition for {word!r}: {h}") - tones.beep(150, 200) - ui.message(error + ": " + str(h)) + report(error + ": " + str(h)) return None except URLError as u: log.error(f"URLError fetching definition for {word!r}: {u}") - tones.beep(150, 200) # translators: message spoken when we can't connect (error with connection) error_connection = _("error making connection") if str(u).find("Errno 11001") > -1 or str(u).find("Errno 10060") > -1: - ui.message(error_connection) + report(error_connection) elif str(u).find("Errno 10061") > -1: # translators: message spoken when the connection is refused by our target - ui.message(_("error, connection refused by target")) + report(_("error, connection refused by target")) else: - ui.message(error + ": " + str(u)) + report(error + ": " + str(u)) return None except Exception as e: log.error(f"Unexpected error fetching definition for {word!r}: {e}", exc_info=True) - tones.beep(150, 200) - ui.message(error + ": " + str(e)) + report(error + ": " + str(e)) return None return json.loads(response)[0] @@ -119,17 +234,30 @@ def get_entry_audio(entry): return audio +# Sources differ over whether definitions come with a trailing full stop. dictionaryapi.dev +# omits it, Wiktionary includes it, so appending our own separator unconditionally produces +# a doubled ".." for the latter. +SENTENCE_ENDING = tuple(".!?:;,") + + +def append_field(text, field): + """Appends a field to a definition, without doubling up sentence punctuation.""" + if text.endswith(SENTENCE_ENDING): + return text + " " + field + return text + ". " + field + + def format_word_definition(definition, index): text = str(index) + ". " + definition["definition"] if definition.get("example"): # translators: label for an example sentence using a word - text += ". " + _("example") + ": " + definition["example"] + text = append_field(text, _("example") + ": " + definition["example"]) if definition.get("synonyms"): # translators: label for a list of synonyms - text += ". " + _("synonyms") + ": " + ", ".join(definition["synonyms"]) + text = append_field(text, _("synonyms") + ": " + ", ".join(definition["synonyms"])) if definition.get("antonyms"): # translators: label for a list of antonyms - text += ". " + _("antonyms") + ": " + ", ".join(definition["antonyms"]) + text = append_field(text, _("antonyms") + ": " + ", ".join(definition["antonyms"])) return text @@ -152,6 +280,9 @@ def format_word_meaning(meaning): _audioQueue = queue.Queue() # sentinel telling the worker to stop playback and clean up, without shutting the thread down _AUDIO_STOP = object() +# Set by the plugin to a callable taking a word and returning replacement audio urls. Injected +# rather than imported, because the module that knows about other sources imports this one. +audio_fallback_resolver = None def _remove_audio_temp_files(): @@ -162,23 +293,58 @@ def _remove_audio_temp_files(): log.debug(f"Unable to remove pronunciation temp file {path!r}: {e}") +def _download_audio(audioUrl): + # Wikimedia asks for an identifiable agent, the dictionary API wants a browser one + userAgent = WIKIMEDIA_UA if "wikimedia.org" in audioUrl else CHROME_UA + req = Request(audioUrl, headers={"User-Agent": userAgent}) + return urlopen_with_retry(req, timeout=10).read() + + +def _fetch_audio(audioUrl, word): + """Downloads a recording, trying other sources for the word if that fails. + + Returns (data, url) or (None, None). A recording can be missing even when the source + advertised it, as happens whenever the dictionary API's media host is unwell. + """ + try: + return _download_audio(audioUrl), audioUrl + except Exception as e: + log.error(f"Error downloading pronunciation audio from {audioUrl!r}: {e}") + if not word or not audio_fallback_resolver: + return None, None + try: + alternatives = [url for url in audio_fallback_resolver(word) if url != audioUrl] + except Exception as e: + log.error(f"Error looking for alternative pronunciations of {word!r}: {e}", exc_info=True) + return None, None + for url in alternatives: + try: + data = _download_audio(url) + except Exception as e: + log.debug(f"Alternative pronunciation {url!r} also failed: {e}") + continue + log.debug(f"Playing alternative pronunciation of {word!r} from {url!r}") + return data, url + return None, None + + def _audio_worker(): winmm = ctypes.windll.winmm counter = 0 while True: - audioUrl = _audioQueue.get() - if audioUrl is None or audioUrl is _AUDIO_STOP: + item = _audioQueue.get() + if item is None or item is _AUDIO_STOP: winmm.mciSendStringW("close " + AUDIO_MCI_ALIAS, None, 0, None) _remove_audio_temp_files() - if audioUrl is None: + if item is None: return continue - try: - req = Request(audioUrl, headers={"User-Agent": CHROME_UA}) - data = urlopen(req, timeout=10).read() - except Exception as e: - log.error(f"Error downloading pronunciation audio from {audioUrl!r}: {e}", exc_info=True) + audioUrl, word = item + data, audioUrl = _fetch_audio(audioUrl, word) + if data is None: tones.beep(150, 200) + # translators: message spoken when a word's pronunciation cannot be played + ui.message(_("pronunciation unavailable")) continue winmm.mciSendStringW("close " + AUDIO_MCI_ALIAS, None, 0, None) _remove_audio_temp_files() @@ -200,8 +366,9 @@ def _audio_worker(): _audioThread.start() -def play_audio_url(audioUrl): - _audioQueue.put(audioUrl) +def play_audio_url(audioUrl, word=""): + """Queues a recording for playback. `word` allows falling back to another source.""" + _audioQueue.put((audioUrl, word)) def stop_audio_playback(): @@ -212,14 +379,14 @@ def shutdown_audio_worker(): _audioQueue.put(None) -def make_audio_button_handler(audioUrl): +def make_audio_button_handler(audioUrl, word=""): def handler(event): - play_audio_url(audioUrl) + play_audio_url(audioUrl, word) return handler class WordAudioDialog(wx.Dialog): - def __init__(self, parent, title, message, audioEntries): + def __init__(self, parent, title, message, audioEntries, word=""): super().__init__(parent, title=title, style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) sizer = wx.BoxSizer(wx.VERTICAL) textCtrl = wx.TextCtrl(self, value=message, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH) @@ -227,7 +394,7 @@ def __init__(self, parent, title, message, audioEntries): for label, audioUrl in audioEntries: # translators: label for a button that plays a pronunciation audio clip, {0} is the locale/variant button = wx.Button(self, label=_("Play {0}").format(label)) - button.Bind(wx.EVT_BUTTON, make_audio_button_handler(audioUrl)) + button.Bind(wx.EVT_BUTTON, make_audio_button_handler(audioUrl, word)) sizer.Add(button, flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, border=5) closeButton = wx.Button(self, wx.ID_CLOSE) closeButton.Bind(wx.EVT_BUTTON, lambda evt: self.Close()) @@ -244,7 +411,7 @@ def onClose(self, event): self.Destroy() -def show_audio_browseable_message(message, title, audioEntries): - dialog = WordAudioDialog(gui.mainFrame, title, message, audioEntries) +def show_audio_browseable_message(message, title, audioEntries, word=""): + dialog = WordAudioDialog(gui.mainFrame, title, message, audioEntries, word) gui.mainFrame.prePopup() dialog.Show() diff --git a/addon/globalPlugins/text_information/providers.py b/addon/globalPlugins/text_information/providers.py new file mode 100644 index 0000000..a3c6a8a --- /dev/null +++ b/addon/globalPlugins/text_information/providers.py @@ -0,0 +1,357 @@ +# providers.py +# Selectable dictionary definition sources for the text information add-on. +# Copyright (C) 2018 Carter Temm + +"""Definition sources. + +Every provider turns a word into a normalised entry, so the formatting and audio code +never has to care where the data came from: + + { + "phonetic": str or None, + "meanings": [ + { + "partOfSpeech": str, + "definitions": [ + {"definition": str, "example": str, "synonyms": [str], "antonyms": [str]} + ], + "synonyms": [str], + "antonyms": [str], + } + ], + "audio": [(label, url)], + } + +That is deliberately the shape dictionaryapi.dev already returns, so `format_word_meaning` +and `get_entry_audio` keep working unchanged. +""" + +from urllib.request import Request +from urllib.parse import quote + +from logHandler import log +import hashlib +import json +import html +import re +import sys +import os + +import definitions + +sys.path.append(os.path.abspath(os.path.dirname(__file__))) +from bs4 import BeautifulSoup + +sys.path.remove(sys.path[-1]) + + +class Provider(object): + """A dictionary definition source. + + `name` is the internal identifier stored in the configuration, `displayName` is the + untranslated label for the settings panel. Display names are translated where they + are shown rather than here, so importing this module never depends on gettext having + been installed yet. + """ + + name = None + displayName = None + # Whether entries from this source can carry pronunciation audio + providesAudio = False + + def fetch(self, word, reportErrors=True): + """Returns a normalised entry for `word`, or None when there isn't one. + + With reportErrors False, failures are logged but never spoken, so a caller can + try another source or another form of the word silently. + """ + raise NotImplementedError + + +class DictionaryApiProvider(Provider): + """The Free Dictionary API (https://dictionaryapi.dev/). + + Terse, well structured, and the only source we have that carries pronunciation audio. + """ + + name = "dictionaryapi" + displayName = "Free Dictionary API (dictionaryapi.dev)" + providesAudio = True + + def fetch(self, word, reportErrors=True): + entry = definitions.fetch_word_entry(word, reportErrors=reportErrors) + if entry is None: + return None + return { + "phonetic": definitions.get_entry_phonetic(entry), + "meanings": entry.get("meanings", []), + "audio": definitions.get_entry_audio(entry), + } + + +# Wiktionary entries can run to dozens of senses, which is unusable when spoken aloud. +WIKTIONARY_MAX_DEFINITIONS = 10 +# Tags carrying editorial annotations rather than the definition itself. Nested lists hold +# sub-senses, which the API also returns as definitions in their own right, so leaving them +# in would repeat every sub-sense inside its parent. +WIKTIONARY_NOISE_SELECTORS = ("style", "sup", "ol", "ul") +# Separating tags with a space keeps words apart, but leaves gaps before punctuation +SPACE_BEFORE_PUNCTUATION = re.compile(r"\s+([,.;:!?%\)\]\}»”’])") +SPACE_AFTER_OPENING = re.compile(r"([\(\[\{«“‘])\s+") + + +def strip_html(markup): + """Reduces a fragment of Wiktionary markup to plain readable text.""" + if not markup: + return "" + soup = BeautifulSoup(markup, "html.parser") + for selector in WIKTIONARY_NOISE_SELECTORS: + for tag in soup.find_all(selector): + tag.decompose() + text = html.unescape(soup.get_text(" ")) + # Collapse the whitespace left behind by removed markup, then tidy the gaps that + # separating every tag with a space introduces around punctuation + text = re.sub(r"\s+", " ", text) + text = SPACE_BEFORE_PUNCTUATION.sub(r"\1", text) + text = SPACE_AFTER_OPENING.sub(r"\1", text) + return text.strip() + + +class WiktionaryProvider(Provider): + """English Wiktionary, via the Wikimedia REST API. + + Far more reliable and much broader than dictionaryapi.dev, but definitions arrive as + HTML, are grouped by language, and carry no pronunciation audio. + """ + + name = "wiktionary" + displayName = "Wiktionary" + providesAudio = False + + def fetch(self, word, reportErrors=True): + try: + req = Request( + definitions.WIKTIONARY_DEFINITION_URL + quote(word), + headers=definitions.wikimedia_headers(), + ) + response = definitions.read_response(definitions.urlopen_with_retry(req, timeout=10)) + except definitions.HTTPError as h: + if h.code == 404: + log.debug(f"No Wiktionary page for word: {word}") + # translators: The message spoken when a word was not found. + definitions.report_failure(_("unable to find definition for word"), reportErrors) + elif h.code in definitions.RETRYABLE_STATUS_CODES: + log.error(f"Wiktionary unavailable for {word!r} after {definitions.RETRY_ATTEMPTS} attempts: {h}") + # translators: message spoken when the dictionary service is temporarily unreachable + definitions.report_failure(_("dictionary service unavailable, please try again"), reportErrors) + else: + log.error(f"HTTPError fetching Wiktionary definition for {word!r}: {h}") + # translators: error + definitions.report_failure(_("error") + ": " + str(h), reportErrors) + return None + except Exception as e: + log.error(f"Error fetching Wiktionary definition for {word!r}: {e}", exc_info=True) + # translators: error + definitions.report_failure(_("error") + ": " + str(e), reportErrors) + return None + try: + sections = json.loads(response).get("en", []) + except (ValueError, TypeError) as e: + log.error(f"Unexpected Wiktionary response for {word!r}: {e}") + # translators: error + definitions.report_failure(_("error") + ": " + str(e), reportErrors) + return None + # The English group can still contain Translingual entries (symbols, letters), which + # are noise for a definition lookup. Keep them only when there is nothing else. + english = [s for s in sections if s.get("language") == "English"] + meanings = self.build_meanings(english or sections) + if not meanings: + log.debug(f"Wiktionary has a page for {word!r} but no usable English definitions") + # translators: The message spoken when a word was not found. + definitions.report_failure(_("unable to find definition for word"), reportErrors) + return None + return {"phonetic": None, "meanings": meanings, "audio": []} + + def build_meanings(self, sections): + meanings = [] + remaining = WIKTIONARY_MAX_DEFINITIONS + for section in sections: + if remaining <= 0: + break + usable = [] + for sense in section.get("definitions", []): + if len(usable) >= remaining: + break + text = strip_html(sense.get("definition")) + # Some senses are only a grammatical label, and read as nothing once stripped + if not text: + continue + usable.append((text, self.get_examples(sense))) + senses = [ + { + "definition": text, + "example": example, + "synonyms": [], + "antonyms": [], + } + for text, example in self.assign_examples(usable) + ] + remaining -= len(senses) + if senses: + meanings.append( + { + "partOfSpeech": section.get("partOfSpeech", ""), + "definitions": senses, + "synonyms": [], + "antonyms": [], + } + ) + return meanings + + def get_examples(self, sense): + """Every example a sense offers, in the order Wiktionary lists them.""" + examples = [] + for parsed in sense.get("parsedExamples", []): + example = strip_html(parsed.get("example")) + if example and example not in examples: + examples.append(example) + for raw in sense.get("examples", []): + example = strip_html(raw) + if example and example not in examples: + examples.append(example) + return examples + + def assign_examples(self, senses): + """Gives each sense one example, never repeating a sentence within the section. + + A heading sense pools every example belonging to the senses beneath it, so taking + its first would steal the sentence from the sense that actually owns it. Anything + that is some other sense's only example is therefore reserved for that sense, and + a heading draws from whatever is left over. + """ + reserved = set() + for text, examples in senses: + if len(examples) == 1 and not self.is_heading(text): + reserved.add(examples[0]) + assigned = [] + used = set() + for text, examples in senses: + chosen = "" + # Reserved sentences are still fair game for the sense that owns them + ownReserved = len(examples) == 1 and not self.is_heading(text) + for example in examples: + if example in used or (example in reserved and not ownReserved): + continue + chosen = example + break + # Repeating a sentence reads better than a sense with nothing to illustrate it, + # so fall back to one already used rather than leaving this sense bare + if not chosen and examples: + chosen = examples[0] + if chosen: + used.add(chosen) + assigned.append((text, chosen)) + return assigned + + def is_heading(self, text): + """Whether a sense only labels the senses nested beneath it, eg. "As a noun:".""" + return text.endswith(":") + + +# Wikimedia hosts pronunciations as Ogg Vorbis or wave, neither of which the MCI player can +# handle (nothing shipped with NVDA decodes Ogg at all). It also generates an mp3 transcode +# of every audio file though, so those are used instead and the existing player is enough. +WIKTIONARY_MEDIA_LIST_URL = "https://en.wiktionary.org/api/rest_v1/page/media-list/" +COMMONS_TRANSCODED_URL = "https://upload.wikimedia.org/wikipedia/commons/transcoded/" +COMMONS_AUDIO_EXTENSIONS = (".ogg", ".wav", ".oga", ".flac") +# "En-us-cut.wav", "En-uk-cut.wav" and friends name their language and accent up front +COMMONS_LOCALE_RE = re.compile(r"^en-([a-z]{2})-", re.IGNORECASE) +# "LL-Q1860_(eng)-Vealhurl-cut.wav" names the contributor who recorded it, not an accent +COMMONS_CONTRIBUTOR_RE = re.compile(r"^LL-[^-]+-(.+)-[^-]+$") +# A Wiktionary page carries every language's recordings, so a lookup for an English word +# will happily offer the French pronunciation of it. Q1860 is English on Wikidata, which is +# how the Lingua Libre recordings identify themselves. +COMMONS_ENGLISH_RE = re.compile(r"^(en-[a-z]{2}-|LL-Q1860[_ ]?\(eng\)-)", re.IGNORECASE) + + +def commons_filename(title): + """The real Commons filename behind a media-list title. + + Commons stores spaces as underscores and capitalises the first letter, and the path is + derived from the exact name, so a title like "File:en-us-hollow.ogg" has to become + "En-us-hollow.ogg" or the URL is a 404. + """ + filename = title.split(":", 1)[-1].replace(" ", "_") + return filename[:1].upper() + filename[1:] + + +def commons_transcoded_url(filename): + """URL of Wikimedia's mp3 transcode of a Commons audio file. + + Commons derives the path from the md5 of the filename, so this needs no API call. + """ + digest = hashlib.md5(filename.encode("utf-8")).hexdigest() + quoted = quote(filename) + return "{0}{1}/{2}/{3}/{3}.mp3".format(COMMONS_TRANSCODED_URL, digest[0], digest[:2], quoted) + + +def commons_audio_label(filename, usedLabels): + locale = COMMONS_LOCALE_RE.match(filename) + if locale: + label = locale.group(1).upper() + else: + contributor = COMMONS_CONTRIBUTOR_RE.match(filename) + # Contributor names are wiki usernames, so they arrive with underscores for spaces + # and often a parenthesised account name that adds nothing when spoken + # translators: fallback label for a pronunciation audio button when neither an accent + # nor a contributor could be determined for the recording + label = _("pronunciation") + if contributor: + name = re.sub(r"\s*\([^)]*\)", "", contributor.group(1).replace("_", " ")).strip() + label = name or label + if label in usedLabels: + usedLabels[label] += 1 + label = "{0} ({1})".format(label, usedLabels[label]) + else: + usedLabels[label] = 1 + return label + + +def get_commons_audio(word): + """Pronunciation recordings for a word that NVDA can actually play. + + Returns (label, url) pairs, empty when there are none or the lookup fails. Audio is a + nicety, so failures here are logged and never spoken. + """ + try: + req = Request(WIKTIONARY_MEDIA_LIST_URL + quote(word), headers=definitions.wikimedia_headers()) + response = definitions.read_response(definitions.urlopen_with_retry(req, timeout=10)) + items = json.loads(response).get("items", []) + except Exception as e: + log.debug(f"Unable to list Commons audio for {word!r}: {e}") + return [] + usedLabels = {} + audio = [] + for item in items: + if item.get("type") != "audio": + continue + filename = commons_filename(item.get("title", "")) + if not filename.lower().endswith(COMMONS_AUDIO_EXTENSIONS): + continue + if not COMMONS_ENGLISH_RE.match(filename): + continue + audio.append((commons_audio_label(filename, usedLabels), commons_transcoded_url(filename))) + return audio + + +PROVIDERS = (DictionaryApiProvider(), WiktionaryProvider()) +DEFAULT_PROVIDER = PROVIDERS[0] +FALLBACK_PROVIDER = PROVIDERS[1] + + +def get_provider(name): + for provider in PROVIDERS: + if provider.name == name: + return provider + log.debug(f"Unknown definition source {name!r}, using {DEFAULT_PROVIDER.name}") + return DEFAULT_PROVIDER diff --git a/addon/globalPlugins/text_information/settings.py b/addon/globalPlugins/text_information/settings.py new file mode 100644 index 0000000..77b6744 --- /dev/null +++ b/addon/globalPlugins/text_information/settings.py @@ -0,0 +1,65 @@ +# settings.py +# Configuration and settings panel for the text information add-on. +# Copyright (C) 2018 Carter Temm + +import wx + +import config +from gui import guiHelper +from gui.settingsDialogs import SettingsPanel + +import providers + +CONFIG_SECTION = "textInformation" +CONFIG_SPEC = { + "definitionSource": 'option({0}, default="{1}")'.format( + ", ".join('"{0}"'.format(p.name) for p in providers.PROVIDERS), + providers.DEFAULT_PROVIDER.name, + ), + "useFallback": "boolean(default=False)", +} + + +def initialize(): + config.conf.spec[CONFIG_SECTION] = CONFIG_SPEC + + +def get_definition_provider(): + return providers.get_provider(config.conf[CONFIG_SECTION]["definitionSource"]) + + +def should_use_fallback(): + return config.conf[CONFIG_SECTION]["useFallback"] + + +class TextInformationSettingsPanel(SettingsPanel): + # translators: title of the add-on's settings category in NVDA's settings dialog + title = _("Text Information") + + def makeSettings(self, settingsSizer): + helper = guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + # Provider names are proper nouns, so they are deliberately not translated + self.sourceChoice = helper.addLabeledControl( + # translators: label for the dictionary used to look up word definitions + _("Dictionary for word &definitions:"), + wx.Choice, + choices=[p.displayName for p in providers.PROVIDERS], + ) + names = [p.name for p in providers.PROVIDERS] + current = config.conf[CONFIG_SECTION]["definitionSource"] + self.sourceChoice.SetSelection(names.index(current) if current in names else 0) + self.fallbackCheckBox = helper.addItem( + # translators: label for the option to look elsewhere when the chosen dictionary + # has no definition, or no pronunciation audio, for a word + wx.CheckBox( + self, + label=_("If the selected dictionary lacks a definition or &pronunciation, try another source"), + ) + ) + self.fallbackCheckBox.SetValue(config.conf[CONFIG_SECTION]["useFallback"]) + + def onSave(self): + selection = self.sourceChoice.GetSelection() + if selection != wx.NOT_FOUND: + config.conf[CONFIG_SECTION]["definitionSource"] = providers.PROVIDERS[selection].name + config.conf[CONFIG_SECTION]["useFallback"] = self.fallbackCheckBox.GetValue() diff --git a/addon/manifest.ini b/addon/manifest.ini index 802254a..1d1bc3c 100644 --- a/addon/manifest.ini +++ b/addon/manifest.ini @@ -3,7 +3,7 @@ summary = "text information" description = """Provides information like dictionary definitions for selected text. Press NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the clipboard, and NVDA + control + ; to speak the last retrieved information. You can press this twice quickly to have it displayed in a browseable dialog. Note: for non-english keyboard layouts these gestures might need to be redefined in the input gestures dialog.""" author = "Carter Temm " url = https://github.com/cartertemm/text_information -version = 1.5 +version = 1.6 docFileName = readme.html minimumNVDAVersion = 2019.3 lastTestedNVDAVersion = 2026.1 diff --git a/buildVars.py b/buildVars.py index 3a4e3c7..ebd14ce 100644 --- a/buildVars.py +++ b/buildVars.py @@ -42,6 +42,8 @@ pythonSources = [ os.path.join("addon", "globalPlugins", "text_information", "__init__.py"), os.path.join("addon", "globalPlugins", "text_information", "definitions.py"), + os.path.join("addon", "globalPlugins", "text_information", "providers.py"), + os.path.join("addon", "globalPlugins", "text_information", "settings.py"), ] # Files that contain strings for translation. Usually your python sources From b472fa1c4aa2224a3d89310cbaafdf7044ceb89a Mon Sep 17 00:00:00 2001 From: hassanraza464 Date: Tue, 18 Aug 2026 03:28:53 +0500 Subject: [PATCH 2/3] Document the new settings and translate the new strings Covers the dictionary selection, the fallback option, punctuation handling and the "definition unavailable" message in the readme, and fills in the five new strings for the Spanish, French and Russian catalogues. The settings panel title reuses the existing "Text Information" message, so it needed no new translation. Two entries, "Definition for {0}" and "Play {0}", were already marked fuzzy before this change and are left as they are. --- addon/locale/es/LC_MESSAGES/nvda.po | 196 ++++++++++++++------------ addon/locale/fr/LC_MESSAGES/nvda.po | 205 ++++++++++++++++------------ addon/locale/ru/LC_MESSAGES/nvda.po | 201 +++++++++++++++------------ readme.md | 13 +- 4 files changed, 355 insertions(+), 260 deletions(-) diff --git a/addon/locale/es/LC_MESSAGES/nvda.po b/addon/locale/es/LC_MESSAGES/nvda.po index d279ced..ae0cdd9 100644 --- a/addon/locale/es/LC_MESSAGES/nvda.po +++ b/addon/locale/es/LC_MESSAGES/nvda.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: textInformation 1.5\n" "Report-Msgid-Bugs-To: nvda-translations@groups.io\n" -"POT-Creation-Date: 2026-07-12 10:37-0700\n" +"POT-Creation-Date: 2026-08-18 03:22+0500\n" "PO-Revision-Date: 2026-07-12 17:39+0000\n" "Last-Translator: Carter Temm \n" "Language-Team: es \n" @@ -20,256 +20,284 @@ msgstr "" "Generated-By: Babel 2.18.0\n" #. translators: error -#: addon/globalPlugins/text_information/__init__.py:122 -#: addon/globalPlugins/text_information/__init__.py:286 -#: addon/globalPlugins/text_information/definitions.py:35 +#: addon/globalPlugins/text_information/__init__.py:140 +#: addon/globalPlugins/text_information/__init__.py:331 +#: addon/globalPlugins/text_information/definitions.py:140 +#: addon/globalPlugins/text_information/providers.py:150 +#: addon/globalPlugins/text_information/providers.py:155 +#: addon/globalPlugins/text_information/providers.py:162 msgid "error" msgstr "error" #. translators: message spoken when we can't connect (error with connection) -#: addon/globalPlugins/text_information/__init__.py:128 -#: addon/globalPlugins/text_information/__init__.py:304 -#: addon/globalPlugins/text_information/definitions.py:57 +#: addon/globalPlugins/text_information/__init__.py:146 +#: addon/globalPlugins/text_information/__init__.py:349 +#: addon/globalPlugins/text_information/definitions.py:173 msgid "error making connection" msgstr "error de conexión" #. translators: message spoken when the connection is refused by our target -#: addon/globalPlugins/text_information/__init__.py:140 -#: addon/globalPlugins/text_information/__init__.py:306 -#: addon/globalPlugins/text_information/definitions.py:62 +#: addon/globalPlugins/text_information/__init__.py:158 +#: addon/globalPlugins/text_information/__init__.py:351 +#: addon/globalPlugins/text_information/definitions.py:178 msgid "error, connection refused by target" msgstr "error, conexión rechazada por el destino" -#. translators: message, followed by the error, spoken when the response -#. returned contains an error -#: addon/globalPlugins/text_information/__init__.py:172 +#. translators: message, followed by the error, spoken when the response returned contains an error +#: addon/globalPlugins/text_information/__init__.py:190 msgid "error obtaining IP info " msgstr "error al obtener información de IP " -#: addon/globalPlugins/text_information/__init__.py:177 +#: addon/globalPlugins/text_information/__init__.py:196 msgid "country" msgstr "país" -#: addon/globalPlugins/text_information/__init__.py:181 +#: addon/globalPlugins/text_information/__init__.py:200 msgid "region" msgstr "región" -#: addon/globalPlugins/text_information/__init__.py:185 +#: addon/globalPlugins/text_information/__init__.py:204 msgid "city" msgstr "ciudad" -#: addon/globalPlugins/text_information/__init__.py:189 +#: addon/globalPlugins/text_information/__init__.py:208 msgid "zipcode" msgstr "código postal" -#: addon/globalPlugins/text_information/__init__.py:193 +#: addon/globalPlugins/text_information/__init__.py:212 msgid "longitude" msgstr "longitud" -#: addon/globalPlugins/text_information/__init__.py:197 +#: addon/globalPlugins/text_information/__init__.py:216 msgid "latitude" msgstr "latitud" -#: addon/globalPlugins/text_information/__init__.py:201 +#: addon/globalPlugins/text_information/__init__.py:220 msgid "timezone" msgstr "zona horaria" -#: addon/globalPlugins/text_information/__init__.py:205 +#: addon/globalPlugins/text_information/__init__.py:224 msgid "ISP" msgstr "ISP" -#: addon/globalPlugins/text_information/__init__.py:210 +#: addon/globalPlugins/text_information/__init__.py:229 msgid "mobile connection" msgstr "conexión móvil" -#: addon/globalPlugins/text_information/__init__.py:212 +#: addon/globalPlugins/text_information/__init__.py:231 msgid "Proxy, VPN or Tor exit address" msgstr "dirección de salida de Proxy, VPN o Tor" -#. translators: message spoken when we're unable to find a book with the given -#. ISBN -#: addon/globalPlugins/text_information/__init__.py:227 +#. translators: message spoken when we're unable to find a book with the given ISBN +#: addon/globalPlugins/text_information/__init__.py:246 msgid "no book with that ISBN found" msgstr "no se encontró ningún libro con ese ISBN" -#: addon/globalPlugins/text_information/__init__.py:233 -#: addon/globalPlugins/text_information/__init__.py:323 +#: addon/globalPlugins/text_information/__init__.py:253 +#: addon/globalPlugins/text_information/__init__.py:368 msgid "title" msgstr "título" -#: addon/globalPlugins/text_information/__init__.py:237 +#: addon/globalPlugins/text_information/__init__.py:257 msgid "author (s)" msgstr "autor(es)" -#: addon/globalPlugins/text_information/__init__.py:241 +#: addon/globalPlugins/text_information/__init__.py:261 msgid "language" msgstr "idioma" #. translators: label for the page description field in URL output -#: addon/globalPlugins/text_information/__init__.py:245 -#: addon/globalPlugins/text_information/__init__.py:329 +#: addon/globalPlugins/text_information/__init__.py:265 +#: addon/globalPlugins/text_information/__init__.py:374 msgid "description" msgstr "descripción" -#: addon/globalPlugins/text_information/__init__.py:249 +#: addon/globalPlugins/text_information/__init__.py:269 msgid "maturity rating" msgstr "clasificación por edad" -#: addon/globalPlugins/text_information/__init__.py:253 +#: addon/globalPlugins/text_information/__init__.py:273 msgid "published date" msgstr "fecha de publicación" #. translators: label for the phonetic pronunciation of a word #. translators: fallback label for a pronunciation audio button when no locale #. or phonetic transcription could be determined for it -#: addon/globalPlugins/text_information/__init__.py:269 -#: addon/globalPlugins/text_information/definitions.py:98 +#. Contributor names are wiki usernames, so they arrive with underscores for spaces +#. and often a parenthesised account name that adds nothing when spoken +#. translators: fallback label for a pronunciation audio button when neither an accent +#. nor a contributor could be determined for the recording +#: addon/globalPlugins/text_information/__init__.py:308 +#: addon/globalPlugins/text_information/definitions.py:213 +#: addon/globalPlugins/text_information/providers.py:308 msgid "pronunciation" msgstr "pronunciación" #. translators: The message spoken when a word was not found. -#: addon/globalPlugins/text_information/__init__.py:273 -#: addon/globalPlugins/text_information/definitions.py:47 +#: addon/globalPlugins/text_information/__init__.py:312 +#: addon/globalPlugins/text_information/definitions.py:161 +#: addon/globalPlugins/text_information/providers.py:142 +#: addon/globalPlugins/text_information/providers.py:171 msgid "unable to find definition for word" msgstr "no se pudo encontrar la definición de la palabra" #. translators: message spoken when the page title cannot be retrieved -#: addon/globalPlugins/text_information/__init__.py:320 +#: addon/globalPlugins/text_information/__init__.py:365 msgid "unable to retrieve page title" msgstr "no se pudo obtener el título de la página" #. translators: label for the content length field in URL output -#: addon/globalPlugins/text_information/__init__.py:334 +#: addon/globalPlugins/text_information/__init__.py:379 msgid "content length" msgstr "longitud del contenido" #. translators: label spoken when a URL redirects to a different domain -#: addon/globalPlugins/text_information/__init__.py:340 +#: addon/globalPlugins/text_information/__init__.py:385 msgid "redirects to" msgstr "redirige a" -#: addon/globalPlugins/text_information/__init__.py:355 +#. translators: title of the add-on's settings category in NVDA's settings dialog +#: addon/globalPlugins/text_information/__init__.py:427 +#: addon/globalPlugins/text_information/settings.py:37 msgid "Text Information" msgstr "Información de texto" #. translators: message spoken when the clipboard is empty -#: addon/globalPlugins/text_information/__init__.py:372 +#: addon/globalPlugins/text_information/__init__.py:453 msgid "There is no text on the clipboard" msgstr "No hay texto en el portapapeles" -#: addon/globalPlugins/text_information/__init__.py:377 +#: addon/globalPlugins/text_information/__init__.py:458 msgid "speaks information of text on the clipboard" msgstr "dice la información del texto en el portapapeles" #. translators: message spoken when no text is selected or focused -#: addon/globalPlugins/text_information/__init__.py:397 +#: addon/globalPlugins/text_information/__init__.py:478 msgid "select or focus something first" msgstr "selecciona o enfoca algo primero" -#: addon/globalPlugins/text_information/__init__.py:401 +#: addon/globalPlugins/text_information/__init__.py:482 msgid "speaks information for currently selected text" msgstr "dice la información del texto actualmente seleccionado" -#. translators: title of the dialog showing a word's definition and -#. pronunciation audio buttons -#: addon/globalPlugins/text_information/__init__.py:412 -#, python-brace-format, fuzzy +#. translators: title of the dialog showing a word's definition and pronunciation audio buttons +#: addon/globalPlugins/text_information/__init__.py:493 +#, fuzzy, python-brace-format msgid "Definition for {0}" msgstr "Definición de {0}" -#. translators: message spoken when the user tries getting previous -#. information -#. but there is none -#: addon/globalPlugins/text_information/__init__.py:418 +#. translators: message spoken when the user tries getting previous information but there is none +#: addon/globalPlugins/text_information/__init__.py:501 msgid "you haven't yet gotten info" msgstr "aún no has obtenido información" -#: addon/globalPlugins/text_information/__init__.py:421 +#: addon/globalPlugins/text_information/__init__.py:504 msgid "reports the last retrieved information in a browseable dialog" msgstr "informa la última información obtenida en un diálogo navegable" #. translators: credit card -#: addon/globalPlugins/text_information/__init__.py:430 +#: addon/globalPlugins/text_information/__init__.py:513 msgid "credit card" msgstr "tarjeta de crédito" -#. translators: message spoken after selecting text that contains an IP v4 -#. address -#: addon/globalPlugins/text_information/__init__.py:434 +#. translators: message spoken after selecting text that contains an IP v4 address +#: addon/globalPlugins/text_information/__init__.py:517 msgid " IPv4 address, retrieving information..." msgstr " dirección IPv4, obteniendo información..." -#. translators: message spoken after selecting text that contains an IP v6 -#. address -#: addon/globalPlugins/text_information/__init__.py:438 +#. translators: message spoken after selecting text that contains an IP v6 address +#: addon/globalPlugins/text_information/__init__.py:521 msgid "IPv6 address, retrieving information..." msgstr "dirección IPv6, obteniendo información..." #. translators: phone number -#: addon/globalPlugins/text_information/__init__.py:443 +#: addon/globalPlugins/text_information/__init__.py:526 msgid "phone number" msgstr "número de teléfono" #. translators: email -#: addon/globalPlugins/text_information/__init__.py:446 +#: addon/globalPlugins/text_information/__init__.py:529 msgid "email" msgstr "correo electrónico" #. translators: message spoken after text is selected that contains an ISBN -#: addon/globalPlugins/text_information/__init__.py:449 +#: addon/globalPlugins/text_information/__init__.py:532 msgid "isbn: retrieving information..." msgstr "isbn: obteniendo información..." #. translators: message spoken after selecting text that contains a URL -#: addon/globalPlugins/text_information/__init__.py:454 +#: addon/globalPlugins/text_information/__init__.py:537 msgid "URL, retrieving page information..." msgstr "URL, obteniendo información de la página..." -#. translators: message spoken after selecting text that contains a word (will -#. be defined) -#: addon/globalPlugins/text_information/__init__.py:458 +#. translators: message spoken after selecting text that contains a word (will be defined) +#: addon/globalPlugins/text_information/__init__.py:541 msgid "retrieving word information..." msgstr "obteniendo información de la palabra..." +#. translators: message spoken when the word exists but the dictionary has no definition for it +#: addon/globalPlugins/text_information/definitions.py:158 +msgid "definition unavailable" +msgstr "definición no disponible" + +#. translators: message spoken when the dictionary service is temporarily unreachable +#: addon/globalPlugins/text_information/definitions.py:165 +#: addon/globalPlugins/text_information/providers.py:146 +msgid "dictionary service unavailable, please try again" +msgstr "servicio de diccionario no disponible, inténtelo de nuevo" + #. translators: label for an example sentence using a word -#: addon/globalPlugins/text_information/definitions.py:126 +#: addon/globalPlugins/text_information/definitions.py:254 msgid "example" msgstr "ejemplo" #. translators: label for a list of synonyms -#: addon/globalPlugins/text_information/definitions.py:129 -#: addon/globalPlugins/text_information/definitions.py:142 +#: addon/globalPlugins/text_information/definitions.py:257 +#: addon/globalPlugins/text_information/definitions.py:270 msgid "synonyms" msgstr "sinónimos" #. translators: label for a list of antonyms -#: addon/globalPlugins/text_information/definitions.py:132 -#: addon/globalPlugins/text_information/definitions.py:144 +#: addon/globalPlugins/text_information/definitions.py:260 +#: addon/globalPlugins/text_information/definitions.py:272 msgid "antonyms" msgstr "antónimos" -#. translators: label for a button that plays a pronunciation audio clip, {0} -#. is the locale/variant -#: addon/globalPlugins/text_information/definitions.py:229 -#, python-brace-format, fuzzy +#. translators: message spoken when a word's pronunciation cannot be played +#: addon/globalPlugins/text_information/definitions.py:347 +msgid "pronunciation unavailable" +msgstr "pronunciación no disponible" + +#. translators: label for a button that plays a pronunciation audio clip, {0} is the locale/variant +#: addon/globalPlugins/text_information/definitions.py:396 +#, fuzzy, python-brace-format msgid "Play {0}" msgstr "Reproducir {0}" +#. translators: label for the dictionary used to look up word definitions +#: addon/globalPlugins/text_information/settings.py:44 +msgid "Dictionary for word &definitions:" +msgstr "Diccionario para las &definiciones de palabras:" + +#: addon/globalPlugins/text_information/settings.py:56 +msgid "" +"If the selected dictionary lacks a definition or &pronunciation, try another " +"source" +msgstr "Si el diccionario seleccionado carece de definición o &pronunciación, probar otra fuente" + #. Add-on summary, usually the user visible name of the addon. -#. Translators: Summary for this add-on to be shown on installation and add-on -#. information. +#. Translators: Summary for this add-on to be shown on installation and add-on information. #: buildVars.py:17 msgid "text information" msgstr "información de texto" #. Add-on description -#. Translators: Long description to be shown for this add-on on add-on -#. information from add-ons manager +#. Translators: Long description to be shown for this add-on on add-on information from add-ons manager #: buildVars.py:20 msgid "" "Provides information like dictionary definitions for selected text. Press " -"NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the" -" clipboard, and NVDA + control + ; to speak the last retrieved information. " +"NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the " +"clipboard, and NVDA + control + ; to speak the last retrieved information. " "You can press this twice quickly to have it displayed in a browseable " "dialog. Note: for non-english keyboard layouts these gestures might need to " "be redefined in the input gestures dialog." diff --git a/addon/locale/fr/LC_MESSAGES/nvda.po b/addon/locale/fr/LC_MESSAGES/nvda.po index 3d2fd58..b2ce430 100644 --- a/addon/locale/fr/LC_MESSAGES/nvda.po +++ b/addon/locale/fr/LC_MESSAGES/nvda.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: textInformation 1.5\n" "Report-Msgid-Bugs-To: nvda-translations@groups.io\n" -"POT-Creation-Date: 2026-07-12 10:37-0700\n" +"POT-Creation-Date: 2026-08-18 03:22+0500\n" "PO-Revision-Date: 2026-07-12 17:42+0000\n" "Last-Translator: Carter Temm \n" "Language-Team: fr \n" @@ -20,267 +20,294 @@ msgstr "" "Generated-By: Babel 2.18.0\n" #. translators: error -#: addon/globalPlugins/text_information/__init__.py:122 -#: addon/globalPlugins/text_information/__init__.py:286 -#: addon/globalPlugins/text_information/definitions.py:35 +#: addon/globalPlugins/text_information/__init__.py:140 +#: addon/globalPlugins/text_information/__init__.py:331 +#: addon/globalPlugins/text_information/definitions.py:140 +#: addon/globalPlugins/text_information/providers.py:150 +#: addon/globalPlugins/text_information/providers.py:155 +#: addon/globalPlugins/text_information/providers.py:162 msgid "error" msgstr "erreur" #. translators: message spoken when we can't connect (error with connection) -#: addon/globalPlugins/text_information/__init__.py:128 -#: addon/globalPlugins/text_information/__init__.py:304 -#: addon/globalPlugins/text_information/definitions.py:57 +#: addon/globalPlugins/text_information/__init__.py:146 +#: addon/globalPlugins/text_information/__init__.py:349 +#: addon/globalPlugins/text_information/definitions.py:173 msgid "error making connection" msgstr "erreur de connexion" #. translators: message spoken when the connection is refused by our target -#: addon/globalPlugins/text_information/__init__.py:140 -#: addon/globalPlugins/text_information/__init__.py:306 -#: addon/globalPlugins/text_information/definitions.py:62 +#: addon/globalPlugins/text_information/__init__.py:158 +#: addon/globalPlugins/text_information/__init__.py:351 +#: addon/globalPlugins/text_information/definitions.py:178 msgid "error, connection refused by target" msgstr "erreur, connexion refusée par la cible" -#. translators: message, followed by the error, spoken when the response -#. returned contains an error -#: addon/globalPlugins/text_information/__init__.py:172 +#. translators: message, followed by the error, spoken when the response returned contains an error +#: addon/globalPlugins/text_information/__init__.py:190 msgid "error obtaining IP info " msgstr "erreur lors de l'obtention des informations IP " -#: addon/globalPlugins/text_information/__init__.py:177 +#: addon/globalPlugins/text_information/__init__.py:196 msgid "country" msgstr "pays" -#: addon/globalPlugins/text_information/__init__.py:181 +#: addon/globalPlugins/text_information/__init__.py:200 msgid "region" msgstr "région" -#: addon/globalPlugins/text_information/__init__.py:185 +#: addon/globalPlugins/text_information/__init__.py:204 msgid "city" msgstr "ville" -#: addon/globalPlugins/text_information/__init__.py:189 +#: addon/globalPlugins/text_information/__init__.py:208 msgid "zipcode" msgstr "code postal" -#: addon/globalPlugins/text_information/__init__.py:193 +#: addon/globalPlugins/text_information/__init__.py:212 msgid "longitude" msgstr "longitude" -#: addon/globalPlugins/text_information/__init__.py:197 +#: addon/globalPlugins/text_information/__init__.py:216 msgid "latitude" msgstr "latitude" -#: addon/globalPlugins/text_information/__init__.py:201 +#: addon/globalPlugins/text_information/__init__.py:220 msgid "timezone" msgstr "fuseau horaire" -#: addon/globalPlugins/text_information/__init__.py:205 +#: addon/globalPlugins/text_information/__init__.py:224 msgid "ISP" msgstr "FAI" -#: addon/globalPlugins/text_information/__init__.py:210 +#: addon/globalPlugins/text_information/__init__.py:229 msgid "mobile connection" msgstr "connexion mobile" -#: addon/globalPlugins/text_information/__init__.py:212 +#: addon/globalPlugins/text_information/__init__.py:231 msgid "Proxy, VPN or Tor exit address" msgstr "adresse de sortie Proxy, VPN ou Tor" -#. translators: message spoken when we're unable to find a book with the given -#. ISBN -#: addon/globalPlugins/text_information/__init__.py:227 +#. translators: message spoken when we're unable to find a book with the given ISBN +#: addon/globalPlugins/text_information/__init__.py:246 msgid "no book with that ISBN found" msgstr "aucun livre trouvé avec cet ISBN" -#: addon/globalPlugins/text_information/__init__.py:233 -#: addon/globalPlugins/text_information/__init__.py:323 +#: addon/globalPlugins/text_information/__init__.py:253 +#: addon/globalPlugins/text_information/__init__.py:368 msgid "title" msgstr "titre" -#: addon/globalPlugins/text_information/__init__.py:237 +#: addon/globalPlugins/text_information/__init__.py:257 msgid "author (s)" msgstr "auteur(s)" -#: addon/globalPlugins/text_information/__init__.py:241 +#: addon/globalPlugins/text_information/__init__.py:261 msgid "language" msgstr "langue" #. translators: label for the page description field in URL output -#: addon/globalPlugins/text_information/__init__.py:245 -#: addon/globalPlugins/text_information/__init__.py:329 +#: addon/globalPlugins/text_information/__init__.py:265 +#: addon/globalPlugins/text_information/__init__.py:374 msgid "description" msgstr "description" -#: addon/globalPlugins/text_information/__init__.py:249 +#: addon/globalPlugins/text_information/__init__.py:269 msgid "maturity rating" msgstr "classification par âge" -#: addon/globalPlugins/text_information/__init__.py:253 +#: addon/globalPlugins/text_information/__init__.py:273 msgid "published date" msgstr "date de publication" #. translators: label for the phonetic pronunciation of a word #. translators: fallback label for a pronunciation audio button when no locale #. or phonetic transcription could be determined for it -#: addon/globalPlugins/text_information/__init__.py:269 -#: addon/globalPlugins/text_information/definitions.py:98 +#. Contributor names are wiki usernames, so they arrive with underscores for spaces +#. and often a parenthesised account name that adds nothing when spoken +#. translators: fallback label for a pronunciation audio button when neither an accent +#. nor a contributor could be determined for the recording +#: addon/globalPlugins/text_information/__init__.py:308 +#: addon/globalPlugins/text_information/definitions.py:213 +#: addon/globalPlugins/text_information/providers.py:308 msgid "pronunciation" msgstr "prononciation" #. translators: The message spoken when a word was not found. -#: addon/globalPlugins/text_information/__init__.py:273 -#: addon/globalPlugins/text_information/definitions.py:47 +#: addon/globalPlugins/text_information/__init__.py:312 +#: addon/globalPlugins/text_information/definitions.py:161 +#: addon/globalPlugins/text_information/providers.py:142 +#: addon/globalPlugins/text_information/providers.py:171 msgid "unable to find definition for word" msgstr "impossible de trouver la définition du mot" #. translators: message spoken when the page title cannot be retrieved -#: addon/globalPlugins/text_information/__init__.py:320 +#: addon/globalPlugins/text_information/__init__.py:365 msgid "unable to retrieve page title" msgstr "impossible de récupérer le titre de la page" #. translators: label for the content length field in URL output -#: addon/globalPlugins/text_information/__init__.py:334 +#: addon/globalPlugins/text_information/__init__.py:379 msgid "content length" msgstr "longueur du contenu" #. translators: label spoken when a URL redirects to a different domain -#: addon/globalPlugins/text_information/__init__.py:340 +#: addon/globalPlugins/text_information/__init__.py:385 msgid "redirects to" msgstr "redirige vers" -#: addon/globalPlugins/text_information/__init__.py:355 +#. translators: title of the add-on's settings category in NVDA's settings dialog +#: addon/globalPlugins/text_information/__init__.py:427 +#: addon/globalPlugins/text_information/settings.py:37 msgid "Text Information" msgstr "Informations textuelles" #. translators: message spoken when the clipboard is empty -#: addon/globalPlugins/text_information/__init__.py:372 +#: addon/globalPlugins/text_information/__init__.py:453 msgid "There is no text on the clipboard" msgstr "Il n'y a pas de texte dans le presse-papiers" -#: addon/globalPlugins/text_information/__init__.py:377 +#: addon/globalPlugins/text_information/__init__.py:458 msgid "speaks information of text on the clipboard" msgstr "énonce les informations du texte dans le presse-papiers" #. translators: message spoken when no text is selected or focused -#: addon/globalPlugins/text_information/__init__.py:397 +#: addon/globalPlugins/text_information/__init__.py:478 msgid "select or focus something first" msgstr "sélectionnez ou focalisez quelque chose d'abord" -#: addon/globalPlugins/text_information/__init__.py:401 +#: addon/globalPlugins/text_information/__init__.py:482 msgid "speaks information for currently selected text" msgstr "énonce les informations du texte actuellement sélectionné" -#. translators: title of the dialog showing a word's definition and -#. pronunciation audio buttons -#: addon/globalPlugins/text_information/__init__.py:412 -#, python-brace-format, fuzzy +#. translators: title of the dialog showing a word's definition and pronunciation audio buttons +#: addon/globalPlugins/text_information/__init__.py:493 +#, fuzzy, python-brace-format msgid "Definition for {0}" msgstr "Définition de {0}" -#. translators: message spoken when the user tries getting previous -#. information -#. but there is none -#: addon/globalPlugins/text_information/__init__.py:418 +#. translators: message spoken when the user tries getting previous information but there is none +#: addon/globalPlugins/text_information/__init__.py:501 msgid "you haven't yet gotten info" msgstr "vous n'avez pas encore obtenu d'informations" -#: addon/globalPlugins/text_information/__init__.py:421 +#: addon/globalPlugins/text_information/__init__.py:504 msgid "reports the last retrieved information in a browseable dialog" msgstr "" "signale la dernière information récupérée dans une boîte de dialogue " "navigable" #. translators: credit card -#: addon/globalPlugins/text_information/__init__.py:430 +#: addon/globalPlugins/text_information/__init__.py:513 msgid "credit card" msgstr "carte de crédit" -#. translators: message spoken after selecting text that contains an IP v4 -#. address -#: addon/globalPlugins/text_information/__init__.py:434 +#. translators: message spoken after selecting text that contains an IP v4 address +#: addon/globalPlugins/text_information/__init__.py:517 msgid " IPv4 address, retrieving information..." msgstr " adresse IPv4, récupération des informations..." -#. translators: message spoken after selecting text that contains an IP v6 -#. address -#: addon/globalPlugins/text_information/__init__.py:438 +#. translators: message spoken after selecting text that contains an IP v6 address +#: addon/globalPlugins/text_information/__init__.py:521 msgid "IPv6 address, retrieving information..." msgstr "adresse IPv6, récupération des informations..." #. translators: phone number -#: addon/globalPlugins/text_information/__init__.py:443 +#: addon/globalPlugins/text_information/__init__.py:526 msgid "phone number" msgstr "numéro de téléphone" #. translators: email -#: addon/globalPlugins/text_information/__init__.py:446 +#: addon/globalPlugins/text_information/__init__.py:529 msgid "email" msgstr "e-mail" #. translators: message spoken after text is selected that contains an ISBN -#: addon/globalPlugins/text_information/__init__.py:449 +#: addon/globalPlugins/text_information/__init__.py:532 msgid "isbn: retrieving information..." msgstr "isbn : récupération des informations..." #. translators: message spoken after selecting text that contains a URL -#: addon/globalPlugins/text_information/__init__.py:454 +#: addon/globalPlugins/text_information/__init__.py:537 msgid "URL, retrieving page information..." msgstr "URL, récupération des informations de la page..." -#. translators: message spoken after selecting text that contains a word (will -#. be defined) -#: addon/globalPlugins/text_information/__init__.py:458 +#. translators: message spoken after selecting text that contains a word (will be defined) +#: addon/globalPlugins/text_information/__init__.py:541 msgid "retrieving word information..." msgstr "récupération des informations du mot..." +#. translators: message spoken when the word exists but the dictionary has no definition for it +#: addon/globalPlugins/text_information/definitions.py:158 +msgid "definition unavailable" +msgstr "définition non disponible" + +#. translators: message spoken when the dictionary service is temporarily unreachable +#: addon/globalPlugins/text_information/definitions.py:165 +#: addon/globalPlugins/text_information/providers.py:146 +msgid "dictionary service unavailable, please try again" +msgstr "service de dictionnaire indisponible, veuillez réessayer" + #. translators: label for an example sentence using a word -#: addon/globalPlugins/text_information/definitions.py:126 +#: addon/globalPlugins/text_information/definitions.py:254 msgid "example" msgstr "exemple" #. translators: label for a list of synonyms -#: addon/globalPlugins/text_information/definitions.py:129 -#: addon/globalPlugins/text_information/definitions.py:142 +#: addon/globalPlugins/text_information/definitions.py:257 +#: addon/globalPlugins/text_information/definitions.py:270 msgid "synonyms" msgstr "synonymes" #. translators: label for a list of antonyms -#: addon/globalPlugins/text_information/definitions.py:132 -#: addon/globalPlugins/text_information/definitions.py:144 +#: addon/globalPlugins/text_information/definitions.py:260 +#: addon/globalPlugins/text_information/definitions.py:272 msgid "antonyms" msgstr "antonymes" -#. translators: label for a button that plays a pronunciation audio clip, {0} -#. is the locale/variant -#: addon/globalPlugins/text_information/definitions.py:229 -#, python-brace-format, fuzzy +#. translators: message spoken when a word's pronunciation cannot be played +#: addon/globalPlugins/text_information/definitions.py:347 +msgid "pronunciation unavailable" +msgstr "prononciation non disponible" + +#. translators: label for a button that plays a pronunciation audio clip, {0} is the locale/variant +#: addon/globalPlugins/text_information/definitions.py:396 +#, fuzzy, python-brace-format msgid "Play {0}" msgstr "Lire {0}" +#. translators: label for the dictionary used to look up word definitions +#: addon/globalPlugins/text_information/settings.py:44 +msgid "Dictionary for word &definitions:" +msgstr "Dictionnaire pour les &définitions de mots :" + +#: addon/globalPlugins/text_information/settings.py:56 +msgid "" +"If the selected dictionary lacks a definition or &pronunciation, try another " +"source" +msgstr "Si le dictionnaire sélectionné n'a pas de définition ou de &prononciation, essayer une autre source" + #. Add-on summary, usually the user visible name of the addon. -#. Translators: Summary for this add-on to be shown on installation and add-on -#. information. +#. Translators: Summary for this add-on to be shown on installation and add-on information. #: buildVars.py:17 msgid "text information" msgstr "informations textuelles" #. Add-on description -#. Translators: Long description to be shown for this add-on on add-on -#. information from add-ons manager +#. Translators: Long description to be shown for this add-on on add-on information from add-ons manager #: buildVars.py:20 msgid "" "Provides information like dictionary definitions for selected text. Press " -"NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the" -" clipboard, and NVDA + control + ; to speak the last retrieved information. " +"NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the " +"clipboard, and NVDA + control + ; to speak the last retrieved information. " "You can press this twice quickly to have it displayed in a browseable " "dialog. Note: for non-english keyboard layouts these gestures might need to " "be redefined in the input gestures dialog." msgstr "" -"Fournit des informations comme des définitions de dictionnaire pour le texte" -" sélectionné. Appuyez sur NVDA+ ; (point-virgule) pour activer, NVDA + maj +" -" ; pour obtenir des informations depuis le presse-papiers, et NVDA + " +"Fournit des informations comme des définitions de dictionnaire pour le texte " +"sélectionné. Appuyez sur NVDA+ ; (point-virgule) pour activer, NVDA + maj " +"+ ; pour obtenir des informations depuis le presse-papiers, et NVDA + " "contrôle + ; pour énoncer la dernière information récupérée. Vous pouvez " "appuyer deux fois rapidement pour l'afficher dans une boîte de dialogue " "navigable. Remarque : pour les dispositions de clavier non anglaises, ces " -"gestes devront peut-être être redéfinis dans le dialogue Gestes de " -"commandes." +"gestes devront peut-être être redéfinis dans le dialogue Gestes de commandes." diff --git a/addon/locale/ru/LC_MESSAGES/nvda.po b/addon/locale/ru/LC_MESSAGES/nvda.po index 6d74f35..55dd451 100644 --- a/addon/locale/ru/LC_MESSAGES/nvda.po +++ b/addon/locale/ru/LC_MESSAGES/nvda.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Project-Id-Version: Text information 1.0\n" -"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-12 10:37-0700\n" +"Report-Msgid-Bugs-To: nvda-translations@groups.io\n" +"POT-Creation-Date: 2026-08-18 03:22+0500\n" "PO-Revision-Date: 2026-07-12 17:43+0000\n" "Last-Translator: Carter Temm \n" "Language-Team: \n" @@ -11,262 +11,291 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Generated-By: Babel 2.18.0\n" #. translators: error -#: addon/globalPlugins/text_information/__init__.py:122 -#: addon/globalPlugins/text_information/__init__.py:286 -#: addon/globalPlugins/text_information/definitions.py:35 +#: addon/globalPlugins/text_information/__init__.py:140 +#: addon/globalPlugins/text_information/__init__.py:331 +#: addon/globalPlugins/text_information/definitions.py:140 +#: addon/globalPlugins/text_information/providers.py:150 +#: addon/globalPlugins/text_information/providers.py:155 +#: addon/globalPlugins/text_information/providers.py:162 msgid "error" msgstr "ошибка" #. translators: message spoken when we can't connect (error with connection) -#: addon/globalPlugins/text_information/__init__.py:128 -#: addon/globalPlugins/text_information/__init__.py:304 -#: addon/globalPlugins/text_information/definitions.py:57 +#: addon/globalPlugins/text_information/__init__.py:146 +#: addon/globalPlugins/text_information/__init__.py:349 +#: addon/globalPlugins/text_information/definitions.py:173 msgid "error making connection" msgstr "ошибка соединения" #. translators: message spoken when the connection is refused by our target -#: addon/globalPlugins/text_information/__init__.py:140 -#: addon/globalPlugins/text_information/__init__.py:306 -#: addon/globalPlugins/text_information/definitions.py:62 +#: addon/globalPlugins/text_information/__init__.py:158 +#: addon/globalPlugins/text_information/__init__.py:351 +#: addon/globalPlugins/text_information/definitions.py:178 msgid "error, connection refused by target" msgstr "ошибка, соединение отклонено" -#. translators: message, followed by the error, spoken when the response -#. returned contains an error -#: addon/globalPlugins/text_information/__init__.py:172 +#. translators: message, followed by the error, spoken when the response returned contains an error +#: addon/globalPlugins/text_information/__init__.py:190 msgid "error obtaining IP info " msgstr "ошибка получения информации об IP " -#: addon/globalPlugins/text_information/__init__.py:177 +#: addon/globalPlugins/text_information/__init__.py:196 msgid "country" msgstr "страна" -#: addon/globalPlugins/text_information/__init__.py:181 +#: addon/globalPlugins/text_information/__init__.py:200 msgid "region" msgstr "регион" -#: addon/globalPlugins/text_information/__init__.py:185 +#: addon/globalPlugins/text_information/__init__.py:204 msgid "city" msgstr "город" -#: addon/globalPlugins/text_information/__init__.py:189 +#: addon/globalPlugins/text_information/__init__.py:208 msgid "zipcode" msgstr "индекс" -#: addon/globalPlugins/text_information/__init__.py:193 +#: addon/globalPlugins/text_information/__init__.py:212 msgid "longitude" msgstr "долгота" -#: addon/globalPlugins/text_information/__init__.py:197 +#: addon/globalPlugins/text_information/__init__.py:216 msgid "latitude" msgstr "широта" -#: addon/globalPlugins/text_information/__init__.py:201 +#: addon/globalPlugins/text_information/__init__.py:220 msgid "timezone" msgstr "часовой пояс" -#: addon/globalPlugins/text_information/__init__.py:205 +#: addon/globalPlugins/text_information/__init__.py:224 msgid "ISP" msgstr "провайдер" -#: addon/globalPlugins/text_information/__init__.py:210 +#: addon/globalPlugins/text_information/__init__.py:229 msgid "mobile connection" msgstr "мобильное соединение" -#: addon/globalPlugins/text_information/__init__.py:212 +#: addon/globalPlugins/text_information/__init__.py:231 msgid "Proxy, VPN or Tor exit address" msgstr "адрес выхода Proxy, VPN или Tor" -#. translators: message spoken when we're unable to find a book with the given -#. ISBN -#: addon/globalPlugins/text_information/__init__.py:227 +#. translators: message spoken when we're unable to find a book with the given ISBN +#: addon/globalPlugins/text_information/__init__.py:246 msgid "no book with that ISBN found" msgstr "нет книги с этим ISBN" -#: addon/globalPlugins/text_information/__init__.py:233 -#: addon/globalPlugins/text_information/__init__.py:323 +#: addon/globalPlugins/text_information/__init__.py:253 +#: addon/globalPlugins/text_information/__init__.py:368 msgid "title" msgstr "название" -#: addon/globalPlugins/text_information/__init__.py:237 +#: addon/globalPlugins/text_information/__init__.py:257 msgid "author (s)" msgstr "автор (ы)" -#: addon/globalPlugins/text_information/__init__.py:241 +#: addon/globalPlugins/text_information/__init__.py:261 msgid "language" msgstr "язык" #. translators: label for the page description field in URL output -#: addon/globalPlugins/text_information/__init__.py:245 -#: addon/globalPlugins/text_information/__init__.py:329 +#: addon/globalPlugins/text_information/__init__.py:265 +#: addon/globalPlugins/text_information/__init__.py:374 msgid "description" msgstr "описание" -#: addon/globalPlugins/text_information/__init__.py:249 +#: addon/globalPlugins/text_information/__init__.py:269 msgid "maturity rating" msgstr "рейтинг" -#: addon/globalPlugins/text_information/__init__.py:253 +#: addon/globalPlugins/text_information/__init__.py:273 msgid "published date" msgstr "дата публикации" #. translators: label for the phonetic pronunciation of a word #. translators: fallback label for a pronunciation audio button when no locale #. or phonetic transcription could be determined for it -#: addon/globalPlugins/text_information/__init__.py:269 -#: addon/globalPlugins/text_information/definitions.py:98 +#. Contributor names are wiki usernames, so they arrive with underscores for spaces +#. and often a parenthesised account name that adds nothing when spoken +#. translators: fallback label for a pronunciation audio button when neither an accent +#. nor a contributor could be determined for the recording +#: addon/globalPlugins/text_information/__init__.py:308 +#: addon/globalPlugins/text_information/definitions.py:213 +#: addon/globalPlugins/text_information/providers.py:308 msgid "pronunciation" msgstr "произношение" #. translators: The message spoken when a word was not found. -#: addon/globalPlugins/text_information/__init__.py:273 -#: addon/globalPlugins/text_information/definitions.py:47 +#: addon/globalPlugins/text_information/__init__.py:312 +#: addon/globalPlugins/text_information/definitions.py:161 +#: addon/globalPlugins/text_information/providers.py:142 +#: addon/globalPlugins/text_information/providers.py:171 msgid "unable to find definition for word" msgstr "не в состоянии найти определение слова" #. translators: message spoken when the page title cannot be retrieved -#: addon/globalPlugins/text_information/__init__.py:320 +#: addon/globalPlugins/text_information/__init__.py:365 msgid "unable to retrieve page title" msgstr "не удалось получить заголовок страницы" #. translators: label for the content length field in URL output -#: addon/globalPlugins/text_information/__init__.py:334 +#: addon/globalPlugins/text_information/__init__.py:379 msgid "content length" msgstr "размер содержимого" #. translators: label spoken when a URL redirects to a different domain -#: addon/globalPlugins/text_information/__init__.py:340 +#: addon/globalPlugins/text_information/__init__.py:385 msgid "redirects to" msgstr "перенаправляет на" -#: addon/globalPlugins/text_information/__init__.py:355 +#. translators: title of the add-on's settings category in NVDA's settings dialog +#: addon/globalPlugins/text_information/__init__.py:427 +#: addon/globalPlugins/text_information/settings.py:37 msgid "Text Information" msgstr "Текстовая информация" #. translators: message spoken when the clipboard is empty -#: addon/globalPlugins/text_information/__init__.py:372 +#: addon/globalPlugins/text_information/__init__.py:453 msgid "There is no text on the clipboard" msgstr "В буфере обмена нет текста" -#: addon/globalPlugins/text_information/__init__.py:377 +#: addon/globalPlugins/text_information/__init__.py:458 msgid "speaks information of text on the clipboard" msgstr "говорит информацию о тексте в буфере обмена" #. translators: message spoken when no text is selected or focused -#: addon/globalPlugins/text_information/__init__.py:397 +#: addon/globalPlugins/text_information/__init__.py:478 msgid "select or focus something first" msgstr "сначала выделите или сфокусируйте что-нибудь" -#: addon/globalPlugins/text_information/__init__.py:401 +#: addon/globalPlugins/text_information/__init__.py:482 msgid "speaks information for currently selected text" msgstr "говорит информацию для выбранного в данный момент текста" -#. translators: title of the dialog showing a word's definition and -#. pronunciation audio buttons -#: addon/globalPlugins/text_information/__init__.py:412 -#, python-brace-format, fuzzy +#. translators: title of the dialog showing a word's definition and pronunciation audio buttons +#: addon/globalPlugins/text_information/__init__.py:493 +#, fuzzy, python-brace-format msgid "Definition for {0}" msgstr "Определение слова {0}" -#. translators: message spoken when the user tries getting previous -#. information -#. but there is none -#: addon/globalPlugins/text_information/__init__.py:418 +#. translators: message spoken when the user tries getting previous information but there is none +#: addon/globalPlugins/text_information/__init__.py:501 msgid "you haven't yet gotten info" msgstr "вы ещё не получили информацию" -#: addon/globalPlugins/text_information/__init__.py:421 +#: addon/globalPlugins/text_information/__init__.py:504 msgid "reports the last retrieved information in a browseable dialog" msgstr "" "сообщает последнюю полученную информацию в диалоговом окне с возможностью " "просмотра" #. translators: credit card -#: addon/globalPlugins/text_information/__init__.py:430 +#: addon/globalPlugins/text_information/__init__.py:513 msgid "credit card" msgstr "кредитная карта" -#. translators: message spoken after selecting text that contains an IP v4 -#. address -#: addon/globalPlugins/text_information/__init__.py:434 +#. translators: message spoken after selecting text that contains an IP v4 address +#: addon/globalPlugins/text_information/__init__.py:517 msgid " IPv4 address, retrieving information..." msgstr " IPv4 адрес, получение информации..." -#. translators: message spoken after selecting text that contains an IP v6 -#. address -#: addon/globalPlugins/text_information/__init__.py:438 +#. translators: message spoken after selecting text that contains an IP v6 address +#: addon/globalPlugins/text_information/__init__.py:521 msgid "IPv6 address, retrieving information..." msgstr "IPv6 адрес, получение информации..." #. translators: phone number -#: addon/globalPlugins/text_information/__init__.py:443 +#: addon/globalPlugins/text_information/__init__.py:526 msgid "phone number" msgstr "номер телефона" #. translators: email -#: addon/globalPlugins/text_information/__init__.py:446 +#: addon/globalPlugins/text_information/__init__.py:529 msgid "email" msgstr "эл. почта" #. translators: message spoken after text is selected that contains an ISBN -#: addon/globalPlugins/text_information/__init__.py:449 +#: addon/globalPlugins/text_information/__init__.py:532 msgid "isbn: retrieving information..." msgstr "isbn: получение информации..." #. translators: message spoken after selecting text that contains a URL -#: addon/globalPlugins/text_information/__init__.py:454 +#: addon/globalPlugins/text_information/__init__.py:537 msgid "URL, retrieving page information..." msgstr "URL, получение информации о странице..." -#. translators: message spoken after selecting text that contains a word (will -#. be defined) -#: addon/globalPlugins/text_information/__init__.py:458 +#. translators: message spoken after selecting text that contains a word (will be defined) +#: addon/globalPlugins/text_information/__init__.py:541 msgid "retrieving word information..." msgstr "получение информации о слове..." +#. translators: message spoken when the word exists but the dictionary has no definition for it +#: addon/globalPlugins/text_information/definitions.py:158 +msgid "definition unavailable" +msgstr "определение недоступно" + +#. translators: message spoken when the dictionary service is temporarily unreachable +#: addon/globalPlugins/text_information/definitions.py:165 +#: addon/globalPlugins/text_information/providers.py:146 +msgid "dictionary service unavailable, please try again" +msgstr "служба словаря недоступна, попробуйте снова" + #. translators: label for an example sentence using a word -#: addon/globalPlugins/text_information/definitions.py:126 +#: addon/globalPlugins/text_information/definitions.py:254 msgid "example" msgstr "пример" #. translators: label for a list of synonyms -#: addon/globalPlugins/text_information/definitions.py:129 -#: addon/globalPlugins/text_information/definitions.py:142 +#: addon/globalPlugins/text_information/definitions.py:257 +#: addon/globalPlugins/text_information/definitions.py:270 msgid "synonyms" msgstr "синонимы" #. translators: label for a list of antonyms -#: addon/globalPlugins/text_information/definitions.py:132 -#: addon/globalPlugins/text_information/definitions.py:144 +#: addon/globalPlugins/text_information/definitions.py:260 +#: addon/globalPlugins/text_information/definitions.py:272 msgid "antonyms" msgstr "антонимы" -#. translators: label for a button that plays a pronunciation audio clip, {0} -#. is the locale/variant -#: addon/globalPlugins/text_information/definitions.py:229 -#, python-brace-format, fuzzy +#. translators: message spoken when a word's pronunciation cannot be played +#: addon/globalPlugins/text_information/definitions.py:347 +msgid "pronunciation unavailable" +msgstr "произношение недоступно" + +#. translators: label for a button that plays a pronunciation audio clip, {0} is the locale/variant +#: addon/globalPlugins/text_information/definitions.py:396 +#, fuzzy, python-brace-format msgid "Play {0}" msgstr "Воспроизвести {0}" +#. translators: label for the dictionary used to look up word definitions +#: addon/globalPlugins/text_information/settings.py:44 +msgid "Dictionary for word &definitions:" +msgstr "Словарь для &определений слов:" + +#: addon/globalPlugins/text_information/settings.py:56 +msgid "" +"If the selected dictionary lacks a definition or &pronunciation, try another " +"source" +msgstr "Если в выбранном словаре нет определения или &произношения, использовать другой источник" + #. Add-on summary, usually the user visible name of the addon. -#. Translators: Summary for this add-on to be shown on installation and add-on -#. information. +#. Translators: Summary for this add-on to be shown on installation and add-on information. #: buildVars.py:17 msgid "text information" msgstr "текстовая информация" #. Add-on description -#. Translators: Long description to be shown for this add-on on add-on -#. information from add-ons manager +#. Translators: Long description to be shown for this add-on on add-on information from add-ons manager #: buildVars.py:20 msgid "" "Provides information like dictionary definitions for selected text. Press " -"NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the" -" clipboard, and NVDA + control + ; to speak the last retrieved information. " +"NVDA+; (semicolon) to activate, NVDA + shift + ; to get information from the " +"clipboard, and NVDA + control + ; to speak the last retrieved information. " "You can press this twice quickly to have it displayed in a browseable " "dialog. Note: for non-english keyboard layouts these gestures might need to " "be redefined in the input gestures dialog." diff --git a/readme.md b/readme.md index 2e8e696..4fd56a9 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ With a single keypress, it can give you the meaning of a word, geolocate an IP a Currently, the following features are supported: * IP address information. Includes geolocation, ISP, VPN/tor exit node and cellular network identification. -* english dictionary definitions, part of speech, example sentences, synonyms, antonyms, etc. Courtesy of the [Free Dictionary API](https://dictionaryapi.dev/). When you bring up a word definition in a browsable dialog, buttons are included to hear its pronunciation, when audio is available +* english dictionary definitions, part of speech, example sentences, synonyms, antonyms, etc. Definitions come from either the [Free Dictionary API](https://dictionaryapi.dev/) or [Wiktionary](https://en.wiktionary.org/), whichever you select in the settings. When you bring up a word definition in a browsable dialog, buttons are included to hear its pronunciation, when audio is available * ISBN lookups via the google books API * URL information. Fetches the page title, meta description, content length, and final domain (if the URL redirects to a different one) before you visit a site. * credit card type verification (Mastercard, Visa, Discover, Amex, etc) @@ -17,6 +17,17 @@ The add-on implements support for identifying phone numbers and email addresses Note: Regular expressions are used under the hood to verify data. This means that email addresses and card numbers will never leave your machine. +## Settings + +Options live under NVDA menu -> Preferences -> Settings -> Text Information. + +* Dictionary for word definitions. Choose between the Free Dictionary API and Wiktionary. The Free Dictionary API is the default. Its entries are more concise and it supplies pronunciation audio, but it is less reliable and lacks entries for some very common words. Wiktionary is more dependable and covers far more, at the cost of longer, more technical definitions and no pronunciation audio of its own. +* If the selected dictionary lacks a definition or pronunciation, try another source. Off by default. When enabled, a word the selected dictionary cannot define is looked up in the other one, and a pronunciation it cannot supply is looked for among [Wikimedia Commons](https://commons.wikimedia.org/) recordings. + +Words are looked up without any punctuation surrounding them, so selecting a word at the end of a sentence or inside quotes or brackets works as expected. Punctuation inside a word is kept, leaving things like let's and so-so intact. + +If the selected dictionary reports that a word does not exist, the add-on confirms this against Wiktionary before telling you so. A word that does exist but has no entry available is reported as "definition unavailable" instead, so a real word is never reported as not being a word. + ## Keystrokes note: These bindings asume an English keyboard layout, and might not work otherwise. If you experience an issue, first try changing them in the input gestures dialog. From cf5c5d331240db0df9686cf6294a672c1bd43ace Mon Sep 17 00:00:00 2001 From: hassanraza464 Date: Tue, 18 Aug 2026 22:13:51 +0500 Subject: [PATCH 3/3] Fix a crash on unusable responses and a pause before speaking The dictionary API has been seen returning an empty body with a 200 status while it is unwell. Parsing happened outside the try, so the lookup thread died with a JSONDecodeError and the user was told nothing at all. Parsing now happens inside it, and an unusable body is reported like any other failure. This also covers a response that is valid json but an empty list, which raised IndexError. Looking elsewhere for a pronunciation ran between the beep and the definition being spoken, so a lookup paused for about half a second before saying anything. It happens whenever the fallback option is enabled and the dictionary carries no recording for a word, which is why it was intermittent. The definition is now spoken first and the recording fetched afterwards. That leaves a short window where a definition has been spoken but its audio has not arrived, so opening the dialog within it shows no play buttons. Reaching that needs a double press faster than the lookup, and the buttons appear on a second attempt. --- addon/globalPlugins/text_information/__init__.py | 13 +++++++------ addon/globalPlugins/text_information/definitions.py | 9 ++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/addon/globalPlugins/text_information/__init__.py b/addon/globalPlugins/text_information/__init__.py index 5dc3021..514a0d9 100644 --- a/addon/globalPlugins/text_information/__init__.py +++ b/addon/globalPlugins/text_information/__init__.py @@ -312,16 +312,17 @@ def get_word_info(text): ui.message(_("unable to find definition for word")) return tones.beep(300, 200) - audio = entry.get("audio", []) - # The chosen dictionary may not carry pronunciation audio at all, in which case looking - # elsewhere for it is part of what the fallback option asks for - if not audio and settings.should_use_fallback(): - audio = providers.get_commons_audio(word) - lastAudio = audio + lastAudio = entry.get("audio", []) lastWord = word lastIsWordDefinition = True last = ". ".join(fields) ui.message(last) + # The chosen dictionary may not carry pronunciation audio at all, in which case looking + # elsewhere for it is part of what the fallback option asks for. That is a request, so it + # happens once the definition has been spoken rather than delaying it. The dialog that + # uses the audio needs a second keypress, which takes longer than the lookup does. + if not lastAudio and settings.should_use_fallback(): + lastAudio = providers.get_commons_audio(word) def get_url_info(addr, timeout=10): diff --git a/addon/globalPlugins/text_information/definitions.py b/addon/globalPlugins/text_information/definitions.py index 92a5974..03ac061 100644 --- a/addon/globalPlugins/text_information/definitions.py +++ b/addon/globalPlugins/text_information/definitions.py @@ -148,6 +148,9 @@ def report(message): headers={"User-Agent": CHROME_UA}, ) response = urlopen_with_retry(req, timeout=10).read() + # Parsed inside the try, because a request can succeed and still carry a body we + # cannot use. The API has been seen returning an empty 200 while it is unwell. + return json.loads(response)[0] except HTTPError as h: if h.code == 404: log.debug(f"No dictionary entry for word: {word}") @@ -179,11 +182,15 @@ def report(message): else: report(error + ": " + str(u)) return None + except (ValueError, IndexError) as e: + log.error(f"Unusable response body for {word!r}: {e}") + # translators: message spoken when the word exists but the dictionary has no definition for it + report(_("definition unavailable")) + return None except Exception as e: log.error(f"Unexpected error fetching definition for {word!r}: {e}", exc_info=True) report(error + ": " + str(e)) return None - return json.loads(response)[0] def get_entry_phonetic(entry):