Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.vscode
.venv
__pycache__
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
1 change: 0 additions & 1 deletion .ruby-version

This file was deleted.

6 changes: 0 additions & 6 deletions Gemfile

This file was deleted.

40 changes: 0 additions & 40 deletions Gemfile.lock

This file was deleted.

48 changes: 32 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions src/tabby/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "1.0.0"
3 changes: 3 additions & 0 deletions src/tabby/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from tabby.cli import main

main()
158 changes: 158 additions & 0 deletions src/tabby/bookmarks.py
Original file line number Diff line number Diff line change
@@ -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)')}")
86 changes: 86 additions & 0 deletions src/tabby/cli.py
Original file line number Diff line number Diff line change
@@ -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)
Loading