From 0353efb5a1b4a766a453828d2e0cd074d92eecbb Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Thu, 5 Mar 2026 12:04:27 +0100 Subject: [PATCH 01/10] refactor: Split Plugin from IndexQueryHandler --- __init__.py | 234 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 138 insertions(+), 96 deletions(-) diff --git a/__init__.py b/__init__.py index d62c39a..696398e 100644 --- a/__init__.py +++ b/__init__.py @@ -6,7 +6,7 @@ import threading from contextlib import contextmanager from pathlib import Path -from typing import List, Tuple +from typing import List, Tuple, Callable from albert import * @@ -151,12 +151,136 @@ def get_favicons_data(favicons_db: Path) -> dict[str, bytes]: return {} -class Plugin(PluginInstance, IndexQueryHandler): - def __init__(self): - PluginInstance.__init__(self) +class FirefoxQueryHandler(IndexQueryHandler): + """Handles fuzzy search over Firefox bookmarks and optionally history.""" + + def __init__(self, + profile_path: Path, + data_location: Path, + icon_factory: Callable[[], Icon], + index_history: bool = False, + ): + """ + :param profile_path: Path to the profile + :param data_location: Path to the recommended plugin data location to store icons + :param icon_factory: Callable that takes a profile path and returns + a bytes object + :param index_history: If true, history is also indexed + """ IndexQueryHandler.__init__(self) self.thread = None + self.profile_path = profile_path + self.icon_factory = icon_factory + self.index_history = index_history + self.plugin_data_location = data_location + + def id(self) -> str: + """ + Returns the extension identifier. + """ + return md_iid + + def name(self) -> str: + """ + Returns the pretty, human readable extension name. + """ + return md_name + + def description(self) -> str: + """ + Returns the brief extension description. + """ + return md_description + + def __del__(self): + if self.thread and self.thread.is_alive(): + self.thread.join() + + def defaultTrigger(self): + return "f " + + def updateIndexItems(self): + if self.thread and self.thread.is_alive(): + self.thread.join() + self.thread = threading.Thread(target=self._update_index_items_task) + self.thread.start() + + def _update_index_items_task(self): + places_db = self.profile_path/ "places.sqlite" + favicons_db = self.profile_path / "favicons.sqlite" + + bookmarks = get_bookmarks(places_db) + info(f"Found {len(bookmarks)} bookmarks") + + favicons_location = self.plugin_data_location / "favicons" + favicons_location.mkdir(exist_ok=True, parents=True) + + for f in favicons_location.glob("*"): + f.unlink() + + favicons = get_favicons_data(favicons_db) + + index_items = [] + seen_urls = set() + + for guid, title, url, url_hash in bookmarks: + if url in seen_urls: + continue + seen_urls.add(url) + + favicon_data = favicons.get(url_hash) + if favicon_data: + favicon_path = favicons_location / f"favicon_{guid}.png" + with open(favicon_path, "wb") as f: + f.write(favicon_data) + icon_factory = lambda p=favicon_path: Icon.composed( + self.icon_factory(), Icon.iconified(Icon.image(p)), 1.0, .7) + else: + icon_factory = lambda: Icon.composed( + self.icon_factory(), Icon.grapheme("🌐"), 1.0, .7) + + item = StandardItem( + id=guid, + text=title if title else url, + subtext=url, + icon_factory=icon_factory, + actions=[ + Action("open", "Open in Firefox", lambda u=url: openUrl(u)), + Action("copy", "Copy URL", lambda u=url: setClipboardText(u)), + ], + ) + index_items.append(IndexItem(item=item, string=f"{title} {url}".lower())) + + if self.index_history: + history = get_history(places_db) + info(f"Found {len(history)} history items") + for guid, title, url in history: + if url in seen_urls: + continue + seen_urls.add(url) + item = StandardItem( + id=guid, + text=title if title else url, + subtext=url, + icon_factory=lambda: Icon.composed( + self.icon_factory(), Icon.grapheme("🕘"), 1.0), + actions=[ + Action("open", "Open in Firefox", lambda u=url: openUrl(u)), + Action("copy", "Copy URL", lambda u=url: setClipboardText(u)), + ], + ) + index_items.append(IndexItem(item=item, string=f"{title} {url}".lower())) + + self.setIndexItems(index_items) + + +class Plugin(PluginInstance): + """Owns shared Firefox state and configuration.""" + + def __init__(self): + PluginInstance.__init__(self) + # Get the Firefox root directory match platform.system(): case "Darwin": @@ -186,15 +310,16 @@ def __init__(self): self._index_history = False self.writeConfig("index_history", self._index_history) - def __del__(self): - if self.thread and self.thread.is_alive(): - self.thread.join() + self.handler = FirefoxQueryHandler( + profile_path=self.firefox_data_dir / self.current_profile_path, + data_location=Path(self.dataLocation()), + icon_factory=self.firefox_icon_factory, + index_history=self._index_history, + ) + self.handler.updateIndexItems() def extensions(self): - return [self] - - def defaultTrigger(self): - return "f " + return [self.handler] @property def current_profile_path(self): @@ -204,7 +329,7 @@ def current_profile_path(self): def current_profile_path(self, value): self._current_profile_path = value self.writeConfig("current_profile_path", value) - self.updateIndexItems() + self.handler.updateIndexItems() @property def index_history(self): @@ -214,7 +339,7 @@ def index_history(self): def index_history(self, value): self._index_history = value self.writeConfig("index_history", value) - self.updateIndexItems() + self.handler.updateIndexItems() def configWidget(self): return [ @@ -236,86 +361,3 @@ def configWidget(self): }, }, ] - - def updateIndexItems(self): - if self.thread and self.thread.is_alive(): - self.thread.join() - self.thread = threading.Thread(target=self.update_index_items_task) - self.thread.start() - - def update_index_items_task(self): - places_db = self.firefox_data_dir / self.current_profile_path / "places.sqlite" - favicons_db = self.firefox_data_dir / self.current_profile_path / "favicons.sqlite" - - bookmarks = get_bookmarks(places_db) - info(f"Found {len(bookmarks)} bookmarks") - - # Create favicons directory if it doesn't exist - favicons_location = Path(self.dataLocation()) / "favicons" - favicons_location.mkdir(exist_ok=True, parents=True) - - # Drop existing favicons - for f in favicons_location.glob("*"): - f.unlink() - - favicons = get_favicons_data(favicons_db) - - index_items = [] - seen_urls = set() - - for guid, title, url, url_hash in bookmarks: - if url in seen_urls: - continue - seen_urls.add(url) - - # Search and store the favicon if it exists - favicon_data = favicons.get(url_hash) - if favicon_data: - favicon_path = favicons_location / f"favicon_{guid}.png" - with open(favicon_path, "wb") as f: - f.write(favicon_data) - icon_factory = lambda p=favicon_path: Icon.composed(self.firefox_icon_factory(), - Icon.iconified(Icon.image(p)), - 1.0, .7) - else: - icon_factory = lambda: Icon.composed(self.firefox_icon_factory(), - Icon.grapheme("🌐"), - 1.0, .7) - item = StandardItem( - id=guid, - text=title if title else url, - subtext=url, - icon_factory=icon_factory, - actions=[ - Action("open", "Open in Firefox", lambda u=url: openUrl(u)), - Action("copy", "Copy URL", lambda u=url: setClipboardText(u)), - ], - ) - - # Create searchable string for the bookmark - index_items.append(IndexItem(item=item, string=f"{title} {url}".lower())) - - if self._index_history: - history = get_history(places_db) - info(f"Found {len(history)} history items") - for guid, title, url in history: - if url in seen_urls: - continue - seen_urls.add(url) - item = StandardItem( - id=guid, - text=title if title else url, - subtext=url, - icon_factory=lambda: Icon.composed(self.firefox_icon_factory(), Icon.grapheme("🕘"), 1.0), - actions=[ - Action("open", "Open in Firefox", lambda u=url: openUrl(u)), - Action("copy", "Copy URL", lambda u=url: setClipboardText(u)), - ], - ) - - # Create searchable string for the history item - index_items.append( - IndexItem(item=item, string=f"{title} {url}".lower()) - ) - - self.setIndexItems(index_items) From 9eb29441b9699a6329216546d8bf1a5aac13f24c Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Thu, 5 Mar 2026 14:15:45 +0100 Subject: [PATCH 02/10] feat: Add recent history handler for ordered history on fh --- __init__.py | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 696398e..094b8df 100644 --- a/__init__.py +++ b/__init__.py @@ -130,6 +130,50 @@ def get_history(places_db: Path) -> List[Tuple[str, str, str]]: return [] +def get_recent_history(places_db: Path, search: str = "", limit: int = 50) -> List[Tuple[str, str, str]]: + """Get history items ordered by most recently visited, optionally filtered by search term. + + :param places_db: Path to the places.sqlite database + :param search: Optional search string to filter by title or URL + :param limit: Maximum number of results to return + """ + try: + with get_connection(places_db) as conn: + cursor = conn.cursor() + + if search: + cursor.execute(""" + SELECT place.guid, place.title, place.url + FROM moz_places place + LEFT JOIN moz_bookmarks bookmark ON place.id = bookmark.fk + WHERE place.hidden = 0 + AND place.url IS NOT NULL + AND place.last_visit_date IS NOT NULL + AND bookmark.id IS NULL + AND (place.title LIKE :search OR place.url LIKE :search) + ORDER BY place.last_visit_date DESC + LIMIT :limit + """, {"search": f"%{search}%", "limit": limit}) + else: + cursor.execute(""" + SELECT place.guid, place.title, place.url + FROM moz_places place + LEFT JOIN moz_bookmarks bookmark ON place.id = bookmark.fk + WHERE place.hidden = 0 + AND place.url IS NOT NULL + AND place.last_visit_date IS NOT NULL + AND bookmark.id IS NULL + ORDER BY place.last_visit_date DESC + LIMIT :limit + """, {"limit": limit}) + + return cursor.fetchall() + + except sqlite3.Error as e: + critical(f"Failed to read Firefox recent history: {str(e)}") + return [] + + def get_favicons_data(favicons_db: Path) -> dict[str, bytes]: """Get all favicon data from the favicons database""" try: @@ -275,6 +319,49 @@ def _update_index_items_task(self): self.setIndexItems(index_items) +class FirefoxHistoryHandler(GeneratorQueryHandler): + """Yields Firefox history ordered by most recently visited.""" + + def __init__(self, profile_path: Path, icon_factory): + """ + :param profile_path: Path to the Firefox profile directory + :param icon_factory: Callable returning the Firefox icon + """ + GeneratorQueryHandler.__init__(self) + self.profile_path = profile_path + self.icon_factory = icon_factory + + def id(self) -> str: + return md_iid + "_history" + + def name(self) -> str: + return md_name + " History" + + def description(self) -> str: + return "Browse Firefox history ordered by most recently visited" + + def defaultTrigger(self): + return "fh " + + def items(self, context: QueryContext): + places_db = self.profile_path / "places.sqlite" + history = get_recent_history(places_db, search=context.query.strip()) + + yield [ + StandardItem( + id=guid, + text=title if title else url, + subtext=url, + icon_factory=lambda: Icon.composed(self.icon_factory(), Icon.grapheme("🕘"), 1.0), + actions=[ + Action("open", "Open in Firefox", lambda u=url: openUrl(u)), + Action("copy", "Copy URL", lambda u=url: setClipboardText(u)), + ], + ) + for guid, title, url in history + ] + + class Plugin(PluginInstance): """Owns shared Firefox state and configuration.""" @@ -318,8 +405,13 @@ def __init__(self): ) self.handler.updateIndexItems() + self.history_handler = FirefoxHistoryHandler( + profile_path=self.firefox_data_dir / self.current_profile_path, + icon_factory=self.firefox_icon_factory, + ) + def extensions(self): - return [self.handler] + return [self.handler, self.history_handler] @property def current_profile_path(self): @@ -360,4 +452,4 @@ def configWidget(self): "toolTip": "Enable or disable indexing of Firefox history" }, }, - ] + ] \ No newline at end of file From 00d2567fdc74d06ee66e972581b9d9e35b5468c5 Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Thu, 5 Mar 2026 14:18:45 +0100 Subject: [PATCH 03/10] docs: Add description for history search --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 018520e..98ca541 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Albert will present the entries in frequency order: the more you use a bookmark, 1. Enable the plugin in `Settings > Plugins` and tick `Firefox Bookmarks` 2. Configure the plugin by picking the Firefox profile to use and if you want to search in history 3. The default trigger is `f` ("f" for Firefox), so start typing `f ` in Albert to see your bookmarks and history +4. For a history-only chronological search use `fh` as trigger ("fh" for Firefox History). Your most recent visited page will show as the first entry. ## Alternatives From af21a5aceb8599c71d58e50cddee0c70f5c47dae Mon Sep 17 00:00:00 2001 From: ffpyt <38795588+ffpyt@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:59:20 +0100 Subject: [PATCH 04/10] fix: Propagate profile update Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- __init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/__init__.py b/__init__.py index 094b8df..7ae2405 100644 --- a/__init__.py +++ b/__init__.py @@ -421,6 +421,11 @@ def current_profile_path(self): def current_profile_path(self, value): self._current_profile_path = value self.writeConfig("current_profile_path", value) + + # Update handlers to point to the newly selected profile before reindexing + new_profile_path = self.firefox_data_dir / value + self.handler.profile_path = new_profile_path + self.history_handler.profile_path = new_profile_path self.handler.updateIndexItems() @property From c92a3620062944bcb0db3f12b8bcdc53534f050c Mon Sep 17 00:00:00 2001 From: ffpyt <38795588+ffpyt@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:59:44 +0100 Subject: [PATCH 05/10] fix: Propagate index history setting update Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- __init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/__init__.py b/__init__.py index 7ae2405..01b06e4 100644 --- a/__init__.py +++ b/__init__.py @@ -436,6 +436,8 @@ def index_history(self): def index_history(self, value): self._index_history = value self.writeConfig("index_history", value) + # Ensure the query handler uses the updated history indexing setting + self.handler.index_history = value self.handler.updateIndexItems() def configWidget(self): From e4a4dc8edec30e84eef069d6b9242320a2e0ef96 Mon Sep 17 00:00:00 2001 From: ffpyt <38795588+ffpyt@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:01:36 +0100 Subject: [PATCH 06/10] docs: Update docstring of icon_factory Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- __init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 01b06e4..006cc9a 100644 --- a/__init__.py +++ b/__init__.py @@ -207,8 +207,8 @@ def __init__(self, """ :param profile_path: Path to the profile :param data_location: Path to the recommended plugin data location to store icons - :param icon_factory: Callable that takes a profile path and returns - a bytes object + :param icon_factory: Callable with no arguments that returns an Icon + to be used for Firefox results :param index_history: If true, history is also indexed """ IndexQueryHandler.__init__(self) From 64e6819c84d803086fde72e4be58449734b82866 Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Mon, 9 Mar 2026 14:58:11 +0100 Subject: [PATCH 07/10] docs: Improve doc as suggested by copilot --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 98ca541..bdb04f8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Albert will present the entries in frequency order: the more you use a bookmark, 1. Enable the plugin in `Settings > Plugins` and tick `Firefox Bookmarks` 2. Configure the plugin by picking the Firefox profile to use and if you want to search in history 3. The default trigger is `f` ("f" for Firefox), so start typing `f ` in Albert to see your bookmarks and history -4. For a history-only chronological search use `fh` as trigger ("fh" for Firefox History). Your most recent visited page will show as the first entry. +4. For a history-only chronological search use `fh` ("fh" for Firefox History) to show the history list with your + most recent visited page will show as the first entry. ## Alternatives From aad82387d98ededb059b72dc2b1e9d86374cdb91 Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Mon, 9 Mar 2026 15:14:17 +0100 Subject: [PATCH 08/10] refactor: Avoid duplication in sql query --- __init__.py | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/__init__.py b/__init__.py index 006cc9a..ac76d1e 100644 --- a/__init__.py +++ b/__init__.py @@ -141,32 +141,26 @@ def get_recent_history(places_db: Path, search: str = "", limit: int = 50) -> Li with get_connection(places_db) as conn: cursor = conn.cursor() + search_clause = "1=1" + params: dict = {"limit": limit} if search: - cursor.execute(""" - SELECT place.guid, place.title, place.url - FROM moz_places place - LEFT JOIN moz_bookmarks bookmark ON place.id = bookmark.fk - WHERE place.hidden = 0 - AND place.url IS NOT NULL - AND place.last_visit_date IS NOT NULL - AND bookmark.id IS NULL - AND (place.title LIKE :search OR place.url LIKE :search) - ORDER BY place.last_visit_date DESC - LIMIT :limit - """, {"search": f"%{search}%", "limit": limit}) - else: - cursor.execute(""" - SELECT place.guid, place.title, place.url - FROM moz_places place - LEFT JOIN moz_bookmarks bookmark ON place.id = bookmark.fk - WHERE place.hidden = 0 - AND place.url IS NOT NULL - AND place.last_visit_date IS NOT NULL - AND bookmark.id IS NULL - ORDER BY place.last_visit_date DESC - LIMIT :limit - """, {"limit": limit}) + search_clause = "(place.title LIKE :search OR place.url LIKE :search)" + params["search"] = f"%{search}%" + + query = f""" + SELECT place.guid, place.title, place.url + FROM moz_places place + LEFT JOIN moz_bookmarks bookmark ON place.id = bookmark.fk + WHERE place.hidden = 0 + AND place.url IS NOT NULL + AND place.last_visit_date IS NOT NULL + AND bookmark.id IS NULL + AND {search_clause} + ORDER BY place.last_visit_date DESC + LIMIT :limit + """ + cursor.execute(query, params) return cursor.fetchall() except sqlite3.Error as e: From df62a1c7e8a3591d8d5e1f2302c3bc5b12cea800 Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Mon, 9 Mar 2026 21:24:51 +0100 Subject: [PATCH 09/10] fix: Do not use md_iid (API version) as id for QueryHandlers --- __init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index ac76d1e..76bface 100644 --- a/__init__.py +++ b/__init__.py @@ -217,7 +217,7 @@ def id(self) -> str: """ Returns the extension identifier. """ - return md_iid + return md_name def name(self) -> str: """ @@ -326,7 +326,7 @@ def __init__(self, profile_path: Path, icon_factory): self.icon_factory = icon_factory def id(self) -> str: - return md_iid + "_history" + return md_name + "_history" def name(self) -> str: return md_name + " History" From 28e9e21c06b079a2e9c68831938eb712d0ccd8a9 Mon Sep 17 00:00:00 2001 From: Frank Faha Date: Fri, 27 Mar 2026 15:16:53 +0100 Subject: [PATCH 10/10] fix: Duplicate scan for bookmarks and history in FirefoxHistoryHandler --- __init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/__init__.py b/__init__.py index 76bface..7108803 100644 --- a/__init__.py +++ b/__init__.py @@ -397,7 +397,6 @@ def __init__(self): icon_factory=self.firefox_icon_factory, index_history=self._index_history, ) - self.handler.updateIndexItems() self.history_handler = FirefoxHistoryHandler( profile_path=self.firefox_data_dir / self.current_profile_path,