diff --git a/.github/workflows/pr-packager.yml b/.github/workflows/pr-packager.yml index 921a2c2..d968a10 100644 --- a/.github/workflows/pr-packager.yml +++ b/.github/workflows/pr-packager.yml @@ -10,12 +10,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ env.PYTHON_VER }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VER }} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~\AppData\Local\pip\Cache key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} @@ -27,7 +27,7 @@ jobs: pip install wheel pip install -r ./requirements.txt -t ./lib - name: Upload - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: artifact path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9904519..1bb01a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,14 +18,14 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ env.PYTHON_VER }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VER }} - - uses: actions/cache@v2 + - uses: actions/cache@v4 if: startsWith(runner.os, 'Windows') with: path: ~\AppData\Local\pip\Cache diff --git a/.github/workflows/test-plugin.yml b/.github/workflows/test-plugin.yml index ec00b73..38ceccd 100644 --- a/.github/workflows/test-plugin.yml +++ b/.github/workflows/test-plugin.yml @@ -22,7 +22,7 @@ jobs: python_ver: ['3.8'] steps: - name: Checkout Plugin Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: path: ${{github.event.repository.name}} - name: Get Plugin's version @@ -66,7 +66,7 @@ jobs: echo "FILE_NAME=$file_name" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf-8 -Append echo "TAG_NAME=$tag_name" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf-8 -Append - name: Flow Launcher Cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: flow_cache with: path: | @@ -95,10 +95,10 @@ jobs: New-Item -ItemType SymbolicLink -Path $plugin_path -Target $repo_path echo "PLUGIN_PATH=$plugin_path" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf-8 -Append - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python_ver }} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~\AppData\Local\pip\Cache key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6580fdb --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# ๐ŸŒŸ Browser History Plugin for Flow Launcher + +The **Browser History Plugin** for Flow Launcher allows you to quickly search and access your browsing history across multiple browsers. Whether you're looking for a specific webpage or want to revisit recent sites, this plugin makes it easy to find what you need with just a few keystrokes. + +## ๐Ÿ“‹ Features + +- **Multi-Browser Support**: Access history from Chrome, Firefox, Edge, Brave, Opera, Vivaldi, Arc, Zen, Floorp, Thorium, Waterfox, and more. +- **Custom Profiles**: Add custom Chromium-based or Firefox-based browser profiles for personalized history retrieval. +- **Combined History**: Combine and sort history entries from all supported browsers into a single list. +- **Profile Selection**: Automatically select the most recently updated profile or manually specify one. +- **Domain Blocking**: Exclude specific domains from search results to filter out unwanted websites. + +## ๐Ÿš€ Installation + +### Step 1: Install Flow Launcher +Make sure you have [Flow Launcher](https://www.flowlauncher.com/) installed on your system. If not, download and install it from the official website. + +### Step 2: Install the Plugin +1. Open Flow Launcher and go to **Settings > Plugin Store**. +2. Search for "Browser History" in the store. +3. Click **Install** to add the plugin to your Flow Launcher setup. + +Alternatively, you can manually install the plugin: +1. Download the latest release. +2. Extract the files and place the plugin folder in your Flow Launcher plugins directory: `%AppData%\FlowLauncher\Plugins`. +3. Restart Flow Launcher to load the new plugin. + +### Step 3: Configure Settings +The defaults are okay for 99% of users. But if you want to change anything, just go to Flow Launcher plugin settings. + +## โš™๏ธ Configuration + +The plugin provides several settings to customize its behavior: + +| Setting Name | Description | Default Value | +|:----------------------------------|:-----------------------------------------------------------------------------------------------|:---------------------:| +| **Choose a Browser or Profile** | Select the browser or profile to use by default. Choose **Chromium Profile** or **Firefox Profile** to target a specific profile directory. | `Chrome` | +| **Select the Most Recently Used Profile** | When enabled, the plugin selects the most recently updated profile for browsers that support multiple profiles. | `true` | +| **Path to Profile Folder** | Required ONLY if you select **Chromium Profile** or **Firefox Profile**. Must be a DIRECTORY (profile folder), not the History / places.sqlite file. | `N/A` | +| **Combine History from All Browsers** | When enabled, the plugin fetches and combines history from all supported browsers, including custom profiles. | `true` | +| **Number of History Items to Load** | Set the maximum number of history entries to fetch per browser. Decrease this value if you experience slowdowns. | `1000` | +| **Blocked Domains** | Comma-separated list of domains to exclude from search results (e.g., facebook.com, twitter.com). Leave empty to show all domains. | `N/A` | + +## ๐Ÿ”ง Troubleshooting + +### 1. Plugin Not Working +- Ensure that Flow Launcher has permission to access your browser's history database. +- Verify that the browser's profile folder exists and contains the required files (`History` for Chromium-based browsers, `places.sqlite` for Firefox). +- If using a custom profile: the path MUST be the folder containing the file, e.g.: + - Chromium: `%LocalAppData%\BraveSoftware\Brave-Browser\User Data\Default` + - Firefox: `%AppData%\Mozilla\Firefox\Profiles\xxxxxxxx.default-release` + +### 2. Missing or Incorrect Results +- Check the plugin settings to ensure the correct browser or profile is selected. +- If using a custom profile, confirm that the provided path is a directory (not a file) and that it contains the expected database file. + +### 3. Slow Performance +- Reduce the **History Limit** in the settings to fetch fewer entries. +- Disable **Combine History** if you only need results from a single browser. + +### 4. Errors in Logs +- If you encounter errors, check the Flow Launcher logs for details: `%AppData%\FlowLauncher\Logs` +- For custom profiles, the error message will include the attempted database pathโ€”verify it exists. + +## โ“ Reporting Issues + +If you encounter any issues while using the plugin or have suggestions for improvement, please feel free to open an issue on GitHub: + +1. Navigate to the **Issues** page. +2. Click **New Issue** and provide the following information: + - A clear description of the problem or feature request. + - Steps to reproduce the issue (if applicable). + - Any relevant error messages or logs from Flow Launcher. + +> **Note**: Before opening a new issue, please check the existing issues to see if your problem has already been reported. diff --git a/SettingsTemplate.yaml b/SettingsTemplate.yaml index fffdb39..f90b3c3 100644 --- a/SettingsTemplate.yaml +++ b/SettingsTemplate.yaml @@ -2,13 +2,52 @@ body: - type: dropdown attributes: name: default_browser - label: Default Browser + label: Choose a Browser or Profile defaultValue: Chrome options: - Chrome - Firefox + - Firefox Nightly - Edge - Brave + - Brave Nightly - Opera - Vivaldi - - Arc \ No newline at end of file + - Arc + - Zen + - Floorp + - Thorium + - Helium + - Waterfox + - Chromium Profile + - Firefox Profile + - type: checkbox + attributes: + name: profile_last_updated + label: Select the Most Recently Used Profile + defaultValue: 'true' + - type: inputWithFolderBtn + attributes: + name: custom_profile_path + label: Path to Profile Folder + defaultValue: '' + description: Only needed if picked "Chromium Profile" or "Firefox Profile". + - type: checkbox + attributes: + name: all_browsers_history + label: Combine History from All Browsers + defaultValue: 'true' + - type: input + attributes: + name: history_limit + label: Number of History Items to Load + defaultValue: '1000' + description: This is the maximum number of history entries to load for each browser. The default is 1,000. + - type: textarea + attributes: + name: blocked_domains + label: Blocked Domains + defaultValue: '' + description: Comma-separated list of domains to exclude from search results. (e.g., facebook.com, twitter.com) + + diff --git a/plugin.json b/plugin.json index 16e9fb4..1a8a970 100644 --- a/plugin.json +++ b/plugin.json @@ -4,7 +4,7 @@ "Name": "Browser History", "Description": "Search your Web Browser history", "Author": "Garulf", - "Version": "0.6.0", + "Version": "1.6.2", "Language": "python", "Website": "https://github.com/Garulf/browser-history", "IcoPath": "./icon.png", diff --git a/plugin/browsers.py b/plugin/browsers.py index 64d71fb..3e2b73e 100644 --- a/plugin/browsers.py +++ b/plugin/browsers.py @@ -1,204 +1,263 @@ +from __future__ import annotations + +import os import shutil +import uuid import sqlite3 +import time from tempfile import gettempdir -import os from pathlib import Path from datetime import datetime -import logging - -log = logging.getLogger(__name__) +from typing import List, Optional +# Environment variables LOCAL_DATA = os.getenv('LOCALAPPDATA') ROAMING = os.getenv('APPDATA') -CHROME_DIR = Path(LOCAL_DATA, 'Google', 'Chrome', 'User Data', 'Default', 'History') -FIREFOX_DIR = Path(ROAMING, 'Mozilla', 'Firefox', 'Profiles') -EDGE_DIR = Path(LOCAL_DATA, 'Microsoft', 'Edge', 'User Data', 'Default', 'History') -BRAVE_DIR = Path(LOCAL_DATA, 'BraveSoftware', 'Brave-Browser', 'User Data', 'Default', 'History') -OPERA_DIR = Path(ROAMING, 'Opera Software', 'Opera Stable', 'Default', 'History') -VIVALDI_DIR = Path(LOCAL_DATA, 'Vivaldi', 'User Data', 'Default', 'History') -ARC_DIR = Path(LOCAL_DATA, 'Packages', 'TheBrowserCompany.Arc_ttt1ap7aakyb4', 'LocalCache', 'Local', 'Arc', 'User Data', 'Default', 'History') - -def get(browser_name): - if browser_name == 'chrome': - return Chrome() - elif browser_name == 'firefox': - return Firefox() - elif browser_name == 'edge': - return Edge() - elif browser_name == 'brave': - return Brave() - elif browser_name == 'opera': - return Opera() - elif browser_name == 'vivaldi': - return Vivaldi() - elif browser_name == 'arc': - return Arc() - else: - raise ValueError('Invalid browser name') - -class Base(object): - - def __del__(self): - if hasattr(self, 'temp_path'): - # Probably best we don't leave browser history in the temp directory - # This deletes the temporary database file after the object is destroyed - os.remove(self.temp_path) - - def _copy_database(self, database_path): - """ - Copies the database to a temporary location and returns the path to the - copy. - """ - temp_dir = gettempdir() - temp_path = shutil.copy(database_path, temp_dir) - self.temp_path = temp_path - return temp_path - - def query_history(self, database_path, query, limit=10): - """ - Query Browser history SQL Database. - """ - # Copy the database to a temporary location. - temp_path = self._copy_database(database_path) - - # Open the database. - connection = sqlite3.connect(temp_path) - - cursor = connection.cursor() - cursor.execute(f'{query} LIMIT {limit}') - recent = cursor.fetchall() - connection.close() - return recent - - def get_history_items(self, results): - """ - Converts the tuple returned by the query to a HistoryItem object. - """ - data = [] - for result in results: - data.append(HistoryItem(self, *result)) - return data - - -class Chrome(Base): - """Google Chrome History""" - - def __init__(self, database_path=CHROME_DIR): - self.database_path = database_path - - def history(self, limit=10): - """ - Returns a list of the most recently visited sites in Chrome's history. - """ - recents = self.query_history(self.database_path, 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC', limit) - return self.get_history_items(recents) - -class Firefox(Base): - """Firefox Browser History""" - - def __init__(self, database_path=FIREFOX_DIR): - # Firefox database is not in a static location, so we need to find it - self.database_path = self.find_database(database_path) - def find_database(self, path): - """Find database in path""" - release_folder = Path(path).glob('*.default-release').__next__() - return Path(path, release_folder, 'places.sqlite') +# Chromium profile base folders (User Data only, profile selected later) +CHROMIUM_PROFILE_BASES = { + 'chrome': Path(LOCAL_DATA, 'Google', 'Chrome', 'User Data'), + 'edge': Path(LOCAL_DATA, 'Microsoft', 'Edge', 'User Data'), + 'brave': Path(LOCAL_DATA, 'BraveSoftware', 'Brave-Browser', 'User Data'), + 'brave nightly': Path(LOCAL_DATA, 'BraveSoftware', 'Brave-Browser-Nightly', 'User Data'), + 'vivaldi': Path(LOCAL_DATA, 'Vivaldi', 'User Data'), + 'arc': Path(LOCAL_DATA, 'Packages', 'TheBrowserCompany.Arc_ttt1ap7aakyb4', 'LocalCache', 'Local', 'Arc', 'User Data'), + 'thorium': Path(LOCAL_DATA, 'Thorium', 'User Data'), + 'helium': Path(LOCAL_DATA, 'imput', 'Helium', 'User Data') +} + +# Single fixed path browsers +FIXED_PATHS = { + 'opera': Path(ROAMING, 'Opera Software', 'Opera Stable', 'Default', 'History') +} + +FIREFOX_BASES = { + 'firefox': Path(ROAMING, 'Mozilla', 'Firefox', 'Profiles'), + 'firefox nightly': Path(ROAMING, 'Mozilla', 'Firefox', 'Profiles'), + 'zen': Path(ROAMING, 'zen', 'Profiles'), + 'floorp': Path(ROAMING, 'Floorp', 'Profiles'), + 'waterfox': Path(ROAMING, 'Waterfox', 'Profiles') +} + +CHROMIUM_QUERY = 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC' +FIREFOX_QUERY = 'SELECT url, title, visit_date FROM moz_places INNER JOIN moz_historyvisits ON moz_historyvisits.place_id = moz_places.id ORDER BY visit_date DESC' + +CHROMIUM_EPOCH_OFFSET = 11644473600 + +class Browser: + """Represents a single browser profile history database.""" + + def __init__( + self, + name: str, + query: str, + timestamp_type: str = 'chromium', + custom_path: Optional[Path] = None, + dynamic_profile: bool = False, + db_file: str = 'History', + profile_last_updated: bool = False, + ) -> None: + self.name = name + self.query = query + self.timestamp_type = timestamp_type + self.db_file = db_file + + # Resolve database path + if custom_path: + # Enforce directory-only custom path + if not isinstance(custom_path, Path): + custom_path = Path(custom_path) + if not custom_path.exists() or not custom_path.is_dir(): + raise FileNotFoundError( + f"Custom profile directory does not exist or is not a directory: '{custom_path}'" + ) + self.database_path = custom_path / db_file + elif name in CHROMIUM_PROFILE_BASES: + self.database_path = self._select_chromium_profile( + CHROMIUM_PROFILE_BASES[name], db_file, profile_last_updated + ) + elif name in FIREFOX_BASES: + self.database_path = self._select_firefox_profile( + FIREFOX_BASES[name], db_file, profile_last_updated + ) + elif name in FIXED_PATHS: + self.database_path = FIXED_PATHS[name] + else: + raise ValueError(f"Unsupported browser name: {name}") - def history(self, limit=10): - """Most recent Firefox history""" - recents = self.query_history(self.database_path, 'SELECT url, title, visit_date FROM moz_places INNER JOIN moz_historyvisits on moz_historyvisits.place_id = moz_places.id ORDER BY visit_date DESC', limit) - return self.get_history_items(recents) + if not self.database_path or not self.database_path.exists(): + raise FileNotFoundError( + f"History database not found for '{name}'. Expected: '{self.database_path}'." + ) -class Edge(Base): - """Microsoft Edge History""" + def _select_chromium_profile(self, base: Path, db_file: str, last_updated: bool) -> Path: + if not base.exists(): + return None - def __init__(self, database_path=EDGE_DIR): - self.database_path = database_path + candidates = [ + p / db_file + for p in base.iterdir() + if p.is_dir() and (p / db_file).exists() and (p.name == 'Default' or p.name.startswith('Profile ')) + ] - def history(self, limit=10): - """ - Returns a list of the most recently visited sites in Chrome's history. - """ - recents = self.query_history(self.database_path, 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC', limit) - return self.get_history_items(recents) + if not candidates: + return None -class Brave(Base): - """Brave Browser History""" + return max(candidates, key=lambda p: p.stat().st_mtime) if last_updated else candidates[0] - def __init__(self, database_path=BRAVE_DIR): - self.database_path = database_path - - def history(self, limit=10): - """ - Returns a list of the most recently visited sites in Brave's history. - """ - recents = self.query_history(self.database_path, 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC', limit) - return self.get_history_items(recents) - -class Opera(Base): - """Opera Browser History""" + def _select_firefox_profile(self, base: Path, db_file: str, last_updated: bool) -> Path: + if not base.exists(): + return None - def __init__(self, database_path=OPERA_DIR): - self.database_path = database_path + candidates = [ + p / db_file + for p in base.iterdir() + if p.is_dir() and (p / db_file).exists() + ] - def history(self, limit=10): - """ - Returns a list of the most recently visited sites in Opera's history. - """ - recents = self.query_history(self.database_path, 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC', limit) - return self.get_history_items(recents) + if not candidates: + return None -class Vivaldi(Base): - """Vivaldi Browser History""" + return max(candidates, key=lambda p: p.stat().st_mtime) if last_updated else candidates[0] - def __init__(self, database_path=VIVALDI_DIR): - self.database_path = database_path + def _copy_database(self) -> str: + """Copy the locked original DB to a uniquely named temp file for safe reading. - def history(self, limit=10): + Includes retry logic to mitigate transient file locking issues. """ - Returns a list of the most recently visited sites in Vivaldi's history. - """ - recents = self.query_history(self.database_path, 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC', limit) - return self.get_history_items(recents) - -class Arc(Base): - """Arc Browser History""" - - def __init__(self, database_path=ARC_DIR): - self.database_path = database_path - - def history(self, limit=10): - """ - Returns a list of the most recently visited sites in Arc's history. - """ - recents = self.query_history(self.database_path, 'SELECT url, title, last_visit_time FROM urls ORDER BY last_visit_time DESC', limit) - return self.get_history_items(recents) - -class HistoryItem(object): - """Representation of a history item""" - - def __init__(self, browser, url, title, last_visit_time): + tmp_dir = Path(gettempdir()) + unique_name = f"bh_{self.name}_{uuid.uuid4().hex}.sqlite" + target = tmp_dir / unique_name + + last_err = None + for attempt in range(3): + try: + shutil.copy(self.database_path, target) + break + except OSError as e: + last_err = e + # Short backoff then retry + time.sleep(0.05 * (attempt + 1)) + else: + raise OSError( + f"Failed copying history DB for '{self.name}' from '{self.database_path}' to '{target}': {last_err}" + ) + + # Track multiple temp files (in case history() called many times) + if not hasattr(self, '_temp_paths'): + self._temp_paths = [] + self._temp_paths.append(str(target)) + return str(target) + + def __del__(self): # best-effort cleanup of copied temp DBs + try: + if hasattr(self, '_temp_paths'): + for p in self._temp_paths: + try: + if p and os.path.exists(p): + os.remove(p) + except Exception: + continue + except Exception: + pass + + def history(self, limit: int = 100) -> List['HistoryItem']: + db_path = self._copy_database() + + # Retry opening (handle rare cases where copy completes but FS metadata not flushed) + last_err = None + for attempt in range(3): + try: + connection = sqlite3.connect(db_path) + break + except OSError as e: + last_err = e + time.sleep(0.03 * (attempt + 1)) + else: + raise OSError( + f"Failed to open copied history DB for '{self.name}' at '{db_path}' after retries: {last_err}" + ) + + try: + cursor = connection.cursor() + cursor.execute(f"{self.query} LIMIT {limit}") + rows = cursor.fetchall() + finally: + connection.close() + return [HistoryItem(self, *row) for row in rows] + + def convert_timestamp(self, raw_time): + try: + if raw_time is None: + return datetime.fromtimestamp(0) + if self.timestamp_type == 'chromium': + seconds = raw_time / 1_000_000 - CHROMIUM_EPOCH_OFFSET + elif self.timestamp_type == 'unix_us': + seconds = raw_time / 1_000_000 + else: + seconds = 0 + # Clamp to valid range for datetime + if seconds < 0: + seconds = 0 + return datetime.fromtimestamp(seconds) + except OSError: + # Fallback to epoch if system clock range issue + return datetime.fromtimestamp(0) + + +class HistoryItem: + """Single history row wrapper.""" + + def __init__(self, browser: Browser, url: str, title: str, last_visit_time: int) -> None: self.browser = browser self.url = url - if title is None: - title = '' - if title.strip() == '': - self.title = url - else: - self.title = title + self.title = title.strip() if title else url self.last_visit_time = last_visit_time - def timestamp(self): - if isinstance(self.browser, (Chrome)): - return datetime((self.last_visit_time/1000000)-11644473600, 'unixepoch', 'localtime') - elif isinstance(self.browser, (Firefox)): - return datetime.fromtimestamp(self.last_visit_time / 1000000.0) - elif isinstance(self.browser, (Edge)): - return datetime((self.last_visit_time/1000000)-11644473600, 'unixepoch', 'localtime') - elif isinstance(self.browser, (Brave)): - return datetime((self.last_visit_time/1000000)-11644473600, 'unixepoch', 'localtime') - elif isinstance(self.browser, (Opera)): - return datetime((self.last_visit_time/1000000)-11644473600, 'unixepoch', 'localtime') - elif isinstance(self.browser, (Vivaldi)): - return datetime((self.last_visit_time/1000000)-11644473600, 'unixepoch', 'localtime') + def timestamp(self) -> datetime: + return self.browser.convert_timestamp(self.last_visit_time) + + +def get(browser_name: str, custom_profile_path: Optional[str] = None, profile_last_updated: bool = False) -> Optional[Browser]: + """Factory for Browser objects. + + Returns None if the resolved database cannot be found (FileNotFoundError). + """ + browser_name = browser_name.lower() + profile_last_updated = bool(profile_last_updated) + + try: + if browser_name in CHROMIUM_PROFILE_BASES or browser_name in FIXED_PATHS: + return Browser(browser_name, CHROMIUM_QUERY, 'chromium', profile_last_updated=profile_last_updated) + if browser_name in FIREFOX_BASES: + return Browser( + browser_name, + FIREFOX_QUERY, + 'unix_us', + dynamic_profile=True, + db_file='places.sqlite', + profile_last_updated=profile_last_updated, + ) + if browser_name == 'chromium profile': + if not custom_profile_path: + raise FileNotFoundError('Custom chromium profile path not provided.') + return Browser( + 'chromium profile', + CHROMIUM_QUERY, + 'chromium', + custom_path=Path(custom_profile_path), + db_file='History', + ) + if browser_name == 'firefox profile': + if not custom_profile_path: + raise FileNotFoundError('Custom firefox profile path not provided.') + return Browser( + 'firefox profile', + FIREFOX_QUERY, + 'unix_us', + custom_path=Path(custom_profile_path), + db_file='places.sqlite', + ) + raise ValueError(f"Unsupported browser: {browser_name}") + except FileNotFoundError: + return None \ No newline at end of file diff --git a/plugin/main.py b/plugin/main.py index 5450565..f50c5e1 100644 --- a/plugin/main.py +++ b/plugin/main.py @@ -1,48 +1,151 @@ -from flox import Flox, ICON_HISTORY, ICON_BROWSER - +from flox import Flox, ICON_HISTORY, ICON_BROWSER, ICON_FILE +import pyperclip import browsers +from urllib.parse import urlparse +# Constants HISTORY_GLYPH = '๏œธ' DEFAULT_BROWSER = 'chrome' +CUSTOM_CHROMIUM = 'chromium profile' +CUSTOM_FIREFOX = 'firefox profile' -def remove_duplicates(results): - for item in results: - if item in results: - results.remove(item) - return results class BrowserHistory(Flox): def __init__(self): super().__init__() - self.default_browser = self.settings.get('default_browser', DEFAULT_BROWSER) - self.browser = browsers.get(self.default_browser.lower()) + self.default_browser = self.settings.get('default_browser', DEFAULT_BROWSER).lower() + self.custom_profile_path = self.settings.get('custom_profile_path', '') + self.profile_last_updated = self.settings.get('profile_last_updated', False) + self.all_browsers_history = self.settings.get('all_browsers_history', False) + self.history_limit = int(self.settings.get('history_limit', 10000)) # Default limit is 10000 + blocked_domains_str = self.settings.get('blocked_domains') or '' + self.blocked_domains = [domain.strip().lower() for domain in blocked_domains_str.split(',') if domain.strip()] + self.init_error = None # Store any initialization error to display in query() + + # Initialize browser(s) + if self.all_browsers_history: + self.browsers = [ + browsers.get(browser_name, profile_last_updated=self.profile_last_updated) + for browser_name in browsers.CHROMIUM_PROFILE_BASES.keys() | browsers.FIREFOX_BASES.keys() + ] + + # Include custom browsers if a custom profile path is provided + if self.custom_profile_path: + custom_chromium = browsers.get(CUSTOM_CHROMIUM, custom_profile_path=self.custom_profile_path, profile_last_updated=self.profile_last_updated) + custom_firefox = browsers.get(CUSTOM_FIREFOX, custom_profile_path=self.custom_profile_path, profile_last_updated=self.profile_last_updated) + + if custom_chromium: + self.browsers.append(custom_chromium) + if custom_firefox: + self.browsers.append(custom_firefox) - def _query(self, query): + # Filter out None values (browsers with missing profiles) + self.browsers = [browser for browser in self.browsers if browser is not None] + else: + # Validate custom profile path requirement + if self.default_browser in (CUSTOM_CHROMIUM, CUSTOM_FIREFOX) and not self.custom_profile_path: + self.browser = None + self.init_error = "You selected a custom profile, but no folder path was provided in settings." + else: + self.browser = browsers.get( + self.default_browser, + custom_profile_path=self.custom_profile_path, + profile_last_updated=self.profile_last_updated + ) + if self.browser is None: + self.init_error = f"Default browser '{self.default_browser}' not found or its profile/database is missing." + + def _is_domain_blocked(self, url): + """Check if the domain of the given URL is in the blocked domains list.""" + if not self.blocked_domains: + return False try: - self.query(query) - except FileNotFoundError: - self.add_item( - title='History not found!', - subtitle='Check your logs for more information.', - ) - finally: - return self._results + domain = urlparse(url).netloc.lower() + return any(blocked_domain in domain for blocked_domain in self.blocked_domains) + except: + return False def query(self, query): - history = self.browser.history(limit=10000) - for idx, item in enumerate(history): - if query.lower() in item.title.lower() or query.lower() in item.url.lower(): - subtitle = f"{idx}. {item.url}" + try: + # Surface any initialization error immediately + if not self.all_browsers_history and (self.browser is None or self.init_error): + self.add_item( + title='Browser not available', + subtitle=self.init_error or 'Unknown error initializing browser.' + ) + return + + if self.all_browsers_history and (not hasattr(self, 'browsers') or not self.browsers): + self.add_item( + title='No browser histories found', + subtitle='Could not locate any supported browser profile databases.' + ) + return + + source_items = [] + if self.all_browsers_history: + source_items = self._get_combined_history(query) + if getattr(self, '_warnings', None): + self.add_item( + title='Some browsers were skipped', + subtitle='; '.join(self._warnings)[:150], + icon=ICON_BROWSER + ) + else: + history = self.browser.history(limit=self.history_limit) if self.browser else [] + source_items = [h for h in history if query.lower() in h.title.lower() or query.lower() in h.url.lower()] + source_items = [h for h in source_items if not self._is_domain_blocked(h.url)] + + for idx, item in enumerate(source_items): self.add_item( title=item.title, - subtitle=subtitle, + subtitle=f"{idx + 1}. {item.url}", icon=ICON_HISTORY, glyph=HISTORY_GLYPH, method=self.browser_open, parameters=[item.url], context=[item.title, item.url] ) + except Exception as e: + self.add_item( + title='An error occurred', + subtitle=str(e), + ) + + def _get_combined_history(self, query): + """Combine histories from all browsers, deduplicate, and sort.""" + combined_history = [] + self._warnings = [] + for browser in self.browsers: + try: + combined_history.extend(browser.history(limit=self.history_limit)) + except FileNotFoundError: + continue # Skip browsers with missing databases + except OSError as e: + # Collect problematic browsers but do not abort whole aggregation + self._warnings.append(f"{browser.name}: {e}") + continue + + # Deduplicate by URL + seen_urls = set() + unique_history = [] + for item in combined_history: + if item.url not in seen_urls: + unique_history.append(item) + seen_urls.add(item.url) + + # Sort by normalized timestamp (most recent first) + unique_history.sort(key=lambda x: x.timestamp(), reverse=True) + + # Filter by query + filtered_history = [ + item for item in unique_history + if query.lower() in item.title.lower() or query.lower() in item.url.lower() + ] + + # Filter out blocked domains + return [item for item in filtered_history if not self._is_domain_blocked(item.url)] def context_menu(self, data): self.add_item( @@ -51,8 +154,26 @@ def context_menu(self, data): icon=ICON_BROWSER, method=self.browser_open, parameters=[data[1]], - ) + self.add_item( + title='Copy to clipboard', + subtitle=data[1], + icon=ICON_FILE, + method=self.copy_to_clipboard, + parameters=[data[1]], + ) + + def copy_to_clipboard(self, data): + pyperclip.copy(data) + self.show_msg("Copied!", f"{data}") + + def run(self): + """ + Entry point for Flow Launcher. + This method is required to start the plugin. + """ + pass # Ensure this method exists + if __name__ == "__main__": - BrowserHistory() + BrowserHistory().run() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 151f5b3..07a519c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flox-lib==0.10.4 +pyperclip==1.9.0 diff --git a/run.py b/run.py index 7099c53..6e39162 100644 --- a/run.py +++ b/run.py @@ -1,12 +1,31 @@ import sys import os -plugindir = os.path.abspath(os.path.dirname(__file__)) -sys.path.append(plugindir) -sys.path.append(os.path.join(plugindir, "lib")) -sys.path.append(os.path.join(plugindir, "plugin")) +def main(): + try: + # Get the absolute path of the plugin directory + plugindir = os.path.abspath(os.path.dirname(__file__)) -from plugin.main import BrowserHistory + # Add necessary directories to the Python path + directories = [ + plugindir, + os.path.join(plugindir, "lib"), + os.path.join(plugindir, "plugin") + ] + for directory in directories: + if os.path.exists(directory): + sys.path.append(directory) + else: + print(f"Warning: Directory not found: {directory}", file=sys.stderr) + + # Import and run the BrowserHistory plugin + from plugin.main import BrowserHistory + BrowserHistory().run() + + except ImportError as e: + print(f"Error: Failed to import required modules. {e}", file=sys.stderr) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) if __name__ == "__main__": - BrowserHistory() \ No newline at end of file + main() \ No newline at end of file