├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── BrowserBookmarks.py ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── images ├── brave.png ├── chrome.png ├── chromium.png ├── demo │ ├── ulauncher-browser-bookmarks-1.png │ ├── ulauncher-browser-bookmarks-2.png │ └── ulauncher-browser-bookmarks.mp4 └── vivaldi.png ├── main.py ├── manifest.json ├── querier ├── BookmarkQuerier.py └── __init__.py ├── requirements.txt ├── run_tests.py ├── tests └── test_bookmark_querier.py ├── versions.json └── watch-and-deploy.sh /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: The continuous integration workflow 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [ main ] 7 | push: 8 | branches: [ main ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | - name: Setup Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.8 20 | - name: Install dependencies 21 | run: pip install -r requirements.txt 22 | - name: Run formatting checks 23 | run: ruff format 24 | - name: Run linting checks 25 | run: ruff check 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | #PyCharm 104 | .idea 105 | -------------------------------------------------------------------------------- /BrowserBookmarks.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from typing import List, Tuple, Dict, Union 5 | from querier import BookmarkQuerier 6 | 7 | from ulauncher.api.client.EventListener import EventListener 8 | from ulauncher.api.client.Extension import Extension 9 | from ulauncher.api.shared.action.OpenUrlAction import OpenUrlAction 10 | from ulauncher.api.shared.action.RenderResultListAction import RenderResultListAction 11 | from ulauncher.api.shared.event import ( 12 | KeywordQueryEvent, 13 | PreferencesEvent, 14 | PreferencesUpdateEvent, 15 | ) 16 | from ulauncher.api.shared.item.ExtensionResultItem import ExtensionResultItem 17 | 18 | # Swap the two logging configs to enable/disable logging to file debug.log in this directory 19 | # logging.basicConfig( 20 | # filename=os.path.join(os.path.dirname(os.path.realpath(__file__)), "debug.log"), 21 | # level=logging.DEBUG, 22 | # ) 23 | logging.basicConfig() 24 | logger = logging.getLogger(__name__) 25 | 26 | support_browsers = [ 27 | "google-chrome", 28 | "chromium", 29 | "Brave-Browser", 30 | "BraveSoftware", 31 | "vivaldi", 32 | ] 33 | 34 | browser_imgs = { 35 | "google-chrome": "images/chrome.png", 36 | "chromium": "images/chromium.png", 37 | "Brave-Browser": "images/brave.png", 38 | "BraveSoftware": "images/brave.png", 39 | "vivaldi": "images/vivaldi.png", 40 | "custom_path": "images/chromium.png", 41 | } 42 | 43 | 44 | class PreferencesEventListener(EventListener): 45 | def on_event( 46 | self, 47 | event: Union[PreferencesEvent, PreferencesUpdateEvent], 48 | extension: "BrowserBookmarks", 49 | ) -> None: 50 | """ 51 | Listens for preference events and updates the extension preferences. Then updates the bookmarks paths. 52 | 53 | Parameters: 54 | event (Union[PreferencesEvent, PreferencesUpdateEvent]): The event to listen for 55 | extension (BrowserBookmarks): The extension to update 56 | """ 57 | if isinstance(event, PreferencesUpdateEvent): 58 | if event.id == "keyword": 59 | return 60 | extension.preferences[event.id] = event.new_value 61 | elif isinstance(event, PreferencesEvent): 62 | assert isinstance(event.preferences, dict) 63 | extension.preferences = event.preferences 64 | # Could be optimized so it only refreshes the custom paths 65 | extension.bookmarks_paths = extension.find_bookmarks_paths() 66 | 67 | 68 | class KeywordQueryEventListener(EventListener): 69 | def on_event( # type: ignore 70 | self, event: KeywordQueryEvent, extension: "BrowserBookmarks" 71 | ) -> RenderResultListAction: 72 | items = extension.get_items(event.get_argument()) 73 | return RenderResultListAction(items) 74 | 75 | 76 | class BrowserBookmarks(Extension): 77 | max_matches_len = 10 78 | bookmarks_paths: List[Tuple[str, str]] 79 | 80 | def __init__(self): 81 | super(BrowserBookmarks, self).__init__() 82 | 83 | # Subscribe to preference events 84 | self.subscribe(PreferencesEvent, PreferencesEventListener()) 85 | self.subscribe(PreferencesUpdateEvent, PreferencesEventListener()) 86 | 87 | # Subscribe to keyword query events 88 | self.subscribe(KeywordQueryEvent, KeywordQueryEventListener()) 89 | 90 | def find_bookmarks_paths(self) -> List[Tuple[str, str]]: 91 | """ 92 | Searches for bookmarks by supported browsers and custom paths 93 | 94 | Returns: 95 | List[Tuple[str, str]]: A list of tuples containing the path to the bookmarks and the browser name 96 | """ 97 | found_bookmarks: List[Tuple[str, str]] = [] 98 | additional_browser_paths = self.preferences["additional_browser_paths"] 99 | 100 | for browser in support_browsers: 101 | potential_bookmark_paths = [ 102 | "$HOME/.config/%s" % browser, 103 | "$HOME/snap/%s/current/.config/%s" % (browser, browser), 104 | ] 105 | 106 | found_bookmarks.extend( 107 | BrowserBookmarks.collect_bookmarks_paths( 108 | potential_bookmark_paths, browser 109 | ) 110 | ) 111 | 112 | if additional_browser_paths: 113 | custom_paths: List[str] = list(additional_browser_paths.split(":")) 114 | logger.info( 115 | "Custom browser paths found, searching through: %s" % custom_paths 116 | ) 117 | found_bookmarks.extend( 118 | BrowserBookmarks.collect_bookmarks_paths(custom_paths, "custom_path") 119 | ) 120 | 121 | if len(found_bookmarks) == 0: 122 | logger.exception("No Bookmarks were found") 123 | 124 | return found_bookmarks 125 | 126 | @staticmethod 127 | def collect_bookmarks_paths(dirs: List[str], browser: str) -> List[Tuple[str, str]]: 128 | """ 129 | Collects the paths to the bookmarks of the browser 130 | 131 | Parameters: 132 | dirs (List[str]): The directories to search in 133 | browser (str): The browser name (used to match the icon) 134 | 135 | Returns: 136 | List[Tuple[str, str]]: A list of tuples containing the path to the bookmarks and the browser name 137 | """ 138 | grep_results: List[str] = [] 139 | 140 | for command in dirs: 141 | f = os.popen("find %s | grep Bookmarks" % (command)) 142 | grep_results.extend(f.read().split("\n")) 143 | f.close() 144 | 145 | if len(grep_results) == 0: 146 | logger.info("Path to the %s Bookmarks was not found" % browser) 147 | return [] 148 | 149 | bookmarks_paths: List[Tuple[str, str]] = [] 150 | for one_path in grep_results: 151 | if one_path.endswith("Bookmarks"): 152 | bookmarks_paths.append((one_path, browser)) 153 | 154 | return bookmarks_paths 155 | 156 | def get_items(self, query: Union[str, None]) -> List[ExtensionResultItem]: 157 | """ 158 | Returns a list of ExtensionResultItems for the query, which is rendered by Ulauncher 159 | 160 | Parameters: 161 | query (Union[str, None]): The query being searched 162 | 163 | Returns: 164 | List[ExtensionResultItem]: A list of ExtensionResultItems to be rendered 165 | """ 166 | items: List[ExtensionResultItem] = [] 167 | 168 | if query is None: 169 | query = "" 170 | 171 | logger.debug("Finding bookmark entries for query %s" % query) 172 | 173 | querier = BookmarkQuerier( 174 | filter_by_folders=self.preferences["filter_by_folders"], 175 | ) 176 | 177 | for bookmarks_path, browser in self.bookmarks_paths: 178 | matches: List[Dict[str, str | Dict[str, str]]] = [] 179 | 180 | with open(bookmarks_path) as data_file: 181 | data = json.load(data_file) 182 | querier.search(data["roots"]["bookmark_bar"], query, matches) 183 | querier.search(data["roots"]["synced"], query, matches) 184 | querier.search(data["roots"]["other"], query, matches) 185 | 186 | for bookmark in matches: 187 | bookmark_name: bytes = str(bookmark["name"]).encode("utf-8") 188 | bookmark_url: bytes = str(bookmark["url"]).encode("utf-8") 189 | item = ExtensionResultItem( 190 | icon=browser_imgs.get(browser), 191 | name=str(bookmark_name.decode("utf-8")), 192 | description=str(bookmark_url.decode("utf-8")), 193 | on_enter=OpenUrlAction(bookmark_url.decode("utf-8")), 194 | ) 195 | items.append(item) 196 | 197 | return items 198 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to this repository 2 | 3 | First of all: Thanks for considering a contribution. Every kind of contribution is welcome. I try to respond to them as quickly as possible. Expect a response in average after 2 days or latest after 1 week (if not on vacation). 4 | 5 | ## You have a feature idea 6 | 7 | If you have new feature ideas, please check out existing issues first, if an issue exists already. Feel free to thumb up the issue, if you agree or comment for more information. If no issue exists, please open one and explain what feature you would want and why. I'll get to that as soon as possible. Please create a feature issue first before opening a pull request, so that we can ensure, it fits into this repository. It would be sad to waste your time, if for some reason, I do not see the feature as being part of this repo. Please prefix feature idea issue with "Feature:" 8 | 9 | ## You found a bug 10 | 11 | Please create an issue if you found a bug and this bug does not exist yet in the issues. Feel free to also provide a pull request, if you want and can fix it yourself. Please prefix bug issues with "Bug:" 12 | 13 | ## You have a question 14 | 15 | If you have any questions about the repo, please open an issue with the prefix "Question:". This can then be a sign, that I should update the documentation. 16 | 17 | ## You want to contribute with a pull request 18 | 19 | Pull requests are welcome. Please make sure, that an issue exists first. Please comment in the issues, if you are planning to provide a pull request for that. For features, as already mentioned, it makes sense to agree on adding them first before putting in work. Please reference the issue ID in the branch name for the pull request. And please use [Conventional commit messages](https://www.conventionalcommits.org/en/v1.0.0/). You can omit the "scope". 20 | 21 | Thank you so much for helping to make this tool better 👏👏 22 | ~ Pascal 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Pascal Betting 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: ## Lists the available commands. Add a comment with '##' to describe a command. 3 | @grep -E '^[a-zA-Z_-].+:.*?## .*$$' $(MAKEFILE_LIST)\ 4 | | sort\ 5 | | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | test: ## Run all tests 8 | python -m unittest discover -s tests 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ulauncher-browser-bookmarks 2 | 3 | > [ulauncher](https://ulauncher.io/) Extension to quickly open browser bookmarks. 4 | 5 | ❗ This extension is heavily based on [this extension](https://github.com/nortmas/chrome-bookmarks). It's published as separate extension, because the original extension is hardly maintained anymore. Special thanks to Dmitry Antonenko for developing the original extension 👏 6 | 7 | ## Demo 8 | 9 | https://github.com/pascalbe-dev/ulauncher-browser-bookmarks/assets/26909176/c39d9610-fe8d-4e1f-89e5-cff483bd1992 10 | 11 | ## Features 12 | 13 | - search and open browser bookmarks 14 | - search by single text (must be contained in the bookmark title) 15 | - search by multiple texts split by space (all must be contained in the bookmark title) 16 | - supports multiple browser profiles 17 | - supports multiple browsers 18 | - Google Chrome 19 | - Chromium 20 | - Brave 21 | - Vivaldi 22 | - other chromium based browsers by specifying the browser config path in the extension settings 23 | 24 | ## Requirements 25 | 26 | - [ulauncher 5](https://ulauncher.io/) 27 | - Python > 3 28 | 29 | ## Installation 30 | 31 | Open ulauncher preferences window -> extensions -> add extension and paste the following url: 32 | 33 | `https://github.com/pascalbe-dev/ulauncher-browser-bookmarks.git` 34 | 35 | ## Contribution 36 | 37 | Please refer to [the contribution guidelines](./CONTRIBUTING.md) 38 | 39 | ## Local development 40 | 41 | ### Requirements 42 | 43 | - `less` package installed 44 | - `inotify-tools` package installed 45 | 46 | ### Getting started 47 | 48 | 1. Clone the repo `git clone https://github.com/pascalbe-dev/ulauncher-browser-bookmarks.git` 49 | 2. Cd into the folder `cd ulauncher-browser-bookmarks` 50 | 51 | ### Quality assurance 52 | 53 | - install the ruff via `pip install -r requirements.txt` 54 | - run formatting via `ruff format` 55 | - run linting via `ruff check` 56 | 57 | ### Local testing 58 | 59 | 1. Watch and deploy your extension locally for simple developing and testing in parallel `./watch-and-deploy.sh` (this will restart ulauncher without extensions and deploy this extension at the beginning and each time a file in this directory changes) 60 | 2. Check the extension log `less /tmp/ulauncher-extension.log +F` 61 | 3. Check ulauncher dev log `less /tmp/ulauncher.log +F` 62 | -------------------------------------------------------------------------------- /images/brave.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/brave.png -------------------------------------------------------------------------------- /images/chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/chrome.png -------------------------------------------------------------------------------- /images/chromium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/chromium.png -------------------------------------------------------------------------------- /images/demo/ulauncher-browser-bookmarks-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/demo/ulauncher-browser-bookmarks-1.png -------------------------------------------------------------------------------- /images/demo/ulauncher-browser-bookmarks-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/demo/ulauncher-browser-bookmarks-2.png -------------------------------------------------------------------------------- /images/demo/ulauncher-browser-bookmarks.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/demo/ulauncher-browser-bookmarks.mp4 -------------------------------------------------------------------------------- /images/vivaldi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascalbe-dev/ulauncher-browser-bookmarks/4c5ccf5ff5186d92f3870adc8453f89151eaedb7/images/vivaldi.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from BrowserBookmarks import BrowserBookmarks 2 | 3 | if __name__ == "__main__": 4 | BrowserBookmarks().run() 5 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "required_api_version": "^2.0.0", 3 | "name": "Browser Bookmarks", 4 | "description": "Search and launch browser bookmarks", 5 | "developer_name": "Pascal Betting", 6 | "icon": "images/chromium.png", 7 | "options": { 8 | "query_debounce": 0.1 9 | }, 10 | "preferences": [ 11 | { 12 | "id": "keyword", 13 | "type": "keyword", 14 | "name": "Browser Bookmarks extension", 15 | "description": "The keyword to start the Browser Bookmarks extension", 16 | "default_value": "b" 17 | }, 18 | { 19 | "id": "additional_browser_paths", 20 | "type": "text", 21 | "name": "Additional browser paths", 22 | "description": "Additional paths to search for browser bookmarks, separated by a colon", 23 | "default_value": "" 24 | }, 25 | { 26 | "id": "filter_by_folders", 27 | "type": "select", 28 | "name": "Filter by folders", 29 | "description": "When enabled, takes into account the folder structure of bookmarks while filtering", 30 | "default_value": false, 31 | "options": [ 32 | {"text": "Disabled", "value": false}, 33 | {"text": "Enabled", "value": true} 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /querier/BookmarkQuerier.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Dict 2 | 3 | class BookmarkQuerier: 4 | def __init__(self, filter_by_folders: bool = False) -> None: 5 | """ 6 | Initializes the BookmarkQuerier class. 7 | Parameters: 8 | filter_by_folders (bool): Whether to filter by folders 9 | """ 10 | self.filter_by_folders = filter_by_folders 11 | self.max_matches_len = 10 12 | 13 | def search( 14 | self, bookmark_entry: Dict[str, Any], query: str, matches: List[Dict[str, Any]], 15 | parent_name: str = "" 16 | ) -> None: 17 | """ 18 | Recursively edits the matches variable with bookmark entries that match the query. 19 | Matches if query terms are found in either name, URL, or parent folder name. 20 | 21 | Parameters: 22 | bookmark_entry (Dict[str, Any]): The bookmark entry to search 23 | query (str): The query 24 | matches (List[Dict[str, Any]]): The list to append matches to 25 | parent_name (str, optional): The name of the parent folder 26 | """ 27 | if len(matches) >= self.max_matches_len: 28 | return 29 | 30 | if bookmark_entry["type"] == "folder": 31 | parent_tree = f"{parent_name} {bookmark_entry['name']}" 32 | for child_bookmark_entry in bookmark_entry["children"]: 33 | self.search(child_bookmark_entry, query, matches, parent_tree) 34 | else: 35 | sub_queries = query.split(" ") 36 | bookmark_title = bookmark_entry["name"] 37 | bookmark_url = bookmark_entry.get("url", "") 38 | 39 | # Create search text that includes parent folder name, bookmark name and URL 40 | search_text = f"{bookmark_title} {bookmark_url}" 41 | if parent_name and self.filter_by_folders: 42 | search_text = f"{parent_name} {search_text}" 43 | 44 | if not self.contains_all_substrings(search_text, sub_queries): 45 | return 46 | 47 | matches.append(bookmark_entry) 48 | 49 | def contains_all_substrings(self, text: str, substrings: List[str]) -> bool: 50 | """ 51 | Check if all substrings are in the text 52 | 53 | Parameters: 54 | text (str): The text to match against 55 | substrings (List[str]): The substrings to check 56 | 57 | Returns: 58 | bool: True if all substrings are in the text, False otherwise 59 | """ 60 | for substring in substrings: 61 | if substring.lower() not in text.lower(): 62 | return False 63 | return True 64 | -------------------------------------------------------------------------------- /querier/__init__.py: -------------------------------------------------------------------------------- 1 | from .BookmarkQuerier import BookmarkQuerier as BookmarkQuerier 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ruff 2 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | import os 4 | 5 | # Add the current directory to the Python path 6 | sys.path.append(os.path.abspath('.')) 7 | 8 | if __name__ == '__main__': 9 | # Discover and run all tests in the tests directory 10 | test_loader = unittest.TestLoader() 11 | test_suite = test_loader.discover('tests', pattern='test_*.py') 12 | 13 | runner = unittest.TextTestRunner(verbosity=2) 14 | result = runner.run(test_suite) 15 | 16 | # Exit with non-zero code if tests failed 17 | sys.exit(not result.wasSuccessful()) -------------------------------------------------------------------------------- /tests/test_bookmark_querier.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from querier import BookmarkQuerier 3 | 4 | bookmarks = { 5 | "type": "folder", 6 | "name": "root", 7 | "children": [ 8 | { 9 | "type": "bookmark", 10 | "name": "git tutorial", 11 | "url": "https://git.org" 12 | }, 13 | { 14 | "type": "folder", 15 | "name": "Python", 16 | "children": [ 17 | { 18 | "type": "bookmark", 19 | "name": "Documentation", 20 | "url": "https://docs.python.org" 21 | }, 22 | { 23 | "type": "bookmark", 24 | "name": "Python Tutorial", 25 | "url": "https://learn.python.org" 26 | }, 27 | { 28 | "type": "folder", 29 | "name": "Advanced", 30 | "children": [ 31 | { 32 | "type": "bookmark", 33 | "name": "Documentation and Tutorial - python", 34 | "url": "https://documentation.python.org" 35 | }, 36 | { 37 | "type": "bookmark", 38 | "name": "Python Tricks Tutorial", 39 | "url": "https://realpython.com" 40 | }, 41 | { 42 | "type": "bookmark", 43 | "name": "Zen of Python", 44 | "url": "https://peps.python.org/pep-0020/" 45 | } 46 | ] 47 | } 48 | ] 49 | }, 50 | { 51 | "type": "folder", 52 | "name": "Typescript", 53 | "children": [ 54 | { 55 | "type": "bookmark", 56 | "name": "Documentation", 57 | "url": "https://docs.typescript.org" 58 | }, 59 | { 60 | "type": "bookmark", 61 | "name": "Typescript Tutorial", 62 | "url": "https://learn.typescript.org" 63 | }, 64 | { 65 | "type": "folder", 66 | "name": "Advanced", 67 | "children": [ 68 | { 69 | "type": "bookmark", 70 | "name": "Documentation and Tutorial", 71 | "url": "https://documentation.typescript.org" 72 | }, 73 | { 74 | "type": "bookmark", 75 | "name": "Typescript Tricks Tutorial", 76 | "url": "https://typescript.org" 77 | }, 78 | { 79 | "type": "bookmark", 80 | "name": "Zen of Typescript", 81 | "url": "https://notfound.com" 82 | } 83 | ] 84 | } 85 | 86 | ] 87 | } 88 | ] 89 | } 90 | class TestBookmarkQuerierNoFilterByFolders(unittest.TestCase): 91 | def setUp(self): 92 | self.querier = BookmarkQuerier() 93 | 94 | def test_matches_any_bookmark_that_contains_query_words(self): 95 | matches = [] 96 | self.querier.search(bookmarks, "documentation", matches) 97 | self.assertEqual(len(matches), 4) 98 | self.assertEqual(matches[0]["name"], "Documentation") 99 | self.assertEqual(matches[0]["url"], "https://docs.python.org") 100 | self.assertEqual(matches[1]["name"], "Documentation and Tutorial - python") 101 | self.assertEqual(matches[1]["url"], "https://documentation.python.org") 102 | self.assertEqual(matches[2]["name"], "Documentation") 103 | self.assertEqual(matches[2]["url"], "https://docs.typescript.org") 104 | self.assertEqual(matches[3]["name"], "Documentation and Tutorial") 105 | self.assertEqual(matches[3]["url"], "https://documentation.typescript.org") 106 | 107 | matches = [] 108 | self.querier.search(bookmarks, "documentation tutorial", matches) 109 | self.assertEqual(len(matches), 2) 110 | self.assertEqual(matches[0]["name"], "Documentation and Tutorial - python") 111 | self.assertEqual(matches[0]["url"], "https://documentation.python.org") 112 | self.assertEqual(matches[1]["name"], "Documentation and Tutorial") 113 | self.assertEqual(matches[1]["url"], "https://documentation.typescript.org") 114 | 115 | matches = [] 116 | self.querier.search(bookmarks, "tutorial", matches) 117 | self.assertEqual(len(matches), 7) 118 | self.assertEqual(matches[0]["name"], "git tutorial") 119 | self.assertEqual(matches[1]["name"], "Python Tutorial") 120 | 121 | 122 | def test_doesnot_filter_by_folders(self): 123 | matches = [] 124 | self.querier.search(bookmarks, "advanced documentation", matches) 125 | self.assertEqual(len(matches), 0) 126 | 127 | matches = [] 128 | self.querier.search(bookmarks, "python advanced", matches) 129 | self.assertEqual(len(matches), 0) 130 | 131 | matches = [] 132 | self.querier.search(bookmarks, "advanced tutorial", matches) 133 | self.assertEqual(len(matches), 0) 134 | 135 | def test_query_max_matches(self): 136 | # Create a bookmark structure with many entries 137 | many_bookmarks = { 138 | "type": "folder", 139 | "name": "root", 140 | "children": [ 141 | { 142 | "type": "bookmark", 143 | "name": f"Test {i}", 144 | "url": f"https://test{i}.com" 145 | } for i in range(15) 146 | ] 147 | } 148 | 149 | matches = [] 150 | self.querier.search(many_bookmarks, "Test", matches) 151 | self.assertEqual(len(matches), 10) # Should stop at max_matches_len 152 | 153 | class TestBookmarkQuerierFilterByFolders(unittest.TestCase): 154 | def setUp(self): 155 | self.querier = BookmarkQuerier(filter_by_folders=True) 156 | 157 | def test_matches_any_bookmark_that_contains_query_words(self): 158 | matches = [] 159 | self.querier.search(bookmarks, "documentation", matches) 160 | self.assertEqual(len(matches), 4) 161 | self.assertEqual(matches[0]["name"], "Documentation") 162 | self.assertEqual(matches[0]["url"], "https://docs.python.org") 163 | self.assertEqual(matches[1]["name"], "Documentation and Tutorial - python") 164 | self.assertEqual(matches[1]["url"], "https://documentation.python.org") 165 | self.assertEqual(matches[2]["name"], "Documentation") 166 | self.assertEqual(matches[2]["url"], "https://docs.typescript.org") 167 | self.assertEqual(matches[3]["name"], "Documentation and Tutorial") 168 | self.assertEqual(matches[3]["url"], "https://documentation.typescript.org") 169 | 170 | matches = [] 171 | self.querier.search(bookmarks, "documentation tutorial", matches) 172 | self.assertEqual(len(matches), 2) 173 | self.assertEqual(matches[0]["name"], "Documentation and Tutorial - python") 174 | self.assertEqual(matches[0]["url"], "https://documentation.python.org") 175 | self.assertEqual(matches[1]["name"], "Documentation and Tutorial") 176 | self.assertEqual(matches[1]["url"], "https://documentation.typescript.org") 177 | 178 | matches = [] 179 | self.querier.search(bookmarks, "tutorial", matches) 180 | self.assertEqual(len(matches), 7) 181 | self.assertEqual(matches[0]["name"], "git tutorial") 182 | self.assertEqual(matches[1]["name"], "Python Tutorial") 183 | 184 | def test_query_max_matches(self): 185 | # Create a bookmark structure with many entries 186 | many_bookmarks = { 187 | "type": "folder", 188 | "name": "root", 189 | "children": [ 190 | { 191 | "type": "bookmark", 192 | "name": f"Test {i}", 193 | "url": f"https://test{i}.com" 194 | } for i in range(15) 195 | ] 196 | } 197 | 198 | matches = [] 199 | self.querier.search(many_bookmarks, "Test", matches) 200 | self.assertEqual(len(matches), 10) # Should stop at max_matches_len 201 | 202 | def test_search_matches_parent_folder(self): 203 | matches = [] 204 | self.querier.search(bookmarks, "python documentation", matches) 205 | self.assertEqual(len(matches), 2) 206 | self.assertEqual(matches[0]["name"], "Documentation") 207 | self.assertEqual(matches[0]["url"], "https://docs.python.org") 208 | self.assertEqual(matches[1]["name"], "Documentation and Tutorial - python") 209 | self.assertEqual(matches[1]["url"], "https://documentation.python.org") 210 | 211 | # Make sure to consider URL matches as well 212 | matches = [] 213 | self.querier.search(bookmarks, "python learn", matches) 214 | self.assertEqual(len(matches), 1) 215 | self.assertEqual(matches[0]["name"], "Python Tutorial") 216 | self.assertEqual(matches[0]["url"], "https://learn.python.org") 217 | 218 | def test_filter_by_parent_tree(self): 219 | # Sanity check 220 | matches = [] 221 | self.querier.search(bookmarks, "advanced", matches) 222 | self.assertEqual(len(matches), 6) 223 | 224 | matches = [] 225 | self.querier.search(bookmarks, "python advanced", matches) 226 | self.assertEqual(len(matches), 3) 227 | matches = [] 228 | self.querier.search(bookmarks, "typescript advanced", matches) 229 | self.assertEqual(len(matches), 3) 230 | 231 | def test_filter_by_children_and_grand_children(self): 232 | # Test that it matches when both parent and bookmark match 233 | # Should match: python/* && python/Advanced/* 234 | matches = [] 235 | self.querier.search(bookmarks, "python tutorial", matches) 236 | self.assertEqual(len(matches), 3) 237 | self.assertEqual(matches[0]["name"], "Python Tutorial") 238 | self.assertEqual(matches[0]["url"], "https://learn.python.org") 239 | self.assertEqual(matches[1]["name"], "Documentation and Tutorial - python") 240 | self.assertEqual(matches[1]["url"], "https://documentation.python.org") 241 | self.assertEqual(matches[2]["name"], "Python Tricks Tutorial") 242 | self.assertEqual(matches[2]["url"], "https://realpython.com") 243 | 244 | def test_filter_by_bookmark_name_and_parent_dont_match(self): 245 | # Test that it doesn't match when neither parent nor bookmark match 246 | matches = [] 247 | self.querier.search(bookmarks, "java", matches) 248 | self.assertEqual(len(matches), 0) 249 | self.querier.search(bookmarks, "java docs", matches) 250 | self.assertEqual(len(matches), 0) 251 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "required_api_version": "^2.0.0", 4 | "commit": "main" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /watch-and-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # exit on error 4 | set -e 5 | 6 | # TO NOTE: this is a workaround in vscode for this issue: https://github.com/ros2/ros2/issues/1406#issuecomment-1500898231 7 | unset GTK_PATH 8 | 9 | ln -sf $(pwd) ~/.local/share/ulauncher/extensions/ 10 | 11 | # restart ulauncher 12 | pkill ulauncher || true 13 | ulauncher --no-extensions --dev -v > /tmp/ulauncher.log 2>&1 & 14 | 15 | sleep 2 16 | 17 | # redeploy app when files change 18 | export VERBOSE=1 19 | export ULAUNCHER_WS_API=ws://127.0.0.1:5054/ulauncher-browser-bookmarks 20 | export PYTHONPATH=/usr/lib/python3/dist-packages 21 | 22 | startExtension() { 23 | /usr/bin/python3 $HOME/.local/share/ulauncher/extensions/ulauncher-browser-bookmarks/main.py >> /tmp/ulauncher-extension.log 2>&1 & 24 | pid=$(echo $!) 25 | } 26 | 27 | startExtension 28 | 29 | echo "Waiting for file changes ..." 30 | while inotifywait -qqre modify --exclude 'debug.log' "$(pwd)/"; do 31 | echo "Files have changed..." 32 | echo "Killing extension process: $pid" 33 | kill $pid 34 | startExtension 35 | echo "Restarted extension. New PID: $pid" 36 | done 37 | --------------------------------------------------------------------------------