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/SettingsTemplate.yaml b/SettingsTemplate.yaml index fffdb39..0cb9b83 100644 --- a/SettingsTemplate.yaml +++ b/SettingsTemplate.yaml @@ -7,8 +7,19 @@ body: options: - Chrome - Firefox + - Firefox Nightly - Edge - Brave + - Brave Nightly - Opera - Vivaldi - - Arc \ No newline at end of file + - Arc + - Zen + - Floorp + - Thorium + - Custom (Chromium) + - Custom (Firefox) + - type: inputWithFolderBtn + attributes: + name: custom_profile_path + label: Custom Profile Folder \ No newline at end of file diff --git a/plugin.json b/plugin.json index 16e9fb4..99ac4a6 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": "0.9.0", "Language": "python", "Website": "https://github.com/Garulf/browser-history", "IcoPath": "./icon.png", diff --git a/plugin/browsers.py b/plugin/browsers.py index 64d71fb..ba18412 100644 --- a/plugin/browsers.py +++ b/plugin/browsers.py @@ -1,7 +1,7 @@ +import os import shutil import sqlite3 from tempfile import gettempdir -import os from pathlib import Path from datetime import datetime import logging @@ -10,195 +10,111 @@ 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) +# Paths to known browser history locations +BROWSER_PATHS = { + 'chrome': Path(LOCAL_DATA, 'Google', 'Chrome', 'User Data', 'Default', 'History'), + 'edge': Path(LOCAL_DATA, 'Microsoft', 'Edge', 'User Data', 'Default', 'History'), + 'brave': Path(LOCAL_DATA, 'BraveSoftware', 'Brave-Browser', 'User Data', 'Default', 'History'), + 'brave nightly': Path(LOCAL_DATA, 'BraveSoftware', 'Brave-Browser-Nightly', 'User Data', 'Default', 'History'), + 'opera': Path(ROAMING, 'Opera Software', 'Opera Stable', 'Default', 'History'), + 'vivaldi': Path(LOCAL_DATA, 'Vivaldi', 'User Data', 'Default', 'History'), + 'arc': Path(LOCAL_DATA, 'Packages', 'TheBrowserCompany.Arc_ttt1ap7aakyb4', 'LocalCache', 'Local', 'Arc', 'User Data', 'Default', 'History'), + 'thorium': Path(LOCAL_DATA, 'Thorium', 'User Data', 'Default', 'History'), + 'firefox': Path(ROAMING, 'Mozilla', 'Firefox', 'Profiles'), + 'firefox nightly': Path(ROAMING, 'Mozilla', 'Firefox', 'Profiles'), + 'zen': Path(ROAMING, 'zen', 'Profiles'), + 'floorp': Path(ROAMING, 'Floorp', 'Profiles'), +} + +# Constants for timestamp conversion +CHROMIUM_EPOCH_OFFSET = 11644473600 # seconds from 1601 to 1970 + + +class Browser: + def __init__(self, name, query, timestamp_type='chromium', custom_path=None, dynamic_profile=False, db_file='History'): + self.name = name + self.query = query + self.timestamp_type = timestamp_type + self.dynamic_profile = dynamic_profile + self.db_file = db_file + + if custom_path: + self.database_path = Path(custom_path) + elif dynamic_profile: + profile_base = BROWSER_PATHS.get(name) + if not profile_base or not Path(profile_base).exists(): + raise FileNotFoundError(f"Profile base not found for {name}: {profile_base}") + + for profile_folder in Path(profile_base).iterdir(): + candidate_db = profile_folder / db_file + if candidate_db.exists(): + self.database_path = candidate_db + break + else: + raise FileNotFoundError(f"No valid profile found with {db_file} in {profile_base}") + else: + self.database_path = BROWSER_PATHS.get(name) + + if not self.database_path.exists(): + raise FileNotFoundError(f"Database not found for {name}: {self.database_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) + def _copy_database(self): + temp_path = shutil.copy(self.database_path, gettempdir()) 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) + def __del__(self): + if hasattr(self, 'temp_path'): + os.remove(self.temp_path) - # Open the database. - connection = sqlite3.connect(temp_path) - + def history(self, limit=10): + db_path = self._copy_database() + connection = sqlite3.connect(db_path) cursor = connection.cursor() - cursor.execute(f'{query} LIMIT {limit}') - recent = cursor.fetchall() + cursor.execute(f"{self.query} LIMIT {limit}") + rows = 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') + return [HistoryItem(self, *row) for row in rows] - 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) - -class Edge(Base): - """Microsoft Edge History""" - - def __init__(self, database_path=EDGE_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 Brave(Base): - """Brave Browser History""" - - 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 __init__(self, database_path=OPERA_DIR): - self.database_path = database_path - - 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) + def convert_timestamp(self, raw_time): + if self.timestamp_type == 'chromium': + return datetime.fromtimestamp(raw_time / 1_000_000 - CHROMIUM_EPOCH_OFFSET) + elif self.timestamp_type == 'unix_us': + return datetime.fromtimestamp(raw_time / 1_000_000) -class Vivaldi(Base): - """Vivaldi Browser History""" - - def __init__(self, database_path=VIVALDI_DIR): - self.database_path = database_path - - def history(self, limit=10): - """ - 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""" +class HistoryItem: def __init__(self, browser, url, title, last_visit_time): 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') + return self.browser.convert_timestamp(self.last_visit_time) + + +# Queries +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' + +# Factory function +def get(browser_name, custom_profile_path=None): + browser_name = browser_name.lower() + if browser_name in ['chrome', 'edge', 'brave', 'brave nightly', 'opera', 'vivaldi', 'arc', 'thorium']: + return Browser(browser_name, CHROMIUM_QUERY, 'chromium') + elif browser_name in ['firefox', 'firefox nightly', 'zen', 'floorp']: + return Browser( + browser_name, + FIREFOX_QUERY, + 'unix_us', + dynamic_profile=True, + db_file='places.sqlite' + ) + elif browser_name == 'custom (chromium)': + return Browser('custom', CHROMIUM_QUERY, 'chromium', custom_path=Path(custom_profile_path) / 'History') + elif browser_name == 'custom (firefox)': + return Browser('custom', FIREFOX_QUERY, 'unix_us', custom_path=Path(custom_profile_path) / 'places.sqlite') + else: + raise ValueError(f"Unsupported browser: {browser_name}") diff --git a/plugin/main.py b/plugin/main.py index 5450565..9e5f8e1 100644 --- a/plugin/main.py +++ b/plugin/main.py @@ -1,22 +1,27 @@ -from flox import Flox, ICON_HISTORY, ICON_BROWSER +from flox import Flox, ICON_HISTORY, ICON_BROWSER, ICON_FILE +import pyperclip import browsers HISTORY_GLYPH = '' DEFAULT_BROWSER = 'chrome' def remove_duplicates(results): + seen = set() + unique_results = [] for item in results: - if item in results: - results.remove(item) - return results + if item not in seen: + unique_results.append(item) + seen.add(item) + return unique_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.custom_profile_path = self.settings.get('custom_profile_path', '') + self.browser = browsers.get(self.default_browser.lower(), self.custom_profile_path) def _query(self, query): try: @@ -53,6 +58,18 @@ def context_menu(self, data): 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}") if __name__ == "__main__": - BrowserHistory() + BrowserHistory().run() 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