sync-brave 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sync_brave-0.1.0/.gitignore +7 -0
- sync_brave-0.1.0/LICENSE +21 -0
- sync_brave-0.1.0/PKG-INFO +104 -0
- sync_brave-0.1.0/README.md +79 -0
- sync_brave-0.1.0/SETUP.md +91 -0
- sync_brave-0.1.0/pyproject.toml +47 -0
- sync_brave-0.1.0/sync_brave.py +429 -0
sync_brave-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The sync-brave contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sync-brave
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export bookmarks from Chrome and merge them into Brave
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: bookmarks,brave,browser,chrome,cli
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: questionary<3.0,>=2.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Sync Brave bookmarks from Chrome
|
|
27
|
+
|
|
28
|
+
`sync-brave` is an interactive command-line tool that exports bookmarks from a
|
|
29
|
+
local Google Chrome profile, merges them into a local Brave profile, and opens
|
|
30
|
+
the selected Brave profile. It uses the browsers installed on your computer;
|
|
31
|
+
it does not download or run a bundled browser.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- Select a Chrome source profile and Brave destination profile in an arrow-key
|
|
36
|
+
interface.
|
|
37
|
+
- Export bookmarks as Netscape-format HTML to
|
|
38
|
+
`bookmarks/chrome_<profile_name>.html` in the current project directory.
|
|
39
|
+
- Read Chrome's standard `Bookmarks` file, or use `AccountBookmarks` when the
|
|
40
|
+
standard file is absent.
|
|
41
|
+
- Merge into Brave without replacing its existing bookmarks; existing URLs are
|
|
42
|
+
not added a second time.
|
|
43
|
+
- Save a timestamped backup of Brave's bookmark data before making changes.
|
|
44
|
+
- Open the selected profile in the installed Brave browser.
|
|
45
|
+
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
- Python 3.10 or newer.
|
|
49
|
+
- [uv](https://docs.astral.sh/uv/).
|
|
50
|
+
- Google Chrome (or Chromium on Linux) and Brave installed with local profiles.
|
|
51
|
+
- Close all Brave windows before syncing. The tool refuses to change Brave's
|
|
52
|
+
profile while Brave is running.
|
|
53
|
+
|
|
54
|
+
Chrome does not need to be open. The program reads its bookmark data directly
|
|
55
|
+
from the local profile directory. It does not access browser passwords,
|
|
56
|
+
cookies, or other profile data.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
After this project has been published to PyPI:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
uv tool install sync-brave
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This installs the `sync-brave` command so it can be run from any terminal.
|
|
67
|
+
|
|
68
|
+
To use a local checkout during development:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
uv tool install --editable .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To update a published installation:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
uv tool upgrade sync-brave
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Use
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
sync-brave
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use the arrow keys and Enter to:
|
|
87
|
+
|
|
88
|
+
1. Choose **Sync bookmarks**.
|
|
89
|
+
2. Select the Chrome profile to export from.
|
|
90
|
+
3. Select the Brave profile to merge into.
|
|
91
|
+
|
|
92
|
+
The HTML export is written to `bookmarks/` in the current working directory.
|
|
93
|
+
To save it in a particular project folder, run `sync-brave` from that folder.
|
|
94
|
+
The bookmark data is merged into Brave's selected profile, backed up, and
|
|
95
|
+
Brave is opened on that profile.
|
|
96
|
+
|
|
97
|
+
## Release setup
|
|
98
|
+
|
|
99
|
+
For instructions to build, test, and publish this package to TestPyPI or PyPI,
|
|
100
|
+
see [SETUP.md](SETUP.md).
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
This project is distributed under the MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Sync Brave bookmarks from Chrome
|
|
2
|
+
|
|
3
|
+
`sync-brave` is an interactive command-line tool that exports bookmarks from a
|
|
4
|
+
local Google Chrome profile, merges them into a local Brave profile, and opens
|
|
5
|
+
the selected Brave profile. It uses the browsers installed on your computer;
|
|
6
|
+
it does not download or run a bundled browser.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Select a Chrome source profile and Brave destination profile in an arrow-key
|
|
11
|
+
interface.
|
|
12
|
+
- Export bookmarks as Netscape-format HTML to
|
|
13
|
+
`bookmarks/chrome_<profile_name>.html` in the current project directory.
|
|
14
|
+
- Read Chrome's standard `Bookmarks` file, or use `AccountBookmarks` when the
|
|
15
|
+
standard file is absent.
|
|
16
|
+
- Merge into Brave without replacing its existing bookmarks; existing URLs are
|
|
17
|
+
not added a second time.
|
|
18
|
+
- Save a timestamped backup of Brave's bookmark data before making changes.
|
|
19
|
+
- Open the selected profile in the installed Brave browser.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Python 3.10 or newer.
|
|
24
|
+
- [uv](https://docs.astral.sh/uv/).
|
|
25
|
+
- Google Chrome (or Chromium on Linux) and Brave installed with local profiles.
|
|
26
|
+
- Close all Brave windows before syncing. The tool refuses to change Brave's
|
|
27
|
+
profile while Brave is running.
|
|
28
|
+
|
|
29
|
+
Chrome does not need to be open. The program reads its bookmark data directly
|
|
30
|
+
from the local profile directory. It does not access browser passwords,
|
|
31
|
+
cookies, or other profile data.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
After this project has been published to PyPI:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uv tool install sync-brave
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This installs the `sync-brave` command so it can be run from any terminal.
|
|
42
|
+
|
|
43
|
+
To use a local checkout during development:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uv tool install --editable .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
To update a published installation:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
uv tool upgrade sync-brave
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
sync-brave
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use the arrow keys and Enter to:
|
|
62
|
+
|
|
63
|
+
1. Choose **Sync bookmarks**.
|
|
64
|
+
2. Select the Chrome profile to export from.
|
|
65
|
+
3. Select the Brave profile to merge into.
|
|
66
|
+
|
|
67
|
+
The HTML export is written to `bookmarks/` in the current working directory.
|
|
68
|
+
To save it in a particular project folder, run `sync-brave` from that folder.
|
|
69
|
+
The bookmark data is merged into Brave's selected profile, backed up, and
|
|
70
|
+
Brave is opened on that profile.
|
|
71
|
+
|
|
72
|
+
## Release setup
|
|
73
|
+
|
|
74
|
+
For instructions to build, test, and publish this package to TestPyPI or PyPI,
|
|
75
|
+
see [SETUP.md](SETUP.md).
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
This project is distributed under the MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Package and PyPI publishing setup
|
|
2
|
+
|
|
3
|
+
This guide covers building, testing, and publishing `sync-brave` to TestPyPI
|
|
4
|
+
and PyPI.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
|
|
8
|
+
- A [uv installation](https://docs.astral.sh/uv/).
|
|
9
|
+
- A PyPI account for production publishing.
|
|
10
|
+
- A TestPyPI account for the optional test upload.
|
|
11
|
+
- The project name must be available on the target index. The current name is
|
|
12
|
+
`sync-brave`; check [PyPI](https://pypi.org/project/sync-brave/) before the
|
|
13
|
+
first production release. If it is unavailable, change `name` in
|
|
14
|
+
`pyproject.toml`. The command name (`sync-brave`) can remain unchanged.
|
|
15
|
+
|
|
16
|
+
## 1. Prepare a release
|
|
17
|
+
|
|
18
|
+
Review `pyproject.toml`, including the package name, version, Python
|
|
19
|
+
requirement, license, and dependencies. Every release must use a version that
|
|
20
|
+
has not already been uploaded.
|
|
21
|
+
|
|
22
|
+
From the project directory, run the tests and build both distribution formats:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
uv run --no-project python -m unittest discover -s tests -v
|
|
26
|
+
uv build
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The wheel and source archive are written to `dist/`. Inspect their contents
|
|
30
|
+
before upload. They should include the application and its documentation and
|
|
31
|
+
license, but not personal browser data, generated bookmark exports, or local
|
|
32
|
+
error logs.
|
|
33
|
+
|
|
34
|
+
## 2. Test on TestPyPI (recommended)
|
|
35
|
+
|
|
36
|
+
Create an account and account-scoped upload token at
|
|
37
|
+
[test.pypi.org](https://test.pypi.org/). A project-scoped token cannot be
|
|
38
|
+
created until the project exists on that index.
|
|
39
|
+
|
|
40
|
+
In Bash, enter the token at a hidden prompt and upload the artifacts:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
read -s -p 'TestPyPI token: ' UV_PUBLISH_TOKEN
|
|
44
|
+
printf '\n'
|
|
45
|
+
export UV_PUBLISH_TOKEN
|
|
46
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
47
|
+
unset UV_PUBLISH_TOKEN
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Try installing the TestPyPI package. The additional index allows uv to get
|
|
51
|
+
dependencies that are available on regular PyPI:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv tool install --index-url https://test.pypi.org/simple/ \
|
|
55
|
+
--extra-index-url https://pypi.org/simple/ sync-brave
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use a TestPyPI token for this step, never your production PyPI token.
|
|
59
|
+
|
|
60
|
+
## 3. Publish to PyPI
|
|
61
|
+
|
|
62
|
+
Create a PyPI account and upload token at [pypi.org](https://pypi.org/). For
|
|
63
|
+
the first upload, use an account-scoped token. Once the project exists, revoke
|
|
64
|
+
that token and use a project-scoped token for later releases.
|
|
65
|
+
|
|
66
|
+
Keep the token private. Do not place it in source code, a file committed to
|
|
67
|
+
version control, or a command that will be saved in shell history. In Bash,
|
|
68
|
+
enter the token at a hidden prompt and publish:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
read -s -p 'PyPI token: ' UV_PUBLISH_TOKEN
|
|
72
|
+
printf '\n'
|
|
73
|
+
export UV_PUBLISH_TOKEN
|
|
74
|
+
uv publish
|
|
75
|
+
unset UV_PUBLISH_TOKEN
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`uv publish` uploads the distributions in `dist/`. After a successful upload,
|
|
79
|
+
the project is available at `https://pypi.org/project/sync-brave/`.
|
|
80
|
+
|
|
81
|
+
## 4. Verify the published installation
|
|
82
|
+
|
|
83
|
+
Install the published command and check that it starts:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uv tool install sync-brave
|
|
87
|
+
sync-brave --help
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For repeatable releases, consider setting up PyPI Trusted Publishing from a CI
|
|
91
|
+
workflow instead of storing an API token.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sync-brave"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Export bookmarks from Chrome and merge them into Brave"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = ["chrome", "brave", "bookmarks", "browser", "cli"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: Microsoft :: Windows",
|
|
14
|
+
"Operating System :: MacOS",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"questionary>=2.0,<3.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
sync-brave = "sync_brave:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
only-include = ["sync_brave.py"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
only-include = [
|
|
42
|
+
"sync_brave.py",
|
|
43
|
+
"pyproject.toml",
|
|
44
|
+
"README.md",
|
|
45
|
+
"SETUP.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
]
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class BrowserProfile:
|
|
20
|
+
directory: str
|
|
21
|
+
name: str
|
|
22
|
+
bookmarks_path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _browser_data_dir(browser: str) -> Path:
|
|
26
|
+
home = Path.home()
|
|
27
|
+
system = platform.system()
|
|
28
|
+
if system == "Windows":
|
|
29
|
+
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local"))
|
|
30
|
+
if browser == "chrome":
|
|
31
|
+
return local_app_data / "Google/Chrome/User Data"
|
|
32
|
+
return local_app_data / "BraveSoftware/Brave-Browser/User Data"
|
|
33
|
+
if system == "Darwin":
|
|
34
|
+
if browser == "chrome":
|
|
35
|
+
return home / "Library/Application Support/Google/Chrome"
|
|
36
|
+
return home / "Library/Application Support/BraveSoftware/Brave-Browser"
|
|
37
|
+
if browser == "chrome":
|
|
38
|
+
chrome = home / ".config/google-chrome"
|
|
39
|
+
return chrome if chrome.exists() else home / ".config/chromium"
|
|
40
|
+
return home / ".config/BraveSoftware/Brave-Browser"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
47
|
+
raise RuntimeError(f"Could not read browser data at {path}: {exc}") from exc
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise RuntimeError(f"Browser data at {path} has an invalid format.")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _profile_bookmarks_path(profile_path: Path) -> Path:
|
|
54
|
+
for filename in ("Bookmarks", "AccountBookmarks"):
|
|
55
|
+
bookmarks_path = profile_path / filename
|
|
56
|
+
if bookmarks_path.is_file():
|
|
57
|
+
return bookmarks_path
|
|
58
|
+
return profile_path / "Bookmarks"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def discover_profiles(user_data_dir: Path) -> list[BrowserProfile]:
|
|
62
|
+
state_path = user_data_dir / "Local State"
|
|
63
|
+
info_cache = _read_json(state_path).get("profile", {}).get("info_cache", {})
|
|
64
|
+
profiles: list[BrowserProfile] = []
|
|
65
|
+
|
|
66
|
+
if isinstance(info_cache, dict):
|
|
67
|
+
for directory, info in info_cache.items():
|
|
68
|
+
if not isinstance(directory, str) or Path(directory).name != directory:
|
|
69
|
+
continue
|
|
70
|
+
profile_path = user_data_dir / directory
|
|
71
|
+
if not profile_path.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
name = info.get("name", directory) if isinstance(info, dict) else directory
|
|
74
|
+
profiles.append(
|
|
75
|
+
BrowserProfile(
|
|
76
|
+
directory=directory,
|
|
77
|
+
name=str(name),
|
|
78
|
+
bookmarks_path=_profile_bookmarks_path(profile_path),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not profiles:
|
|
83
|
+
for profile_path in sorted(user_data_dir.iterdir()):
|
|
84
|
+
if profile_path.is_dir() and (
|
|
85
|
+
profile_path.name == "Default"
|
|
86
|
+
or re.fullmatch(r"Profile \d+", profile_path.name)
|
|
87
|
+
):
|
|
88
|
+
preferences_path = profile_path / "Preferences"
|
|
89
|
+
name = profile_path.name
|
|
90
|
+
if preferences_path.exists():
|
|
91
|
+
preferences = _read_json(preferences_path)
|
|
92
|
+
name = str(preferences.get("profile", {}).get("name", name))
|
|
93
|
+
profiles.append(
|
|
94
|
+
BrowserProfile(
|
|
95
|
+
directory=profile_path.name,
|
|
96
|
+
name=name,
|
|
97
|
+
bookmarks_path=_profile_bookmarks_path(profile_path),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return profiles
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _profile_label(profile: BrowserProfile) -> str:
|
|
105
|
+
return f"{profile.name} ({profile.directory})"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def select_item(message: str, choices: list[str]) -> int | None:
|
|
109
|
+
try:
|
|
110
|
+
import questionary
|
|
111
|
+
except ImportError as exc:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
"The CLI dependency is missing. Install with: uv tool install --editable ."
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
selected = questionary.select(
|
|
117
|
+
message,
|
|
118
|
+
choices=choices,
|
|
119
|
+
instruction="(Use arrow keys, Enter to select)",
|
|
120
|
+
).ask()
|
|
121
|
+
if selected is None:
|
|
122
|
+
return None
|
|
123
|
+
return choices.index(selected)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _html_node(node: dict[str, Any], level: int = 0) -> list[str]:
|
|
127
|
+
indent = " " * level
|
|
128
|
+
title = html.escape(str(node.get("name", "")), quote=False)
|
|
129
|
+
node_type = node.get("type")
|
|
130
|
+
if node_type == "url":
|
|
131
|
+
url = html.escape(str(node.get("url", "")), quote=True)
|
|
132
|
+
return [f'{indent}<DT><A HREF="{url}">{title}</A>']
|
|
133
|
+
if node_type == "folder":
|
|
134
|
+
children = node.get("children", [])
|
|
135
|
+
if not isinstance(children, list):
|
|
136
|
+
children = []
|
|
137
|
+
lines = [f"{indent}<DT><H3>{title}</H3>", f"{indent}<DL><p>"]
|
|
138
|
+
for child in children:
|
|
139
|
+
if isinstance(child, dict):
|
|
140
|
+
lines.extend(_html_node(child, level + 1))
|
|
141
|
+
lines.append(f"{indent}</DL><p>")
|
|
142
|
+
return lines
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def export_bookmarks(bookmarks_path: Path, output_path: Path) -> None:
|
|
147
|
+
data = _read_json(bookmarks_path)
|
|
148
|
+
roots = data.get("roots", {})
|
|
149
|
+
if not isinstance(roots, dict):
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"Bookmark roots are missing or invalid in {bookmarks_path}."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
lines = [
|
|
155
|
+
"<!DOCTYPE NETSCAPE-Bookmark-file-1>",
|
|
156
|
+
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
|
|
157
|
+
"<TITLE>Bookmarks</TITLE>",
|
|
158
|
+
"<H1>Bookmarks</H1>",
|
|
159
|
+
]
|
|
160
|
+
for key, label in (
|
|
161
|
+
("bookmark_bar", "Bookmarks bar"),
|
|
162
|
+
("other", "Other bookmarks"),
|
|
163
|
+
("mobile", "Mobile bookmarks"),
|
|
164
|
+
):
|
|
165
|
+
root = roots.get(key)
|
|
166
|
+
if not isinstance(root, dict):
|
|
167
|
+
continue
|
|
168
|
+
children = root.get("children", [])
|
|
169
|
+
if not isinstance(children, list):
|
|
170
|
+
continue
|
|
171
|
+
lines.extend((f"<DT><H3>{label}</H3>", "<DL><p>"))
|
|
172
|
+
for child in children:
|
|
173
|
+
if isinstance(child, dict):
|
|
174
|
+
lines.extend(_html_node(child, 1))
|
|
175
|
+
lines.append("</DL><p>")
|
|
176
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _merge_children(
|
|
181
|
+
target_children: list[dict[str, Any]], source_children: list[dict[str, Any]]
|
|
182
|
+
) -> int:
|
|
183
|
+
added = 0
|
|
184
|
+
for source in source_children:
|
|
185
|
+
node_type = source.get("type")
|
|
186
|
+
if node_type == "url":
|
|
187
|
+
url = source.get("url")
|
|
188
|
+
if not isinstance(url, str):
|
|
189
|
+
continue
|
|
190
|
+
if any(
|
|
191
|
+
existing.get("type") == "url" and existing.get("url") == url
|
|
192
|
+
for existing in target_children
|
|
193
|
+
):
|
|
194
|
+
continue
|
|
195
|
+
target_children.append(source.copy())
|
|
196
|
+
added += 1
|
|
197
|
+
elif node_type == "folder":
|
|
198
|
+
name = source.get("name")
|
|
199
|
+
matching_folder = next(
|
|
200
|
+
(
|
|
201
|
+
existing
|
|
202
|
+
for existing in target_children
|
|
203
|
+
if existing.get("type") == "folder" and existing.get("name") == name
|
|
204
|
+
),
|
|
205
|
+
None,
|
|
206
|
+
)
|
|
207
|
+
if matching_folder is None:
|
|
208
|
+
target_children.append(source.copy())
|
|
209
|
+
added += _count_bookmarks(source)
|
|
210
|
+
else:
|
|
211
|
+
target_subtree = matching_folder.get("children")
|
|
212
|
+
source_subtree = source.get("children")
|
|
213
|
+
if isinstance(target_subtree, list) and isinstance(
|
|
214
|
+
source_subtree, list
|
|
215
|
+
):
|
|
216
|
+
added += _merge_children(target_subtree, source_subtree)
|
|
217
|
+
return added
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _count_bookmarks(node: dict[str, Any]) -> int:
|
|
221
|
+
if node.get("type") == "url":
|
|
222
|
+
return 1
|
|
223
|
+
children = node.get("children", [])
|
|
224
|
+
if not isinstance(children, list):
|
|
225
|
+
return 0
|
|
226
|
+
return sum(_count_bookmarks(child) for child in children if isinstance(child, dict))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def merge_bookmarks(source_path: Path, target_path: Path) -> int:
|
|
230
|
+
source_data = _read_json(source_path)
|
|
231
|
+
target_data = _read_json(target_path)
|
|
232
|
+
source_roots = source_data.get("roots", {})
|
|
233
|
+
target_roots = target_data.get("roots", {})
|
|
234
|
+
if not isinstance(source_roots, dict) or not isinstance(target_roots, dict):
|
|
235
|
+
raise RuntimeError("Source or destination bookmark roots are invalid.")
|
|
236
|
+
|
|
237
|
+
added = 0
|
|
238
|
+
for root_name in ("bookmark_bar", "other", "mobile"):
|
|
239
|
+
source_root = source_roots.get(root_name)
|
|
240
|
+
if not isinstance(source_root, dict):
|
|
241
|
+
continue
|
|
242
|
+
target_root_name = root_name if root_name in target_roots else "other"
|
|
243
|
+
target_root = target_roots.get(target_root_name)
|
|
244
|
+
if not isinstance(target_root, dict):
|
|
245
|
+
continue
|
|
246
|
+
source_children = source_root.get("children", [])
|
|
247
|
+
target_children = target_root.setdefault("children", [])
|
|
248
|
+
if isinstance(source_children, list) and isinstance(target_children, list):
|
|
249
|
+
valid_sources = [
|
|
250
|
+
child for child in source_children if isinstance(child, dict)
|
|
251
|
+
]
|
|
252
|
+
added += _merge_children(target_children, valid_sources)
|
|
253
|
+
|
|
254
|
+
if added == 0:
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
backup_path = target_path.with_name(
|
|
258
|
+
f"{target_path.name}.backup-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
|
|
259
|
+
)
|
|
260
|
+
shutil.copy2(target_path, backup_path)
|
|
261
|
+
content = json.dumps(target_data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
262
|
+
temp_name: str | None = None
|
|
263
|
+
try:
|
|
264
|
+
with tempfile.NamedTemporaryFile(
|
|
265
|
+
"w", encoding="utf-8", dir=target_path.parent, delete=False
|
|
266
|
+
) as temp_file:
|
|
267
|
+
temp_name = temp_file.name
|
|
268
|
+
temp_file.write(content)
|
|
269
|
+
temp_file.flush()
|
|
270
|
+
os.fsync(temp_file.fileno())
|
|
271
|
+
os.replace(temp_name, target_path)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
if temp_name is not None:
|
|
274
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
275
|
+
raise RuntimeError(f"Could not update Brave bookmarks: {exc}") from exc
|
|
276
|
+
return added
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _brave_running() -> bool:
|
|
280
|
+
if platform.system() == "Windows":
|
|
281
|
+
result = subprocess.run(
|
|
282
|
+
["tasklist", "/FI", "IMAGENAME eq brave.exe"],
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
check=False,
|
|
286
|
+
)
|
|
287
|
+
if result.returncode != 0:
|
|
288
|
+
raise RuntimeError(
|
|
289
|
+
f"Could not check whether Brave is running: {result.stderr}"
|
|
290
|
+
)
|
|
291
|
+
return "brave.exe" in result.stdout.lower()
|
|
292
|
+
|
|
293
|
+
pgrep = shutil.which("pgrep")
|
|
294
|
+
if pgrep is None:
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
"Cannot safely check whether Brave is running (pgrep is missing)."
|
|
297
|
+
)
|
|
298
|
+
for process_name in ("brave", "brave-browser", "Brave Browser"):
|
|
299
|
+
result = subprocess.run(
|
|
300
|
+
[pgrep, "-x", process_name],
|
|
301
|
+
capture_output=True,
|
|
302
|
+
text=True,
|
|
303
|
+
check=False,
|
|
304
|
+
)
|
|
305
|
+
if result.returncode == 0:
|
|
306
|
+
return True
|
|
307
|
+
if result.returncode not in (1,):
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
f"Could not check whether Brave is running: {result.stderr}"
|
|
310
|
+
)
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _brave_executable() -> str:
|
|
315
|
+
candidates = (
|
|
316
|
+
("brave.exe", "brave-browser.exe")
|
|
317
|
+
if platform.system() == "Windows"
|
|
318
|
+
else ("brave-browser", "brave")
|
|
319
|
+
if platform.system() == "Linux"
|
|
320
|
+
else ("brave", "Brave Browser")
|
|
321
|
+
)
|
|
322
|
+
for candidate in candidates:
|
|
323
|
+
executable = shutil.which(candidate)
|
|
324
|
+
if executable is not None:
|
|
325
|
+
return executable
|
|
326
|
+
raise RuntimeError("Could not find the installed Brave browser executable.")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def sync_profile(chrome_profile: BrowserProfile, brave_profile: BrowserProfile) -> None:
|
|
330
|
+
if not chrome_profile.bookmarks_path.is_file():
|
|
331
|
+
raise RuntimeError(
|
|
332
|
+
f"No Chrome bookmarks file found for {_profile_label(chrome_profile)}."
|
|
333
|
+
)
|
|
334
|
+
if not brave_profile.bookmarks_path.is_file():
|
|
335
|
+
raise RuntimeError(
|
|
336
|
+
f"No Brave bookmarks file found for {_profile_label(brave_profile)}."
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if _brave_running():
|
|
340
|
+
raise RuntimeError(
|
|
341
|
+
"Brave is currently running. Close all Brave windows and try again so the "
|
|
342
|
+
"browser cannot overwrite its profile while bookmarks are being updated."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
output_path = (
|
|
346
|
+
Path.cwd() / "bookmarks" / f"chrome_{_safe_filename(chrome_profile.name)}.html"
|
|
347
|
+
)
|
|
348
|
+
export_bookmarks(chrome_profile.bookmarks_path, output_path)
|
|
349
|
+
added = merge_bookmarks(chrome_profile.bookmarks_path, brave_profile.bookmarks_path)
|
|
350
|
+
|
|
351
|
+
command = [
|
|
352
|
+
_brave_executable(),
|
|
353
|
+
f"--user-data-dir={_browser_data_dir('brave')}",
|
|
354
|
+
f"--profile-directory={brave_profile.directory}",
|
|
355
|
+
"--new-window",
|
|
356
|
+
]
|
|
357
|
+
try:
|
|
358
|
+
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
raise RuntimeError(
|
|
361
|
+
f"Bookmarks were updated, but Brave could not be opened: {exc}"
|
|
362
|
+
) from exc
|
|
363
|
+
|
|
364
|
+
print(f"\nExported Chrome bookmarks to: {output_path}")
|
|
365
|
+
print(
|
|
366
|
+
f"Added {added} bookmark(s) to Brave profile {_profile_label(brave_profile)}."
|
|
367
|
+
)
|
|
368
|
+
if added:
|
|
369
|
+
print("A backup of the previous Brave bookmarks file was saved beside it.")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _safe_filename(name: str) -> str:
|
|
373
|
+
safe_name = re.sub(r"[^A-Za-z0-9._@+-]+", "_", name.strip()).strip("._")
|
|
374
|
+
return safe_name or "profile"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def run_cli() -> None:
|
|
378
|
+
while True:
|
|
379
|
+
selection = select_item("Sync Brave through Chrome", ["Sync bookmarks", "Exit"])
|
|
380
|
+
if selection is None or selection == 1:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
chrome_dir = _browser_data_dir("chrome")
|
|
384
|
+
brave_dir = _browser_data_dir("brave")
|
|
385
|
+
if not chrome_dir.is_dir():
|
|
386
|
+
raise RuntimeError(f"Chrome profile directory was not found: {chrome_dir}")
|
|
387
|
+
if not brave_dir.is_dir():
|
|
388
|
+
raise RuntimeError(f"Brave profile directory was not found: {brave_dir}")
|
|
389
|
+
|
|
390
|
+
chrome_profiles = discover_profiles(chrome_dir)
|
|
391
|
+
brave_profiles = discover_profiles(brave_dir)
|
|
392
|
+
if not chrome_profiles:
|
|
393
|
+
raise RuntimeError(f"No Chrome profiles were found in {chrome_dir}")
|
|
394
|
+
if not brave_profiles:
|
|
395
|
+
raise RuntimeError(f"No Brave profiles were found in {brave_dir}")
|
|
396
|
+
|
|
397
|
+
chrome_index = select_item(
|
|
398
|
+
"Choose a Chrome profile to export",
|
|
399
|
+
[_profile_label(profile) for profile in chrome_profiles],
|
|
400
|
+
)
|
|
401
|
+
if chrome_index is None:
|
|
402
|
+
continue
|
|
403
|
+
brave_index = select_item(
|
|
404
|
+
"Choose a Brave profile to receive the bookmarks",
|
|
405
|
+
[_profile_label(profile) for profile in brave_profiles],
|
|
406
|
+
)
|
|
407
|
+
if brave_index is None:
|
|
408
|
+
continue
|
|
409
|
+
sync_profile(chrome_profiles[chrome_index], brave_profiles[brave_index])
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def main() -> None:
|
|
414
|
+
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
|
415
|
+
print(
|
|
416
|
+
"Usage: sync-brave\n\nInteractively export Chrome bookmarks and merge them into Brave."
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
try:
|
|
420
|
+
run_cli()
|
|
421
|
+
except KeyboardInterrupt:
|
|
422
|
+
print("\nCancelled.")
|
|
423
|
+
except (RuntimeError, OSError) as exc:
|
|
424
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
425
|
+
raise SystemExit(1) from exc
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
if __name__ == "__main__":
|
|
429
|
+
main()
|