diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..a402c35 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,11 @@ +version: 2 +jobs: + build: + docker: + - image: circleci/python:3.6.4 + steps: + - checkout + - run: sudo pip3 install pylint yapf + - run: make lint + + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3003e90 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ + +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +# Change these settings to your own preference +indent_style = space +indent_size = 4 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b9ac68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +__pycache__ +.cache +*.log +.vscode \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a846c3b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,17 @@ +[MESSAGES CONTROL] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=locally-disabled,too-few-public-methods diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4ddcd5f --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ + +EXT_NAME:=com.github.brpaz.ulauncher-docsearch +EXT_DIR:=$(shell pwd) + +.PHONY: help lint format link unlink deps dev +.DEFAULT_TARGET: help + +help: ## Show help menu + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +lint: ## Run Pylint + @find . -iname "*.py" | xargs pylint + +format: ## Format code using yapf + @yapf --in-place --recursive . + +link: ## Symlink the project source directory with Ulauncher extensions dir. + @ln -s ${EXT_DIR} ~/.cache/ulauncher_cache/extensions/${EXT_NAME} + +unlink: ## Unlink extension from Ulauncher + @rm -r ~/.cache/ulauncher_cache/extensions/${EXT_NAME} + +deps: ## Install Python Dependencies + @pip3 install -r requirements.txt + +dev: ## Runs ulauncher on development mode + ulauncher --no-extensions --dev -v diff --git a/README.md b/README.md new file mode 100644 index 0000000..e63af2c --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# ulauncher-docsearch + +[![Ulauncher Extension](https://img.shields.io/badge/Ulauncher-Extension-green.svg?style=for-the-badge)](https://ext.ulauncher.io/-/github-brpaz-ulauncher-docsearch) +[![CircleCI](https://img.shields.io/circleci/build/github/brpaz/ulauncher-docsearch.svg?style=for-the-badge)](https://circleci.com/gh/brpaz/ulauncher-docsearch) +![License](https://img.shields.io/github/license/brpaz/ulauncher-docsearch.svg?style=for-the-badge) + +![Algolia](images/search-by-algolia-light-background.svg) + +> Full text search on Documentation sites, powered by [Algolia](https://www.algolia.com/). + +**This is a work in progress project and its not ready to be used yet.** + +## Motivation + +Searching documentation is a constant need but not an easy task. Switch contexts between your code editor to the browser, remember the documentation site url and then find what you need. Many times, I kwew exactly what I want to find and that I have seen before, but simple cant find it again. + +This extension, aims to make documentation search less painfull, allowing you to a full text search on popular documentation sites directly from directly from ulauncher. + +## Features + +This extension allows to easily search on popular documentation websites, using [Algolia DocSearch](https://community.algolia.com/docsearch/). + + + +## Usage + +![demo](demo.gif) + +## Requirements + +* Ulauncher V5 +* Python 3 + * algoliasearch>=2.0,<3.0 (install with pip3) + +## Install + +Open ulauncher preferences window -> extensions -> add extension and paste the following url: + +``` +https://github.com/brpaz/ulauncher-docsearch +``` + +## Development + +``` +git clone https://github.com/brpaz/ulauncher-docsearch +make link +``` + +The `make link` command will symlink the cloned repo into the appropriate location on the ulauncher extensions folder. + +To see your changes, stop ulauncher and run it from the command line with: `ulauncher -v`. + +## Contributing + +All contributions are welcome. + +## Show your support + +Buy Me A Coffee + + +## License + +Copywright @ 2019 [Bruno Paz](https://github.com/brpaz) + +This project is [MIT](LLICENSE) Licensed. + diff --git a/data/docsets.json b/data/docsets.json new file mode 100644 index 0000000..2595901 --- /dev/null +++ b/data/docsets.json @@ -0,0 +1,10 @@ +{ + "react" : { + "name": "React", + "description": "A JavaScript library for building user interfaces", + "icon": "images/docs/react.png", + "algolia_index": "react", + "keyword": "reactdocs", + "url": "https://reactjs.org/docs/getting-started.html" + } +} diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..0858018 Binary files /dev/null and b/demo.gif differ diff --git a/docs.py b/docs.py new file mode 100644 index 0000000..c0f68a6 --- /dev/null +++ b/docs.py @@ -0,0 +1,92 @@ +""" +Module responsible for search into docs +""" + +import json +import os +from algoliasearch.search_client import SearchClient + +ALGOLIA_APPLICAITON_ID = 'BH4D9OD16A' +ALGOLIA_API_KEY = '36221914cce388c46d0420343e0bb32e' + +DEFAULT_DOC_IMAGE = 'images/icon.png' + +class DocSearch: + """ Searches Docs """ + + def __init__(self): + """ Class constructor """ + + self.algolia_client = SearchClient.create(ALGOLIA_APPLICAITON_ID, ALGOLIA_API_KEY) + self.docsets = {} + + with open(os.path.join(os.path.dirname(__file__), 'data', 'docsets.json'), 'r') as data: + self.docsets = json.load(data) + + def get_available_docs(self, filter_term): + """ Returns a list of available docs """ + docs = [] + for key, value in self.docsets.items(): + docs.append({ + 'key': key, + 'name': value['name'], + 'description': value['description'], + 'icon': value['icon'], + 'url': value['url'] + }) + + if filter_term: + docs = [x for x in docs if filter_term.lower() in x['name'].lower()] + + return docs + + def has_docset(self, key): + """ Checks if the specified docset exists """ + return key in self.docsets + + def get_docset(self, key): + """ Returns the details from a docset with the specified key passed as argument """ + + if key in self.docsets.keys(): + return self.docsets[key] + + return None + + def search(self, docset, term): + """ Searches a term on a specific docset and return the results """ + + docset = self.get_docset(docset) + + if not docset: + raise ValueError("The specified docset is not known") + + index = self.algolia_client.init_index(docset['algolia_index']) + search_results = index.search(term) + + if not search_results['hits']: + return [] + + items = [] + for hit in search_results['hits']: + title, description = self.parse_item_description(hit) + items.append({ + 'url': hit['url'], + 'title': title, + 'icon': docset['icon'], + 'category': description + }) + + return items + + def parse_item_description(self, hit): + """ Returns the text to display as result item title """ + hierarchy = hit['hierarchy'].values() + + # Filter the list by removing the empty values + res = [i for i in hierarchy if i] + + if len(res) < 2: + return res[0], "" + + # The last element found will be the description and the previous one the title. + return res[-1], ' -> '.join(res[:-1]) diff --git a/images/docs/react.png b/images/docs/react.png new file mode 100644 index 0000000..7dc4289 Binary files /dev/null and b/images/docs/react.png differ diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..9b12805 Binary files /dev/null and b/images/icon.png differ diff --git a/images/search-by-algolia-light-background.svg b/images/search-by-algolia-light-background.svg new file mode 100644 index 0000000..ab6ae5a --- /dev/null +++ b/images/search-by-algolia-light-background.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..119aa57 --- /dev/null +++ b/main.py @@ -0,0 +1,121 @@ +""" +Doc search extension +This extension provides full text search on popular documentation sites, powered by Algolia. +""" +import logging + +# pylint: disable=import-error +from ulauncher.api.client.Extension import Extension +from ulauncher.api.client.EventListener import EventListener +from ulauncher.api.shared.event import KeywordQueryEvent +from ulauncher.api.shared.item.ExtensionResultItem import ExtensionResultItem +from ulauncher.api.shared.action.RenderResultListAction import RenderResultListAction +from ulauncher.api.shared.action.OpenUrlAction import OpenUrlAction +from ulauncher.api.shared.action.HideWindowAction import HideWindowAction +from ulauncher.api.shared.action.SetUserQueryAction import SetUserQueryAction +from docs import DocSearch + +LOGGING = logging.getLogger(__name__) + +class DocsearchExtension(Extension): + """ Main Extension Class """ + + def __init__(self): + """ Extension constructor""" + super(DocsearchExtension, self).__init__() + self.subscribe(KeywordQueryEvent, KeywordQueryEventListener()) + self.searcher = DocSearch() + + def show_available_docs(self, event, filter_term=None): + """ Displays a list of available docs """ + docs = self.searcher.get_available_docs(filter_term) + + items = [] + + if not docs: + return RenderResultListAction([ + ExtensionResultItem( + icon='images/icon.png', + name='No results found', + on_enter=HideWindowAction() + ) + ]) + + for doc in docs[:8]: + items.append( + ExtensionResultItem( + icon=doc['icon'], + name=doc['name'], + description=doc['description'], + on_alt_enter=OpenUrlAction(doc['url']), + on_enter=SetUserQueryAction("%s %s > " % (event.get_keyword(), doc['key'])) + ) + ) + + return RenderResultListAction(items) + +class KeywordQueryEventListener(EventListener): + """ Listener that handles the user input """ + + # pylint: disable=unused-argument,no-self-use + def on_event(self, event, extension): + """ Handles the event """ + + arg = event.get_argument() or "" + + args_array = arg.split(">") + if len(args_array) != 2: + return extension.show_available_docs(event, arg) + + try: + + docset = args_array[0].strip() + + search_term = args_array[1] + + if len(search_term.strip()) < 3: + return RenderResultListAction([ + ExtensionResultItem( + icon='images/icon.png', + name='Please type a minimum of 3 characters', + description='Searching ...', + on_enter=HideWindowAction() + ) + ]) + + result = extension.searcher.search(docset, search_term) + + items = [] + + if not result: + return RenderResultListAction([ + ExtensionResultItem( + icon='images/icon.png', + name='No results matching your criteria', + on_enter=HideWindowAction() + )] + ) + + for i in result[:8]: + items.append( + ExtensionResultItem(icon=i['icon'], + name=i['title'], + description=i['category'], + highlightable=False, + on_enter=OpenUrlAction(i['url']))) + + return RenderResultListAction(items) + + except Exception as err: + LOGGING.error(err) + return RenderResultListAction([ + ExtensionResultItem( + icon="images/icon.png", + name='An error ocurred when searching documentation', + description='err', + on_enter=HideWindowAction() + ) + ]) + +if __name__ == '__main__': + DocsearchExtension().run() diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6a37769 --- /dev/null +++ b/manifest.json @@ -0,0 +1,17 @@ +{ + "required_api_version": "^2.0.0", + "name": "DocSearch", + "description": "Full text search on Documentation sites, powered by Algolia", + "developer_name": "Bruno Paz", + "icon": "images/icon.png", + "options": { + "query_debounce": 0.1 + }, + "preferences": [{ + "id": "kw", + "type": "keyword", + "name": "Documenation Search", + "default_value": "docs" + } + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..93db6e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +algoliasearch>=2.0,<3.0 diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..e7ff41c --- /dev/null +++ b/versions.json @@ -0,0 +1,6 @@ +[ + { + "required_api_version": "^2.0.0", + "commit": "master" + } +]