diff --git a/.github/workflows/Publish Release.yml b/.github/workflows/Publish Release.yml index f63de4b..5552ae3 100644 --- a/.github/workflows/Publish Release.yml +++ b/.github/workflows/Publish Release.yml @@ -1,11 +1,11 @@ name: Publish Release on: - workflow_dispatch: push: branches: [ main ] - paths-ignore: - - .github/workflows/* + paths: + - 'plugin.json' + workflow_dispatch: jobs: publish: @@ -15,22 +15,27 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - name: get version id: version uses: notiz-dev/github-action-json-property@release with: path: 'plugin.json' prop_path: 'Version' + - run: echo ${{steps.version.outputs.prop}} + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r ./requirements.txt -t ./lib zip -r Flow.Launcher.Plugin.WordNikDictionary.zip . -x '*.git*' + - name: Publish if: success() uses: softprops/action-gh-release@v1 diff --git a/.gitignore b/.gitignore index 01d7f95..5c34338 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -__pycache__ -venv \ No newline at end of file +__pycache__/* +venv/* +lib/* +wordnik.logs +*.pyc \ No newline at end of file diff --git a/Images/discord.png b/Images/discord.png new file mode 100644 index 0000000..c71fe21 Binary files /dev/null and b/Images/discord.png differ diff --git a/Images/error.png b/Images/error.png new file mode 100644 index 0000000..bab1714 Binary files /dev/null and b/Images/error.png differ diff --git a/Images/filter_by_part_of_speech_example.png b/Images/filter_by_part_of_speech_example.png new file mode 100644 index 0000000..a2fd93d Binary files /dev/null and b/Images/filter_by_part_of_speech_example.png differ diff --git a/Images/find_similiar_word_categories_example.png b/Images/find_similiar_word_categories_example.png new file mode 100644 index 0000000..12cca36 Binary files /dev/null and b/Images/find_similiar_word_categories_example.png differ diff --git a/Images/find_similiar_words_by_category_example.png b/Images/find_similiar_words_by_category_example.png new file mode 100644 index 0000000..bdb5617 Binary files /dev/null and b/Images/find_similiar_words_by_category_example.png differ diff --git a/Images/get_definition_example.png b/Images/get_definition_example.png new file mode 100644 index 0000000..1001a71 Binary files /dev/null and b/Images/get_definition_example.png differ diff --git a/Images/get_definition_information_example.png b/Images/get_definition_information_example.png new file mode 100644 index 0000000..1a7f244 Binary files /dev/null and b/Images/get_definition_information_example.png differ diff --git a/Images/get_syllables_example.png b/Images/get_syllables_example.png new file mode 100644 index 0000000..3e0c93f Binary files /dev/null and b/Images/get_syllables_example.png differ diff --git a/Images/github.png b/Images/github.png new file mode 100644 index 0000000..ffacbdc Binary files /dev/null and b/Images/github.png differ diff --git a/Images/invalid_api_key_example.png b/Images/invalid_api_key_example.png new file mode 100644 index 0000000..c506b16 Binary files /dev/null and b/Images/invalid_api_key_example.png differ diff --git a/Images/log_file.png b/Images/log_file.png new file mode 100644 index 0000000..f781c84 Binary files /dev/null and b/Images/log_file.png differ diff --git a/Images/unexpected_error_handler_showcase.mp4 b/Images/unexpected_error_handler_showcase.mp4 new file mode 100644 index 0000000..277edb5 Binary files /dev/null and b/Images/unexpected_error_handler_showcase.mp4 differ diff --git a/Images/word_not_found_example.png b/Images/word_not_found_example.png new file mode 100644 index 0000000..9bb78a3 Binary files /dev/null and b/Images/word_not_found_example.png differ diff --git a/README.md b/README.md index d68b6be..840ac75 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,54 @@ This is a plugin for [Flow Launcher](https://github.com/Flow-Launcher/Flow.Launcher) that lets you easily see the definitions of words using [wordnik](https://wordnik.com). ## Get an API Key -To get an API key, head to [developer.wordnik.com](https://developer.wordnik.com/), and create an account. Once you've created your account, you'll be able to fill out a form to request an api key. \ No newline at end of file +To get an API key, head to [developer.wordnik.com](https://developer.wordnik.com/), and create an account. Once you've created your account, you'll be able to fill out a form to request an api key. + +## Features + +### Feature List +1. [Get the definition of a word](#get-the-definition-of-a-word) +2. [Get information about definition of a word](#get-information-about-definition-of-a-word) +3. [Search Modifiers](#search-modifiers) + - [Filter by parts of speech](#filter-by-parts-of-speech) + - [Get the syllables of a word](#get-the-syllables-of-a-word) + - [Get similiar word by category](#get-similiar-word-by-category) +4. [Advanced Error Handler](#advanced-error-handler) + - [Expected Errors](#expected-errors) + - [Unexpected Errors](#unexpected-errors) + +### Get the definition of a word +Get a list of definitions for your word from various sources. Syntax: `def word` +![Example showing the result of the search `def vague`](Images/get_definition_example.png) +### Get information about definition of a word +Get information about a certain definition, and easy access to the source. This is a context menu that is avalible for all definitions. +![Example showing the context menu of a word definition](Images/get_definition_information_example.png) +### Search Modifiers +You can use search modifiers to filter your results by part of speech, or to get different types of information about a word. Search modifiers have the following syntax: `word!modifier` +#### Filter by parts of speech +You can filter results by parts of speech by inputting the part of speech as a modifier. +Syntax: `word!part_of_speech`. +List of acceptable parts of speech modifiers: +``` +noun, adjective, verb, adverb, interjection, pronoun, preposition, abbreviation, affix, article, auxiliary-verb, conjunction, definite-article, family-name, given-name, idiom, imperative, noun-plural, noun-posessive, past-participle, phrasal-prefix, proper-noun, proper-noun-plural, proper-noun-posessive, suffix, verb-intransitive, verb-transitive +``` +![Example showing the useage of the parts of speech search modifier with the `def vague!noun` query](Images/filter_by_part_of_speech_example.png) +#### Get the syllables of a word +We can use the `syllables` search modifier to get the syllables of a word. Syntax: `def word!syllables` +![Example showing the result of the search `def developer!syllable`](Images/get_syllables_example.png) +#### Get categories of similiar words +To find the categories of avalible similiar words for a given word, use the following command: `def word!similiar`. To see all of the words in a given category, see the section below. +![](Images/find_similiar_word_categories_example.png) +#### Get similiar word by category +To find all of the words that are similiar to a word in a specific category, use the following command: `def word!rel-category`. For a list of avalible categories for a given word, see the above section. +![](Images/find_similiar_words_by_category_example.png) + +### Advanced Error Handler + +#### Expected Errors +Expected errors will return a short, simple, and stylish error message. +![](Images/word_not_found_example.png) +![](Images/invalid_api_key_example.png) +#### Unexpected Errors +When unexpected errors occur, our error handler redirects it to the logs and prompts you to notify us by creating a github issue or discord thread with the logfile. + +https://github.com/cibere/Flow.Launcher.Plugin.WordNikDictionary/raw/refs/heads/v2/Images/unexpected_error_handler_showcase.mp4 \ No newline at end of file diff --git a/SettingsTemplate.yaml b/SettingsTemplate.yaml index e081aee..62fc57d 100644 --- a/SettingsTemplate.yaml +++ b/SettingsTemplate.yaml @@ -14,10 +14,4 @@ body: attributes: name: results label: Number of results to display - defaultValue: 20 - - type: checkbox - attributes: - name: debug_mode - label: Debug Mode - description: If checked, data will be written to temp files for review in the case of an error. - defaultValue: false \ No newline at end of file + defaultValue: 20 \ No newline at end of file diff --git a/WordnikDictionary/attributions.py b/WordnikDictionary/attributions.py new file mode 100644 index 0000000..2e2a674 --- /dev/null +++ b/WordnikDictionary/attributions.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +__all__ = ("Attribution",) + + +class Attribution: + def __init__(self, text: str | None, url: str | None) -> None: + self.text = text or "" + self.url = url + + @classmethod + def from_json(cls: type[Attribution], data: dict) -> Attribution: + return cls(data["attributionText"], data["attributionUrl"]) diff --git a/WordnikDictionary/core.py b/WordnikDictionary/core.py new file mode 100644 index 0000000..c2271a4 --- /dev/null +++ b/WordnikDictionary/core.py @@ -0,0 +1,185 @@ +import inspect +import json +import os +import re +import sys +import webbrowser +from logging import getLogger +from typing import Any + +from flowlauncher import FlowLauncher, FlowLauncherAPI + +from .definition import Definition +from .errors import InternalException +from .http import HTTPClient +from .options import Option +from .utils import convert_options, handle_plugin_exception +from .word_relationship import WordRelationship + +LOG = getLogger(__name__) +QUERY_REGEX = re.compile(r"^(?P[a-zA-Z]+)(!(?P[a-zA-Z-]+))?$") + +parts_of_speech = [ + "noun", + "adjective", + "verb", + "adverb", + "interjection", + "pronoun", + "preposition", + "abbreviation", + "affix", + "article", + "auxiliary-verb", + "conjunction", + "definite-article", + "family-name", + "given-name", + "idiom", + "imperative", + "noun-plural", + "noun-posessive", + "past-participle", + "phrasal-prefix", + "proper-noun", + "proper-noun-plural", + "proper-noun-posessive", + "suffix", + "intransitive-verb", + "transitive-verb", +] + + +class WordnikDictionaryPlugin(FlowLauncher): + def __init__(self, args: str | None = None): + self.http = HTTPClient(self) + + # defalut jsonrpc + self.rpc_request = {"method": "query", "parameters": [""]} + self.debugMessage = "" + + if args is None and len(sys.argv) > 1: + + # Gets JSON-RPC from Flow Launcher process. + self.rpc_request = json.loads(sys.argv[1]) + LOG.debug(f"Received RPC request: {json.dumps(self.rpc_request)}") + + # proxy is not working now + # self.proxy = self.rpc_request.get("proxy", {}) + + request_method_name = self.rpc_request.get("method", "query") + request_parameters = self.rpc_request.get("parameters", []) + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + request_method = dict(methods)[request_method_name] + results = request_method(*request_parameters) + + if request_method_name in ("query", "context_menu"): + data = {"result": results, "debugMessage": self.debugMessage} + + try: + payload = json.dumps(data) + except TypeError as e: + LOG.error( + f"Error occured while trying to convert payload for flow through json.dumps. Data: {data!r}", + exc_info=e, + ) + raise InternalException() from e + else: + LOG.debug(f"Sending data to flow: {payload}") + print(payload) + + @property + def settings(self) -> dict: + return self.rpc_request["settings"] + + def get_definitions(self, word: str) -> list[Definition]: + raw = self.http.fetch_definitions(word) + final = [] + for data in raw: + definition = Definition.from_json(word, data) + if definition: + final.append(definition) + return final + + def get_syllables(self, word: str) -> list[str]: + raw = self.http.fetch_syllables(word) + final = [] + for data in sorted(raw, key=lambda d: d["seq"]): + final.append(data["text"]) + return final + + def get_word_relationships(self, word: str) -> list[WordRelationship]: + raw = self.http.fetch_similiar_words(word) + final = [] + for data in raw: + item = WordRelationship.from_json(word, data) + if item: + final.append(item) + return final + + @handle_plugin_exception + @convert_options + def query(self, query: str): + LOG.info(f"Received query: {query!r}") + + if not query.strip(): + return [Option.wnf()] + + word = query + filter_query = None + matches = QUERY_REGEX.match(query) + if matches: + word = matches["word"] + filter_query = matches.group("filter") + LOG.info(f"Match found. {word=}, {filter_query=}") + + if filter_query: + if filter_query == "syllables": + syllables = self.get_syllables(word) + return [Option(title="-".join(syllables))] or [Option.wnf()] + elif filter_query == "similiar": + return self.get_word_relationships(word) or [Option.wnf()] + elif filter_query.startswith("rel-"): + rel_type = filter_query.removeprefix("rel-") + relationships = self.get_word_relationships(word) + for relationship in relationships: + if relationship.type == rel_type: + return relationship.get_word_options() or [Option.wnf()] + + definitions = self.get_definitions(word) + + if filter_query: + if filter_query in parts_of_speech: + temp = filter_query.replace("-", " ") + definitions = filter(lambda d: d.part_of_speech == temp, definitions) + else: + return [ + Option( + title="Unknown Search Modifier Given", + sub="Press ENTER to open search modifier index", + callback="open_url", + params=[ + "https://github.com/cibere/Flow.Launcher.Plugin.WordNikDictionary/tree/v2?tab=readme-ov-file#search-modifiers" + ], + ) + ] + + return definitions or [Option.wnf()] + + @handle_plugin_exception + def context_menu(self, data: list[Any]): + LOG.debug(f"Context menu received: {data=}") + return data + + def open_url(self, url): + webbrowser.open(url) + + def open_settings_menu(self): + FlowLauncherAPI.open_setting_dialog() + + def change_query(self, query: str): + FlowLauncherAPI.change_query(query) + + def open_log_file_folder(self): + os.system(f'explorer.exe /select, "wordnik.logs"') diff --git a/WordnikDictionary/dataclass.py b/WordnikDictionary/dataclass.py new file mode 100644 index 0000000..6d7a44d --- /dev/null +++ b/WordnikDictionary/dataclass.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Self + +from .options import Option + +__all__ = ("Dataclass",) + + +class Dataclass: + @classmethod + def from_json(cls: type[Self], word: str, data: dict) -> Self | None: + raise RuntimeError("This must be overriden") + + def _generate_base_option(self) -> Option: + raise RuntimeError("This must be overriden") + + def _generate_context_menu_options(self) -> list[Option]: + return [] + + def to_option(self) -> Option: + opt = self._generate_base_option() + opt.context_data = self._generate_context_menu_options() + return opt diff --git a/WordnikDictionary/definition.py b/WordnikDictionary/definition.py new file mode 100644 index 0000000..366f53d --- /dev/null +++ b/WordnikDictionary/definition.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from .attributions import Attribution +from .dataclass import Dataclass +from .html_stripper import strip_tags +from .options import Option + +__all__ = ("Definition",) + + +class Definition(Dataclass): + def __init__( + self, + part_of_speech: str | None, + attribution: Attribution, + text: str, + wordnik_url: str, + word: str, + ) -> None: + self.word: str = word + self.attribution: Attribution = attribution + self.text: str = strip_tags(text) + self.wordnik_url: str = wordnik_url + + self.part_of_speech = part_of_speech + if part_of_speech: + self.part_of_speech = part_of_speech.strip( + r"!@#$%^&*()-=_+[]{}\|';:\"/.,?><`~ " + ) + + @classmethod + def from_json(cls: type[Definition], _: str, data: dict) -> Definition | None: + if data.get("text") is None: + return None + if isinstance(data["text"], list): + text = ", ".join(data["text"]) + else: + text = data["text"] + return cls( + data.get("partOfSpeech"), + Attribution.from_json(data), + text, + data["wordnikUrl"], + data["word"], + ) + + def _generate_base_option(self) -> Option: + return Option( + title=self.text, + sub=( + f"{self.part_of_speech}; {self.attribution.text}" + if self.part_of_speech + else self.attribution.text + ), + callback="open_url", + params=[self.wordnik_url], + ) + + def _generate_context_menu_options(self) -> list[Option]: + temp = [Option(title=self.word, sub=self.text)] + if self.part_of_speech: + temp.append( + Option(title=f"Part of Speech: {self.part_of_speech}"), + ) + if self.wordnik_url: + temp.append(Option.url("in Wordnik", self.wordnik_url)) + if self.attribution.url: + temp.append(Option.url("Attribution", self.attribution.url)) + return temp diff --git a/WordnikDictionary/errors.py b/WordnikDictionary/errors.py new file mode 100644 index 0000000..3895808 --- /dev/null +++ b/WordnikDictionary/errors.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from .options import Option + +__all__ = ("PluginException", "InternalException") + + +class BaseException(Exception): + options: list[Option] + + def __init__(self, text: str, options: list[Option]) -> None: + super().__init__(text) + self.options = options + for opt in options: + opt.icon = "error" + + +class PluginException(BaseException): + @classmethod + def create( + cls: type[PluginException], + text: str, + sub: str = "", + url: str | None = None, + **kwargs, + ) -> PluginException: + if url is not None: + kwargs["callback"] = "open_url" + kwargs["params"] = [url] + + return cls(text, [Option(title=text, sub=sub, **kwargs)]) + + @classmethod + def wnf(cls: type[PluginException]) -> PluginException: + opt = Option.wnf() + return cls(opt.title, [opt]) + + +class InternalException(BaseException): + def __init__(self) -> None: + opts = [ + Option(score=100, icon="error", title="An internal error has occured."), + Option( + score=80, + icon="github", + title="Please open a github issue", + sub="Click this to open github repository", + callback="open_url", + params=[ + "https://github.com/cibere/Flow.Launcher.Plugin.WordNikDictionary" + ], + ), + Option( + score=79, + icon="discord", + title="Or create a thread in our discord server", + sub="Click on this to open discord invite", + callback="open_url", + params=["https://discord.gg/y4STfDvc8j"], + ), + Option( + score=0, + icon="log_file", + title="And provide your log file (wordnik.log)", + sub="Click on this to open plugin folder.", + callback="open_log_file_folder", + ), + ] + super().__init__("An Interal Error has occured.", opts) diff --git a/html_stripper.py b/WordnikDictionary/html_stripper.py similarity index 100% rename from html_stripper.py rename to WordnikDictionary/html_stripper.py index 05aef31..5465df2 100644 --- a/html_stripper.py +++ b/WordnikDictionary/html_stripper.py @@ -2,8 +2,8 @@ Source: https://stackoverflow.com/questions/753052/strip-html-from-strings-in-python """ -from io import StringIO from html.parser import HTMLParser +from io import StringIO class MLStripper(HTMLParser): diff --git a/WordnikDictionary/http.py b/WordnikDictionary/http.py new file mode 100644 index 0000000..fb26e29 --- /dev/null +++ b/WordnikDictionary/http.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from logging import getLogger +from typing import TYPE_CHECKING, Any +from urllib.parse import quote_plus + +import requests + +from .errors import PluginException +from .options import Option + +LOG = getLogger(__name__) +if TYPE_CHECKING: + from .core import WordnikDictionaryPlugin + +ICO_PATH = "Images/app.png" + + +class HTTPClient: + + def __init__(self, flow: WordnikDictionaryPlugin): + self.flow = flow + + @property + def settings(self) -> dict: + return self.flow.rpc_request["settings"] + + @property + def debug(self) -> bool: + try: + return self.settings["debug_mode"] + except TypeError: + return True + + def request( + self, + method: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + **kwargs, + ) -> Any: + if params is None: + params = {} + if headers is None: + headers = {} + + headers["Accept"] = "application/json" + params["api_key"] = self.settings["api_key"] + url = f"https://api.wordnik.com/v4{endpoint}" + LOG.debug(f"Sending HTTP request. {url=}, {params=}, {headers=}, {kwargs=}") + res = requests.request(method, url, params=params, headers=headers, **kwargs) + data = res.json() + LOG.debug( + f"Received HTTP response. {res.status_code=}, {res.headers=}, {data=}" + ) + if res.status_code == 401: + opt = Option( + title="Invalid API Key", + sub="Click ENTER for instructions on how to get a valid API key", + callback="open_url", + params=[ + "https://github.com/cibere/Flow.Launcher.Plugin.WordNikDictionary?tab=readme-ov-file#get-an-api-key" + ], + ) + raise PluginException(opt.title, [opt]) + elif res.status_code == 404: + raise PluginException.wnf() + + res.raise_for_status() + + return data + + def fetch_definitions(self, word: str) -> list[dict[str, Any]]: + """ + Docs on the endpoint + https://developer.wordnik.com/docs#!/word/getDefinitions + """ + + try: + limit = int(self.settings["results"]) + except ValueError: + opt = Option( + title="Error: Invalid Results Value Given.", + sub="The Results settings item must be a valid number.", + callback="open_settings_menu", + ) + raise PluginException(opt.title, [opt]) + + params = { + "limit": limit, + "includeRelated": False, + "useCanonical": self.settings["use_canonical"], + "includeTags": False, + } + endpoint = f"/word.json/{quote_plus(word)}/definitions" + + return self.request("GET", endpoint, params=params) + + def fetch_syllables(self, word: str) -> list[dict[str, Any]]: + """ + Docs on the endpoint + https://developer.wordnik.com/docs#!/word/getHyphenation + """ + + params = { + "limit": 50, + "useCanonical": self.settings["use_canonical"], + } + endpoint = f"/word.json/{quote_plus(word)}/hyphenation" + + return self.request("GET", endpoint, params=params) + + def fetch_similiar_words(self, word: str) -> list[dict[str, Any]]: + """ + Docs on the endpoint + https://developer.wordnik.com/docs#!/word/getRelatedWords + """ + + try: + limit = int(self.settings["results"]) + except ValueError: + opt = Option( + title="Error: Invalid Results Value Given.", + sub="The Results settings item must be a valid number.", + callback="open_settings_menu", + ) + raise PluginException(opt.title, [opt]) + + params = { + "limit": limit, + "useCanonical": self.settings["use_canonical"], + } + endpoint = f"/word.json/{quote_plus(word)}/relatedWords" + + return self.request("GET", endpoint, params=params) diff --git a/WordnikDictionary/options.py b/WordnikDictionary/options.py new file mode 100644 index 0000000..f180768 --- /dev/null +++ b/WordnikDictionary/options.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +__all__ = ("Option",) + + +class Option: + def __init__( + self, + *, + title: str, + sub: str = "", + callback: str | None = None, + params: list[Any] = [], + context_data: list[Option] = [], + hide_after_callback: bool = True, + score: int = 0, + icon: str = "app", + ): + self.title = title + self.icon_name = icon + self.sub = sub + self.callback = callback + self.params = params + self.score = score + self.context_data = context_data + self.hide_after_callback = hide_after_callback + + @property + def icon(self) -> str: + return f"Images/{self.icon_name}.png" + + @icon.setter + def icon(self, name: str) -> None: + self.icon_name = name + + def to_jsonrpc(self) -> dict: + data: dict[str, Any] = { + "Title": self.title, + "SubTitle": self.sub, + "IcoPath": self.icon, + "ContextData": [opt.to_jsonrpc() for opt in self.context_data], + "score": self.score, + } + if self.callback: + data["JsonRPCAction"] = { + "method": self.callback, + "parameters": self.params, + "dontHideAfterAction": not self.hide_after_callback, + } + return data + + @classmethod + def url(cls: type[Option], name: str, url: str) -> Option: + return Option(title=f"Open {name}", sub=url, callback="open_url", params=[url]) + + @classmethod + def wnf(cls: type[Option]) -> Option: + return cls(title="Word not found", icon="error") diff --git a/WordnikDictionary/utils.py b/WordnikDictionary/utils.py new file mode 100644 index 0000000..890f8f1 --- /dev/null +++ b/WordnikDictionary/utils.py @@ -0,0 +1,57 @@ +import logging +import logging.handlers +from typing import Any, Callable + +from .dataclass import Dataclass +from .errors import BaseException, InternalException +from .options import Option + +__all__ = ("handle_plugin_exception", "convert_options", "setup_logging") + + +def handle_plugin_exception(func: Callable[..., Any]) -> Callable[..., Any]: + def inner(*args, **kwargs): + try: + return func(*args, **kwargs) + except BaseException as e: + return e.options + except Exception as e: + return InternalException().options + + return inner + + +def convert_options(func: Callable[..., Any]) -> Callable[..., Any]: + def inner(*args, **kwargs): + try: + options = func(*args, **kwargs) + except BaseException as e: + options = e.options + except Exception as e: + options = InternalException().options + final = [] + for opt in options: + if isinstance(opt, Dataclass): + opt = opt.to_option() + if isinstance(opt, Option): + final.append(opt.to_jsonrpc()) + else: + raise RuntimeError(f"Unknown option returned: {opt!r}") + return final + + return inner + + +def setup_logging() -> None: + level = logging.DEBUG + handler = logging.handlers.RotatingFileHandler("wordnik.logs", maxBytes=1000000) + + dt_fmt = "%Y-%m-%d %H:%M:%S" + formatter = logging.Formatter( + "[{asctime}] [{levelname:<8}] {name}: {message}", dt_fmt, style="{" + ) + + logger = logging.getLogger() + handler.setFormatter(formatter) + logger.setLevel(level) + logger.addHandler(handler) diff --git a/WordnikDictionary/word_relationship.py b/WordnikDictionary/word_relationship.py new file mode 100644 index 0000000..37d8f9d --- /dev/null +++ b/WordnikDictionary/word_relationship.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from .dataclass import Dataclass +from .options import Option + +__all__ = ("WordRelationship",) + + +class WordRelationship(Dataclass): + def __init__(self, word: str, type: str, words: list[str]) -> None: + self.word: str = word + self.type: str = type + self.words: list[str] = words + + @classmethod + def from_json( + cls: type[WordRelationship], word: str, data: dict + ) -> WordRelationship | None: + return cls(word, data["relationshipType"], data["words"]) + + def _generate_base_option(self) -> Option: + return Option( + title=self.type, + sub=", ".join(self.words[:5]), + callback="change_query", + params=[f"def {self.word}!rel-{self.type}"], + hide_after_callback=False, + ) + + def _generate_context_menu_options(self) -> list[Option]: + return [ + Option(title=f"Original Word: {self.word}"), + Option(title=f"Chosen Category: {self.type}"), + Option( + title="Go back and click on the category to see a full list of the words." + ), + ] + + def get_word_options(self) -> list[Option]: + return [ + Option( + title=word, + callback="change_query", + params=[f"def {word}"], + hide_after_callback=False, + context_data=[ + Option(title=f"Chosen Word: {word}"), + Option(title=f"Go back and click on the word to see definitions."), + ], + ) + for word in self.words + ] diff --git a/install_packages.bat b/install_packages.bat new file mode 100644 index 0000000..43cb350 --- /dev/null +++ b/install_packages.bat @@ -0,0 +1,3 @@ +pip install --upgrade pip +pip install --upgrade -U -r ./requirements.txt -t ./lib +pip install --upgrade -U -r ./requirements-dev.txt \ No newline at end of file diff --git a/main.py b/main.py index 74abe99..64453c9 100644 --- a/main.py +++ b/main.py @@ -1,176 +1,14 @@ -import sys, os +import os +import sys parent_folder_path = os.path.abspath(os.path.dirname(__file__)) sys.path.append(parent_folder_path) sys.path.append(os.path.join(parent_folder_path, "lib")) sys.path.append(os.path.join(parent_folder_path, "plugin")) -from flowlauncher import FlowLauncher, FlowLauncherAPI -import requests, webbrowser -from urllib.parse import quote_plus -from typing import Any -import json -from html_stripper import strip_tags - - -ICO_PATH = "Images/app.png" - -bad_response = [ - { - "citations": [], - "exampleUses": [], - "labels": [], - "notes": [], - "relatedWords": [], - "textProns": [], - }, - { - "citations": [], - "exampleUses": [], - "labels": [], - "notes": [], - "relatedWords": [], - "textProns": [], - }, - { - "citations": [], - "exampleUses": [], - "labels": [], - "notes": [], - "relatedWords": [], - "textProns": [], - }, - { - "citations": [], - "exampleUses": [], - "labels": [], - "notes": [], - "relatedWords": [], - "textProns": [], - }, -] - - -class WordnikDictionaryPlugin(FlowLauncher): - @property - def settings(self) -> dict: - return self.rpc_request["settings"] - - @property - def debug(self) -> bool: - try: - return self.settings["debug_mode"] - except TypeError: - return True - - def generate_json( - self, - *, - title: str, - sub: str = "", - callback: str = "", - params: list[str] = [], - context_data: list[Any] = [], - ) -> dict: - data: dict[str, Any] = { - "Title": title, - "SubTitle": sub, - "IcoPath": "Images/app.png", - "ContextData": context_data, - } - if callback: - data["JsonRPCAction"] = {"method": callback, "parameters": params} - return data - - def query(self, query: str): - """ - Docs on the endpoint - https://developer.wordnik.com/docs#!/word/getDefinitions - """ - - if self.debug: - with open("rpc_data.debug.json", "w") as f: - json.dump(self.rpc_request, f, indent=4) - - if not query: - return [self.generate_json(title="Invalid Word Given")] - - url = f"https://api.wordnik.com/v4/word.json/{quote_plus(query)}/definitions" - - try: - limit = int(self.settings["results"]) - except ValueError: - return [ - self.generate_json( - title="Error: Invalid Results Value Given.", - sub="The Results settings item must be a valid number.", - callback="open_settings_menu", - ) - ] - - params = { - "limit": limit, - "includeRelated": False, - "useCanonical": self.settings["use_canonical"], - "includeTags": False, - "api_key": self.settings["api_key"], - } - headers = {"Accept": "application/json"} - res = requests.get(url, params=params, headers=headers) - if res.status_code == 401: - return [self.generate_json(title="Error: Invalid API Key", sub="Click ENTER for instructions on how to get a valid API key", callback="open_url", params=['https://github.com/cibere/Flow.Launcher.Plugin.WordNikDictionary?tab=readme-ov-file#get-an-api-key'])] - res.raise_for_status() - data = res.json() - - if data == bad_response: - return [self.generate_json(title="No Definition was found")] - - if self.debug: - with open("web_request_response.debug.json", "w") as f: - json.dump(data, f, indent=4) - - final = [] - - for definition in data: - final.append( - self.generate_json( - title=strip_tags(definition["text"]), - sub=definition["attributionText"], - callback="open_url", - params=[definition["wordnikUrl"]], - context_data=[ - self.generate_json( - title="Open Wordnik URL", - sub=definition["wordnikUrl"], - callback="open_url", - params=[definition["wordnikUrl"]], - ), - self.generate_json( - title="Open Attribution Website", - sub=definition["attributionUrl"], - callback="open_url", - params=[definition["attributionUrl"]], - ), - ], - ) - ) - - return final - - def context_menu(self, data: list[Any]): - if self.debug: - with open("rpc_data.debug.json", "w") as f: - json.dump(self.rpc_request, f, indent=4) - with open("context_menu_data.debug.json", "w") as f: - json.dump(data, f, indent=4) - return data - - def open_url(self, url): - webbrowser.open(url) - - def open_settings_menu(self): - FlowLauncherAPI.open_setting_dialog() - +from WordnikDictionary.core import WordnikDictionaryPlugin +from WordnikDictionary.utils import setup_logging if __name__ == "__main__": + setup_logging() WordnikDictionaryPlugin() diff --git a/plugin.json b/plugin.json index 50c19cf..60bb4b0 100644 --- a/plugin.json +++ b/plugin.json @@ -4,7 +4,7 @@ "Name": "Wordnik Dictionary", "Description": "Wordnik wrapper using python for Flow Launcher. Gives you the definition of words similiar to duckduckgo.", "Author": "cibere", - "Version": "1.0.0", + "Version": "2.0.0", "Language": "python", "Website": "https://github.com/cibere/Flow.Launcher.Plugin.WordNikDictionary", "IcoPath": "Images\\app.png", diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..21ca47e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +flowlauncher +requests +black +isort \ No newline at end of file diff --git a/run_formatters.bat b/run_formatters.bat new file mode 100644 index 0000000..5f3a83c --- /dev/null +++ b/run_formatters.bat @@ -0,0 +1,3 @@ +@echo OFF +isort . +black . \ No newline at end of file