diff --git a/.gitignore b/.gitignore index 722d5e7..2aa8d4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .vscode +.venv +__pycache__ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 4d9d11c..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.4.2 diff --git a/Gemfile b/Gemfile deleted file mode 100644 index f3a6662..0000000 --- a/Gemfile +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" -ruby "3.4.2" - -gem "htmlbeautifier" -gem "markaby" -gem "sqlite3" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 19ea5ae..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,40 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - builder (3.3.0) - htmlbeautifier (1.4.3) - markaby (0.9.4) - builder - sqlite3 (2.6.0-aarch64-linux-gnu) - sqlite3 (2.6.0-aarch64-linux-musl) - sqlite3 (2.6.0-arm-linux-gnu) - sqlite3 (2.6.0-arm-linux-musl) - sqlite3 (2.6.0-arm64-darwin) - sqlite3 (2.6.0-x86-linux-gnu) - sqlite3 (2.6.0-x86-linux-musl) - sqlite3 (2.6.0-x86_64-darwin) - sqlite3 (2.6.0-x86_64-linux-gnu) - sqlite3 (2.6.0-x86_64-linux-musl) - -PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin - x86-linux-gnu - x86-linux-musl - x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - htmlbeautifier - markaby - sqlite3 - -RUBY VERSION - ruby 3.4.2 - -BUNDLED WITH - 2.6.2 diff --git a/README.md b/README.md index 5e3c1f7..b13327c 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,16 @@ Safari tab groups are an awesome way to manage your bookmarks. But, unfortunatel So let's fix that. Tabby can: -- Export tab groups as HTML -- Export tab groups as CSV -- Export tab groups from default profile -- Export tab groups from all other profiles +- Export tab groups as HTML and CSV files +- Save tab groups directly into Safari's bookmarks sidebar +- Export tab groups from default profile and all other profiles - Export tab groups in current order (including tab order within groups) ## Installation -### 1. Install ruby -Unfortunately, macOS includes an outdated version of Ruby, so you will need to install a modern version of Ruby (3.4.2 or higher) to use Tabby. - -If you need to install ruby, follow this guide: -https://gorails.com/setup/macos/15-sequoia +### 1. Install uv +If you don't have `uv` installed, follow the instructions at: +https://docs.astral.sh/uv/getting-started/installation/ ### 2. Enable full disk permissions for Terminal app 1. Open System Settings. @@ -32,15 +29,34 @@ https://gorails.com/setup/macos/15-sequoia 1. `git clone git@github.com:mokolabs/tabby.git ~/Desktop/tabby` 2. Open your terminal app. 3. Run `cd ~/Desktop/tabby` to open the tabby directory. -4. Run `bundle install` to install dependencies. -5. Run `ruby tabby.rb` to export your tab groups to the desktop. +4. Run `uv sync` to install dependencies. +5. Run `uv run tabby` to export your tab groups to the desktop. + +## Usage + +### Export tab groups to files (default) +``` +uv run tabby # export to ~/Desktop/tabgroups +uv run tabby export ~/Library/Backup # export to custom location +``` + +### Save tab groups to Safari bookmarks +``` +uv run tabby save # save to Bookmarks.plist +uv run tabby save --dry-run # preview without modifying +``` + +This creates a "Tabby" folder in Safari's bookmarks sidebar containing your tab groups organized by profile. Re-running replaces the existing folder (a timestamped backup is created automatically). + +Your tab groups are not modified or removed — restart Safari, confirm the bookmarks appear in the sidebar, then close the tab groups manually. + +### Options +- `--stp` — Use Safari Technology Preview instead of Safari +- Custom export path: `uv run tabby ~/Library/Backup` -### 4. Optional steps -- Need to customize the export location? Just pass a file path to the tabby command. - `ruby tabby.rb ~/Library/Backup` -- Need to move tabby to a different location? The script should work in any location within your home directory. +### Tips +- The script should work in any location within your home directory. - Need to backup your tab groups on a daily basis? Just write a cron task that runs the tabby command! -- Using Safari Technology Preview? Just add this flag: `ruby tabby.rb -stp` ## Feedback Have a suggestion? Your feedback is welcome! Feel free to open an issue or PR. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a671a47 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tabby" +version = "1.0.0" +description = "Backup your Safari tab groups" +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +dependencies = [] + +[project.scripts] +tabby = "tabby.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/src/tabby/__init__.py b/src/tabby/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/src/tabby/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/src/tabby/__main__.py b/src/tabby/__main__.py new file mode 100644 index 0000000..152030d --- /dev/null +++ b/src/tabby/__main__.py @@ -0,0 +1,3 @@ +from tabby.cli import main + +main() diff --git a/src/tabby/bookmarks.py b/src/tabby/bookmarks.py new file mode 100644 index 0000000..06f2e48 --- /dev/null +++ b/src/tabby/bookmarks.py @@ -0,0 +1,158 @@ +import os +import plistlib +import shutil +import subprocess +import uuid +from datetime import datetime +from pathlib import Path + +from tabby.database import SafariDatabase, TabGroup + +BOOKMARKS_PLIST = Path.home() / "Library" / "Safari" / "Bookmarks.plist" +TABBY_FOLDER_TITLE = "Tabby" + + +def generate_uuid() -> str: + return str(uuid.uuid4()).upper() + + +def make_folder(title: str, children: list[dict]) -> dict: + return { + "WebBookmarkUUID": generate_uuid(), + "WebBookmarkType": "WebBookmarkTypeList", + "Title": title, + "Children": children, + } + + +def make_leaf(title: str, url: str) -> dict: + return { + "WebBookmarkUUID": generate_uuid(), + "WebBookmarkType": "WebBookmarkTypeLeaf", + "URLString": url, + "URIDictionary": {"title": title}, + } + + +def backup_plist(path: Path) -> Path: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = path.with_suffix(f".plist.tabby-backup-{stamp}") + shutil.copy2(path, backup) + return backup + + +def is_safari_running() -> bool: + result = subprocess.run( + ["pgrep", "-x", "Safari"], capture_output=True, text=True + ) + return result.returncode == 0 + + +def find_tabby_folder(children: list[dict]) -> int | None: + for i, child in enumerate(children): + if ( + child.get("WebBookmarkType") == "WebBookmarkTypeList" + and child.get("Title") == TABBY_FOLDER_TITLE + ): + return i + return None + + +def build_tabby_tree( + profiles: list[tuple[str, list[TabGroup]]], db: SafariDatabase +) -> dict: + profile_folders = [] + for profile_name, groups in profiles: + group_folders = [] + for group in groups: + bookmarks = db.get_bookmarks(group.id) + leaves = [make_leaf(b.title, b.url) for b in bookmarks] + if leaves: + group_folders.append(make_folder(group.title, leaves)) + if group_folders: + profile_folders.append(make_folder(profile_name, group_folders)) + return make_folder(TABBY_FOLDER_TITLE, profile_folders) + + +def save_to_bookmarks( + plist_path: Path, + profiles: list[tuple[str, list[TabGroup]]], + db: SafariDatabase, + dry_run: bool = False, +) -> None: + try: + with open(plist_path, "rb") as f: + plist = plistlib.load(f) + except FileNotFoundError: + print(f"Error: {plist_path} not found.") + print("Grant Full Disk Access to your terminal in System Settings > Privacy & Security > Full Disk Access.") + raise SystemExit(1) + except PermissionError: + print(f"Error: Permission denied reading {plist_path}.") + print("Grant Full Disk Access to your terminal in System Settings > Privacy & Security > Full Disk Access.") + raise SystemExit(1) + + if not isinstance(plist.get("Children"), list): + print("Error: Unexpected Bookmarks.plist structure (missing root Children array).") + raise SystemExit(1) + + tabby_tree = build_tabby_tree(profiles, db) + + if dry_run: + _print_tree(tabby_tree, indent=0) + return + + if is_safari_running(): + print("Warning: Safari is running. Restart Safari to see changes.\n") + + backup = backup_plist(plist_path) + print(f"Backing up to: {backup}\n") + + children = plist["Children"] + existing = find_tabby_folder(children) + if existing is not None: + children[existing] = tabby_tree + else: + children.append(tabby_tree) + + tmp_path = plist_path.with_suffix(".plist.tmp") + try: + with open(tmp_path, "wb") as f: + plistlib.dump(plist, f, fmt=plistlib.FMT_BINARY) + os.replace(tmp_path, plist_path) + except PermissionError: + tmp_path.unlink(missing_ok=True) + print(f"Error: Permission denied writing {plist_path}.") + print("Grant Full Disk Access to your terminal in System Settings > Privacy & Security > Full Disk Access.") + raise SystemExit(1) + + total_groups = 0 + total_bookmarks = 0 + print("Saved to Safari bookmarks:") + print(f" {TABBY_FOLDER_TITLE}/") + for profile_folder in tabby_tree["Children"]: + print(f" {profile_folder['Title']}/") + for group_folder in profile_folder["Children"]: + count = len(group_folder["Children"]) + total_groups += 1 + total_bookmarks += count + print(f" {group_folder['Title']} ({count} bookmarks)") + + print(f"\nDone. {total_groups} tab groups ({total_bookmarks} bookmarks) saved.") + + +def _print_tree(node: dict, indent: int) -> None: + prefix = " " * indent + if node["WebBookmarkType"] == "WebBookmarkTypeList": + suffix = f" ({len(node['Children'])} bookmarks)" if all( + c["WebBookmarkType"] == "WebBookmarkTypeLeaf" for c in node["Children"] + ) else "/" + if suffix == "/": + print(f"{prefix}{node['Title']}/") + else: + print(f"{prefix}{node['Title']}{suffix}") + if suffix == "/": + for child in node["Children"]: + _print_tree(child, indent + 1) + else: + print(f"{prefix}{node.get('URIDictionary', {}).get('title', '(untitled)')}") diff --git a/src/tabby/cli.py b/src/tabby/cli.py new file mode 100644 index 0000000..3cee46d --- /dev/null +++ b/src/tabby/cli.py @@ -0,0 +1,86 @@ +import argparse +import sys +from pathlib import Path + +from tabby.database import SafariDatabase +from tabby.exporters import export_csv, export_html + + +def _get_db_path(stp: bool) -> Path: + app = "SafariTechnologyPreview" if stp else "Safari" + return ( + Path.home() / "Library" / "Containers" / f"com.apple.{app}" + / "Data" / "Library" / app / "SafariTabs.db" + ) + + +def _collect_profiles(db: SafariDatabase) -> list[tuple[str, list]]: + personal_groups = db.get_personal_groups() + profiles = [("Personal", personal_groups)] + + for profile in db.get_profiles(): + groups = db.get_profile_groups(profile.id) + profiles.append((profile.title, groups)) + + return profiles + + +def _run_export(args: argparse.Namespace) -> None: + base = Path(args.path).expanduser().resolve() / "tabgroups" + base.mkdir(parents=True, exist_ok=True) + + with SafariDatabase(_get_db_path(args.stp)) as db: + profiles = _collect_profiles(db) + + for profile_name, groups in profiles: + profile_dir = base / profile_name.lower() + profile_dir.mkdir(parents=True, exist_ok=True) + + export_csv(profile_dir, groups, db) + export_html(profile_dir, groups, db, profile_name.lower()) + + +def _run_save(args: argparse.Namespace) -> None: + from tabby.bookmarks import BOOKMARKS_PLIST, save_to_bookmarks + + with SafariDatabase(_get_db_path(args.stp)) as db: + profiles = _collect_profiles(db) + save_to_bookmarks(BOOKMARKS_PLIST, profiles, db, dry_run=args.dry_run) + + +def main(): + # Backward compat: if first arg isn't a known subcommand, default to export + subcommands = {"export", "save"} + if len(sys.argv) < 2 or sys.argv[1] not in subcommands | {"-h", "--help"}: + sys.argv.insert(1, "export") + + parser = argparse.ArgumentParser(description="Backup your Safari tab groups") + subparsers = parser.add_subparsers(dest="command") + + # export subcommand + export_parser = subparsers.add_parser("export", help="Export tab groups to files") + export_parser.add_argument( + "path", + nargs="?", + default=str(Path.home() / "Desktop"), + help="Export location (default: ~/Desktop)", + ) + export_parser.add_argument( + "--stp", action="store_true", help="Use Safari Technology Preview" + ) + + # save subcommand + save_parser = subparsers.add_parser("save", help="Save tab groups to Safari bookmarks") + save_parser.add_argument( + "--stp", action="store_true", help="Use Safari Technology Preview" + ) + save_parser.add_argument( + "--dry-run", action="store_true", help="Show what would be saved without modifying the plist" + ) + + args = parser.parse_args() + + if args.command == "save": + _run_save(args) + else: + _run_export(args) diff --git a/src/tabby/database.py b/src/tabby/database.py new file mode 100644 index 0000000..24177cb --- /dev/null +++ b/src/tabby/database.py @@ -0,0 +1,73 @@ +import shutil +import sqlite3 +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class TabGroup: + id: int + title: str + + +@dataclass +class Bookmark: + title: str + url: str + + +@dataclass +class Profile: + id: int + title: str + + +class SafariDatabase: + def __init__(self, db_path: Path): + self._db_path = db_path + self._tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + shutil.copy2(self._db_path, self._tmp.name) + self._tmp.close() + self._conn = sqlite3.connect(self._tmp.name) + + def __enter__(self): + return self + + def __exit__(self, *exc): + self._conn.close() + Path(self._tmp.name).unlink(missing_ok=True) + + def get_personal_groups(self) -> list[TabGroup]: + cursor = self._conn.execute( + "SELECT id, title FROM bookmarks " + "WHERE type = 1 AND parent = 0 AND subtype = 0 " + "AND num_children > 0 AND hidden = 0 " + "ORDER BY id DESC" + ) + return [TabGroup(id=row[0], title=row[1]) for row in cursor] + + def get_profiles(self) -> list[Profile]: + cursor = self._conn.execute( + "SELECT id, title FROM bookmarks WHERE subtype = 2 AND title != ''" + ) + return [Profile(id=row[0], title=row[1]) for row in cursor] + + def get_profile_groups(self, profile_id: int) -> list[TabGroup]: + cursor = self._conn.execute( + "SELECT id, title FROM bookmarks " + "WHERE parent = ? AND subtype = 0 AND num_children > 0 " + "ORDER BY id DESC", + (profile_id,), + ) + return [TabGroup(id=row[0], title=row[1]) for row in cursor] + + def get_bookmarks(self, group_id: int) -> list[Bookmark]: + cursor = self._conn.execute( + "SELECT title, url FROM bookmarks " + "WHERE parent = ? " + "AND title NOT IN ('TopScopedBookmarkList', 'Untitled', 'Start Page') " + "ORDER BY order_index ASC", + (group_id,), + ) + return [Bookmark(title=row[0], url=row[1]) for row in cursor] diff --git a/src/tabby/exporters.py b/src/tabby/exporters.py new file mode 100644 index 0000000..3a5700a --- /dev/null +++ b/src/tabby/exporters.py @@ -0,0 +1,39 @@ +import csv +from html import escape +from pathlib import Path + +from tabby.database import SafariDatabase, TabGroup + + +def export_csv(path: Path, groups: list[TabGroup], db: SafariDatabase) -> None: + with open(path / "bookmarks.csv", "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["Tab Group", "Bookmark", "URL"]) + + for group in groups: + for bookmark in db.get_bookmarks(group.id): + writer.writerow([group.title, bookmark.title, bookmark.url]) + + +def export_html(path: Path, groups: list[TabGroup], db: SafariDatabase, profile_name: str) -> None: + title = f"{profile_name.capitalize()} Bookmarks" + lines = [ + "", + '', + f"
", + ] + + for group in groups: + bookmarks = db.get_bookmarks(group.id) + lines.append(f"
") + for bookmark in bookmarks: + lines.append(f'
") + + lines.append("
") + + with open(path / "bookmarks.html", "w") as f: + f.write("\n".join(lines) + "\n") diff --git a/tabby.rb b/tabby.rb deleted file mode 100755 index c677dea..0000000 --- a/tabby.rb +++ /dev/null @@ -1,101 +0,0 @@ -require "csv" -require "fileutils" -require "htmlbeautifier" -require "markaby" -require "sqlite3" -require "tempfile" - -# Set export path -base = ARGV[0] && !ARGV[0].start_with?("-") ? File.expand_path(ARGV[0]) : File.expand_path("~/Desktop") -base = File.join(base, "tabgroups"); FileUtils.mkdir_p(base) - -# Set library path -app = ARGV.include?("-stp") ? "SafariTechnologyPreview" : "Safari" -library = File.expand_path("~/Library/Containers/com.apple.#{app}/Data/Library/#{app}") - -# Copy safari tabs into temporary file -original = File.expand_path("#{library}/SafariTabs.db") -temporary = Tempfile.new("SafariTabs.db"); FileUtils.cp(original, temporary.path) - -# Export tab groups -begin - db = SQLite3::Database.open(temporary.path) - - # Select tab groups from personal profile - personal = [:personal, "SELECT id, title FROM bookmarks WHERE type = 1 AND parent = 0 AND subtype == 0 AND num_children > 0 AND hidden == 0 ORDER BY id DESC"] - - # Select tab groups from all other profiles - profiles = "SELECT id, title FROM bookmarks WHERE subtype = '2' and title != ''" - profiles = db.execute(profiles).map do |profile| - [profile[1].downcase.to_sym, "SELECT id, title FROM bookmarks WHERE parent = #{profile[0]} AND subtype == 0 AND num_children > 0 ORDER BY id DESC"] - end - - # Export tab groups to CSV and HTML - groups = [personal] - groups << profiles.flatten unless profiles.empty? - groups.each do |profile| - profile, query = profile - tab_groups = db.execute(query) - - profile_directory = File.join(base, profile.to_s) - FileUtils.mkdir_p(profile_directory) - - # Export to CSV - CSV.open("#{profile_directory}/bookmarks.csv", "w") do |csv| - csv << ["Tab Group", "Bookmark", "URL"] - - tab_groups.each do |group| - id, group = group - - query = "SELECT title, url FROM bookmarks WHERE parent = #{id} AND title NOT IN ('TopScopedBookmarkList', 'Untitled', 'Start Page') ORDER BY order_index ASC" - bookmarks = db.execute(query) - - bookmarks.each do |bookmark| - name, url = bookmark - csv << [group, name, url] - end - end - end - - # Export to HTML - File.open("#{profile_directory}/bookmarks.html", "w") do |html| - # Add header - html.puts <<~HTML - - -