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.
@@ -0,0 +1,7 @@
1
+ # Zenrows CLI (do not commit secrets or run artifacts)
2
+ .zenrows/secrets.json
3
+ .zenrows/config.json
4
+ .zenrows/account.json
5
+ .zenrows/runs/
6
+ .zenrows/traces/
7
+ .zenrows/logs/
@@ -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()