befave 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.
befave-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Jackson
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.
befave-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: befave
3
+ Version: 0.1.0
4
+ Summary: Keep favorites in sync across Chrome, Firefox, Safari and Opera via a deduplicated sqlite3 database
5
+ Author: David Jackson
6
+ Author-email: David Jackson <pyguy411@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # befave
13
+
14
+ Keep your favorites in sync across browsers. `befave` reads the bookmark export
15
+ from Chrome, Firefox, Safari or Opera, merges everything into one sqlite3
16
+ database with no duplicates, and writes it back out as a file any of those
17
+ browsers can import. Two commands do this: `pull` reads a browser's exported
18
+ file into the database, and `push` writes the database out as a file for a
19
+ browser to import. (Browsers call these steps "export" and "import"; `befave`
20
+ uses its own words to keep them apart.) Pull and push can be repeated in
21
+ cycles, so favorites can round trip between browsers.
22
+
23
+ ## Why
24
+
25
+ - Frustrated that the browser on your work computer doesn't have the same
26
+ favorites as your home computer? Export from each browser, pull both files
27
+ into `befave`, and push the merged set back to either one.
28
+ - Added new favorites in a new browser after you pulled your favorites from
29
+ another one? Export the new browser's bookmarks, pull them (only the new
30
+ ones are added), then push the full set to any browser.
31
+ - Switching browsers, or using several at once? Keep one master list and
32
+ load it into whichever browser you pick up.
33
+
34
+ ## Install
35
+
36
+ ```
37
+ uv tool install befave # once published
38
+ # or from a checkout
39
+ uv run befave
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ Run `befave` and it asks what it needs to know. Most questions are numbered
45
+ choices: type the number and press Enter. Anything you pass as an argument is
46
+ not asked again.
47
+
48
+ ```
49
+ befave # asks: pull or push?
50
+ befave pull # asks for the file(s), source and database
51
+ befave pull --db a.sqlite3 --source home-firefox example_formats/firefox_bookmarks.html
52
+ befave push # asks for the target browser, output file, source, database
53
+ befave push out.html --browser firefox --source home-firefox --db a.sqlite3
54
+ ```
55
+
56
+ When not run from a terminal, `befave` never prompts. It uses the default
57
+ database and fails with a message if something required is missing.
58
+
59
+ ### Step by step
60
+
61
+ **Pull**: read a browser's exported bookmarks file into the database.
62
+
63
+ 1. In your browser, export your bookmarks as an HTML file (see below).
64
+ 2. Run `befave` and choose `1` (pull), or run `befave pull`.
65
+ 3. Enter the path to the exported file (`.html` or `.zip`).
66
+ 4. **Which database?** Choose `1` to use the default database (its path is
67
+ shown), or `2` to type the path to a different one.
68
+ 5. **Which source is this?** A source is one browser profile on one computer,
69
+ such as `work-chrome`. Pick an existing one from the list, or choose "Add a
70
+ new source" and type a name. Use the same name every time you pull from that
71
+ browser, because `befave` compares each pull with the previous one to spot
72
+ favorites you deleted.
73
+ 6. If asked to confirm deletions or restores, choose `1` (Yes) or `2` (No); see
74
+ [Deletions](#deletions).
75
+
76
+ **Push**: write the database out as a file a browser can import.
77
+
78
+ 1. Run `befave` and choose `2` (push), or run `befave push`.
79
+ 2. **Which browser will import this file?** Choose the browser. This only sets
80
+ the name of the toolbar folder in the file, so your toolbar favorites land
81
+ on that browser's real toolbar (see [What push does](#what-push-does)). It
82
+ does not change which favorites are included.
83
+ 3. Enter the path for the output `.html` file.
84
+ 4. Choose the database, as in pull.
85
+ 5. Choose the source the file is for, usually the browser profile you are about
86
+ to import it into. Pushing records what that source now has, so a favorite
87
+ you later delete there is recognized as a deletion and not re-added.
88
+ 6. In the browser, import the file with its bookmark manager's import option.
89
+
90
+ ### Getting an export file from your browser
91
+
92
+ Use the browser's bookmark manager export (HTML). Safari's export can be a
93
+ `.zip`, which `befave` opens itself. The browser is detected by reading the
94
+ file, so you never say which one it came from.
95
+
96
+ ### Database
97
+
98
+ The default is `~/.befave/favorites.db`; use `--db` to choose
99
+ another file. One table, `bookmarks`, holds the URL, title, folder path, add
100
+ and last-modified dates, tags (Firefox) and the icon (base64 data). The other
101
+ tables (`sources`, `snapshots`, `source_items`, `tombstones`) track deletions.
102
+
103
+ ### Using more than one computer
104
+
105
+ The database is a single file on one machine; `befave` does not sync over the
106
+ network. To share favorites between, say, a work and a home computer, keep the
107
+ database in a folder you already sync (Dropbox, iCloud Drive, a USB stick, etc.)
108
+ and point both computers at it with `--db`:
109
+
110
+ ```
111
+ befave pull --db ~/Dropbox/favorites.db --source work-chrome bookmarks.html
112
+ befave push out.html --browser chrome --source work-chrome --db ~/Dropbox/favorites.db
113
+ ```
114
+
115
+ Or copy `~/.befave/favorites.db` between machines. Use it from
116
+ one computer at a time, and let the sync finish before running `befave` on
117
+ another, so two copies don't diverge.
118
+
119
+ ## What pull does
120
+
121
+ - **No duplicates.** URLs are compared after lowercasing the scheme and host,
122
+ dropping the `#fragment`, and removing `utm_*` tracking parameters. The path
123
+ and the rest of the query keep their case. The first URL processed wins,
124
+ including across files and runs; the same URL in several folders is saved once.
125
+ - **Web links only.** Only `http`, `https` and `ftp` links are kept. Browser
126
+ internals such as `chrome://`, `about:`, `javascript:`, `moz-extension://`,
127
+ `place:` and `file://` are skipped.
128
+ - **Browser defaults are skipped.** Firefox's "Mozilla Firefox" folder (and its
129
+ stray help/customize links) and the 12 links Safari ships in Favorites.
130
+ The lists are `BUNDLED_FOLDERS` and `SAFARI_DEFAULTS` in `puller.py`.
131
+ - **Toolbar folders are normalized.** Chrome/Opera "Bookmarks Bar", Firefox
132
+ "Bookmarks Toolbar" and Safari "Favorites" are all stored as `Bookmarks Bar`.
133
+
134
+ ## Deletions
135
+
136
+ Every pull and push is tied to a **source**: one browser profile on one
137
+ computer (for example `work-chrome`). `befave` asks you to pick an existing
138
+ source or add a new one, or you can pass `--source NAME`.
139
+
140
+ Each pull is treated as the full list of that source's favorites. A favorite
141
+ that was in the source's previous pull but is missing now counts as deleted
142
+ and is removed from the database, so it is no longer pushed to your other
143
+ browsers. A deleted favorite is not brought back by a pull of another source
144
+ that still has it.
145
+
146
+ Two safeguards ask for confirmation (`pull --yes` accepts them):
147
+
148
+ - **Mass deletion:** if more than 30% of a source's favorites (for sources with
149
+ more than 10) are missing, it may be a partial export, such as a single folder.
150
+ - **Restoring:** a file contains a favorite that was deleted earlier and that
151
+ source did not have in its last pull. Declining keeps it deleted.
152
+
153
+ The first pull of a source never deletes anything.
154
+
155
+ ## What push does
156
+
157
+ Writes a Netscape-format HTML file with the folder hierarchy rebuilt. The
158
+ toolbar folder is named for the target browser (`--browser`):
159
+
160
+ | Browser | Toolbar folder |
161
+ |---|---|
162
+ | chrome, opera | Bookmarks Bar |
163
+ | firefox | Bookmarks Toolbar |
164
+ | safari | Favorites |
165
+
166
+ The file has no wrapper folder. Other root folders sit beside the toolbar.
167
+
168
+ ## Possible future feature: hosted sync
169
+
170
+ Today you share the database file yourself (see above). A paid hosted service
171
+ could do that for you: sign in, and your favorites sync between all your
172
+ computers and browsers without moving files around. This is only an idea and
173
+ nothing is built yet. If you would be interested, please get in touch at
174
+ pyguy411@gmail.com.
175
+
176
+ ## Development
177
+
178
+ Example exports live in `example_formats/`, named after their browser. They
179
+ contain real bookmarks and are git-ignored: **never commit sample bookmarks or
180
+ test databases.**
181
+
182
+ ```
183
+ uv run befave -h
184
+ uv build
185
+ ```
186
+
187
+ ## License
188
+
189
+ MIT. See [LICENSE](LICENSE). The software is provided "as is", without warranty of any kind.
befave-0.1.0/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # befave
2
+
3
+ Keep your favorites in sync across browsers. `befave` reads the bookmark export
4
+ from Chrome, Firefox, Safari or Opera, merges everything into one sqlite3
5
+ database with no duplicates, and writes it back out as a file any of those
6
+ browsers can import. Two commands do this: `pull` reads a browser's exported
7
+ file into the database, and `push` writes the database out as a file for a
8
+ browser to import. (Browsers call these steps "export" and "import"; `befave`
9
+ uses its own words to keep them apart.) Pull and push can be repeated in
10
+ cycles, so favorites can round trip between browsers.
11
+
12
+ ## Why
13
+
14
+ - Frustrated that the browser on your work computer doesn't have the same
15
+ favorites as your home computer? Export from each browser, pull both files
16
+ into `befave`, and push the merged set back to either one.
17
+ - Added new favorites in a new browser after you pulled your favorites from
18
+ another one? Export the new browser's bookmarks, pull them (only the new
19
+ ones are added), then push the full set to any browser.
20
+ - Switching browsers, or using several at once? Keep one master list and
21
+ load it into whichever browser you pick up.
22
+
23
+ ## Install
24
+
25
+ ```
26
+ uv tool install befave # once published
27
+ # or from a checkout
28
+ uv run befave
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run `befave` and it asks what it needs to know. Most questions are numbered
34
+ choices: type the number and press Enter. Anything you pass as an argument is
35
+ not asked again.
36
+
37
+ ```
38
+ befave # asks: pull or push?
39
+ befave pull # asks for the file(s), source and database
40
+ befave pull --db a.sqlite3 --source home-firefox example_formats/firefox_bookmarks.html
41
+ befave push # asks for the target browser, output file, source, database
42
+ befave push out.html --browser firefox --source home-firefox --db a.sqlite3
43
+ ```
44
+
45
+ When not run from a terminal, `befave` never prompts. It uses the default
46
+ database and fails with a message if something required is missing.
47
+
48
+ ### Step by step
49
+
50
+ **Pull**: read a browser's exported bookmarks file into the database.
51
+
52
+ 1. In your browser, export your bookmarks as an HTML file (see below).
53
+ 2. Run `befave` and choose `1` (pull), or run `befave pull`.
54
+ 3. Enter the path to the exported file (`.html` or `.zip`).
55
+ 4. **Which database?** Choose `1` to use the default database (its path is
56
+ shown), or `2` to type the path to a different one.
57
+ 5. **Which source is this?** A source is one browser profile on one computer,
58
+ such as `work-chrome`. Pick an existing one from the list, or choose "Add a
59
+ new source" and type a name. Use the same name every time you pull from that
60
+ browser, because `befave` compares each pull with the previous one to spot
61
+ favorites you deleted.
62
+ 6. If asked to confirm deletions or restores, choose `1` (Yes) or `2` (No); see
63
+ [Deletions](#deletions).
64
+
65
+ **Push**: write the database out as a file a browser can import.
66
+
67
+ 1. Run `befave` and choose `2` (push), or run `befave push`.
68
+ 2. **Which browser will import this file?** Choose the browser. This only sets
69
+ the name of the toolbar folder in the file, so your toolbar favorites land
70
+ on that browser's real toolbar (see [What push does](#what-push-does)). It
71
+ does not change which favorites are included.
72
+ 3. Enter the path for the output `.html` file.
73
+ 4. Choose the database, as in pull.
74
+ 5. Choose the source the file is for, usually the browser profile you are about
75
+ to import it into. Pushing records what that source now has, so a favorite
76
+ you later delete there is recognized as a deletion and not re-added.
77
+ 6. In the browser, import the file with its bookmark manager's import option.
78
+
79
+ ### Getting an export file from your browser
80
+
81
+ Use the browser's bookmark manager export (HTML). Safari's export can be a
82
+ `.zip`, which `befave` opens itself. The browser is detected by reading the
83
+ file, so you never say which one it came from.
84
+
85
+ ### Database
86
+
87
+ The default is `~/.befave/favorites.db`; use `--db` to choose
88
+ another file. One table, `bookmarks`, holds the URL, title, folder path, add
89
+ and last-modified dates, tags (Firefox) and the icon (base64 data). The other
90
+ tables (`sources`, `snapshots`, `source_items`, `tombstones`) track deletions.
91
+
92
+ ### Using more than one computer
93
+
94
+ The database is a single file on one machine; `befave` does not sync over the
95
+ network. To share favorites between, say, a work and a home computer, keep the
96
+ database in a folder you already sync (Dropbox, iCloud Drive, a USB stick, etc.)
97
+ and point both computers at it with `--db`:
98
+
99
+ ```
100
+ befave pull --db ~/Dropbox/favorites.db --source work-chrome bookmarks.html
101
+ befave push out.html --browser chrome --source work-chrome --db ~/Dropbox/favorites.db
102
+ ```
103
+
104
+ Or copy `~/.befave/favorites.db` between machines. Use it from
105
+ one computer at a time, and let the sync finish before running `befave` on
106
+ another, so two copies don't diverge.
107
+
108
+ ## What pull does
109
+
110
+ - **No duplicates.** URLs are compared after lowercasing the scheme and host,
111
+ dropping the `#fragment`, and removing `utm_*` tracking parameters. The path
112
+ and the rest of the query keep their case. The first URL processed wins,
113
+ including across files and runs; the same URL in several folders is saved once.
114
+ - **Web links only.** Only `http`, `https` and `ftp` links are kept. Browser
115
+ internals such as `chrome://`, `about:`, `javascript:`, `moz-extension://`,
116
+ `place:` and `file://` are skipped.
117
+ - **Browser defaults are skipped.** Firefox's "Mozilla Firefox" folder (and its
118
+ stray help/customize links) and the 12 links Safari ships in Favorites.
119
+ The lists are `BUNDLED_FOLDERS` and `SAFARI_DEFAULTS` in `puller.py`.
120
+ - **Toolbar folders are normalized.** Chrome/Opera "Bookmarks Bar", Firefox
121
+ "Bookmarks Toolbar" and Safari "Favorites" are all stored as `Bookmarks Bar`.
122
+
123
+ ## Deletions
124
+
125
+ Every pull and push is tied to a **source**: one browser profile on one
126
+ computer (for example `work-chrome`). `befave` asks you to pick an existing
127
+ source or add a new one, or you can pass `--source NAME`.
128
+
129
+ Each pull is treated as the full list of that source's favorites. A favorite
130
+ that was in the source's previous pull but is missing now counts as deleted
131
+ and is removed from the database, so it is no longer pushed to your other
132
+ browsers. A deleted favorite is not brought back by a pull of another source
133
+ that still has it.
134
+
135
+ Two safeguards ask for confirmation (`pull --yes` accepts them):
136
+
137
+ - **Mass deletion:** if more than 30% of a source's favorites (for sources with
138
+ more than 10) are missing, it may be a partial export, such as a single folder.
139
+ - **Restoring:** a file contains a favorite that was deleted earlier and that
140
+ source did not have in its last pull. Declining keeps it deleted.
141
+
142
+ The first pull of a source never deletes anything.
143
+
144
+ ## What push does
145
+
146
+ Writes a Netscape-format HTML file with the folder hierarchy rebuilt. The
147
+ toolbar folder is named for the target browser (`--browser`):
148
+
149
+ | Browser | Toolbar folder |
150
+ |---|---|
151
+ | chrome, opera | Bookmarks Bar |
152
+ | firefox | Bookmarks Toolbar |
153
+ | safari | Favorites |
154
+
155
+ The file has no wrapper folder. Other root folders sit beside the toolbar.
156
+
157
+ ## Possible future feature: hosted sync
158
+
159
+ Today you share the database file yourself (see above). A paid hosted service
160
+ could do that for you: sign in, and your favorites sync between all your
161
+ computers and browsers without moving files around. This is only an idea and
162
+ nothing is built yet. If you would be interested, please get in touch at
163
+ pyguy411@gmail.com.
164
+
165
+ ## Development
166
+
167
+ Example exports live in `example_formats/`, named after their browser. They
168
+ contain real bookmarks and are git-ignored: **never commit sample bookmarks or
169
+ test databases.**
170
+
171
+ ```
172
+ uv run befave -h
173
+ uv build
174
+ ```
175
+
176
+ ## License
177
+
178
+ MIT. See [LICENSE](LICENSE). The software is provided "as is", without warranty of any kind.
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "befave"
3
+ version = "0.1.0"
4
+ description = "Keep favorites in sync across Chrome, Firefox, Safari and Opera via a deduplicated sqlite3 database"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.9"
9
+ dependencies = []
10
+
11
+ [[project.authors]]
12
+ name = "David Jackson"
13
+ email = "pyguy411@gmail.com"
14
+
15
+ [project.scripts]
16
+ befave = "befave.cli:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.12.3,<0.13.0"]
20
+ build-backend = "uv_build"
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "befave"
3
+ version = "0.1.0"
4
+ description = "Keep favorites in sync across Chrome, Firefox, Safari and Opera via a deduplicated sqlite3 database"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "David Jackson", email = "pyguy411@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+
14
+ [project.scripts]
15
+ befave = "befave.cli:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12.3,<0.13.0"]
19
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ """Pull favorites from any browser's bookmark export and push them to any browser."""
@@ -0,0 +1,150 @@
1
+ """befave: interactive wrapper around the puller and pusher."""
2
+ import argparse
3
+ import os
4
+ import shlex
5
+ import sys
6
+
7
+ from .pusher import BROWSER_TOOLBAR, run_push
8
+ from .puller import DEFAULT_DB, list_sources, run_pull
9
+
10
+ INTERACTIVE = sys.stdin.isatty()
11
+
12
+
13
+ def ask(question, default=None, choices=None):
14
+ """Prompt until a non-empty answer (or the default) is given."""
15
+ hint = f" [{'/'.join(choices)}]" if choices else ""
16
+ hint += f" (default: {default})" if default else ""
17
+ while True:
18
+ try:
19
+ answer = input(f"{question}{hint}: ").strip()
20
+ except EOFError:
21
+ raise SystemExit("\naborted")
22
+ answer = answer or default
23
+ if answer and (not choices or answer.lower() in choices):
24
+ return answer.lower() if choices else answer
25
+ print(" please enter a valid answer")
26
+
27
+
28
+ def choose(question, options):
29
+ """Show numbered options and return the index of the one picked."""
30
+ print(question)
31
+ for i, option in enumerate(options, 1):
32
+ print(f" {i}. {option}")
33
+ numbers = [str(i) for i in range(1, len(options) + 1)]
34
+ return int(ask("Choose a number", choices=numbers)) - 1
35
+
36
+
37
+ def need(value, missing_msg, prompt, **kw):
38
+ """Return value if given; otherwise prompt, or fail when not interactive."""
39
+ if value:
40
+ return value
41
+ if not INTERACTIVE:
42
+ raise SystemExit(f"befave: error: {missing_msg}")
43
+ return ask(prompt, **kw)
44
+
45
+
46
+ def pick_db(db):
47
+ if db:
48
+ return db
49
+ if not INTERACTIVE:
50
+ return DEFAULT_DB
51
+ if choose("Which database should be used?",
52
+ [f"Use the default database: {DEFAULT_DB}", "Use a different database"]) == 0:
53
+ return DEFAULT_DB
54
+ return ask("Path to the database file")
55
+
56
+
57
+ def pick_browser(browser):
58
+ if browser:
59
+ return browser
60
+ if not INTERACTIVE:
61
+ raise SystemExit("befave: error: --browser is required")
62
+ names = sorted(BROWSER_TOOLBAR)
63
+ return names[choose("Which browser will import this file?", names)]
64
+
65
+
66
+ def pick_source(db, given=None):
67
+ """Return the named source, or let the user pick an existing one or add a new one."""
68
+ if given:
69
+ return given
70
+ if not INTERACTIVE:
71
+ raise SystemExit("befave: error: --source is required")
72
+ existing = list_sources(db)
73
+ if existing:
74
+ i = choose("Which source is this? (one browser profile on one computer)",
75
+ existing + ["Add a new source"])
76
+ if i < len(existing):
77
+ return existing[i]
78
+ else:
79
+ print("No sources yet. A source is one browser profile on one computer.")
80
+ return ask("Name for the new source (e.g. work-chrome)")
81
+
82
+
83
+ def confirm(msg):
84
+ if not INTERACTIVE:
85
+ print(f" {msg} (not confirmed; pass --yes to accept)")
86
+ return False
87
+ return choose(msg, ["Yes", "No"]) == 0
88
+
89
+
90
+ def do_pull(args):
91
+ files = args.files
92
+ if not files:
93
+ if not INTERACTIVE:
94
+ raise SystemExit("befave: error: no bookmark files given")
95
+ files = shlex.split(ask("Browser export file(s) to pull (.html or .zip, space separated)"))
96
+ db = pick_db(args.db)
97
+ db = os.path.expanduser(db)
98
+ return run_pull([os.path.expanduser(f) for f in files], db,
99
+ lambda path: pick_source(db, args.source), args.yes, confirm)
100
+
101
+
102
+ def do_push(args):
103
+ browser = pick_browser(args.browser)
104
+ output = need(args.output, "output file is required", "Output file (.html)")
105
+ db = pick_db(args.db)
106
+ db = os.path.expanduser(db)
107
+ return run_push(os.path.expanduser(output), browser, db, pick_source(db, args.source))
108
+
109
+
110
+ def build_parser():
111
+ ap = argparse.ArgumentParser(
112
+ prog="befave",
113
+ description="Pull browser bookmark export files into a database, or push the "
114
+ "database out as a file a browser can import. Any missing details "
115
+ "are asked interactively.")
116
+ sub = ap.add_subparsers(dest="action", metavar="{pull,push}")
117
+
118
+ pull = sub.add_parser("pull", help="pull browser export file(s) into the database")
119
+ pull.add_argument("files", nargs="*", help="bookmark export(s): .html or .zip")
120
+ pull.add_argument("--db", help=f"database file (default: {DEFAULT_DB})")
121
+ pull.add_argument("--source", help="name of the browser profile the file(s) come from")
122
+ pull.add_argument("--yes", action="store_true",
123
+ help="accept deletion and restore confirmations")
124
+ pull.set_defaults(func=do_pull)
125
+
126
+ push = sub.add_parser("push", help="push the database out as a file for a target browser")
127
+ push.add_argument("output", nargs="?", help="output .html file")
128
+ push.add_argument("--browser", choices=sorted(BROWSER_TOOLBAR), help="target browser")
129
+ push.add_argument("--db", help=f"database file (default: {DEFAULT_DB})")
130
+ push.add_argument("--source", help="name of the browser profile the file is for")
131
+ push.set_defaults(func=do_push)
132
+ return ap
133
+
134
+
135
+ def main(argv=None):
136
+ ap = build_parser()
137
+ args = ap.parse_args(argv)
138
+ if not args.action:
139
+ if not INTERACTIVE:
140
+ ap.error("an action (pull or push) is required")
141
+ action = ["pull", "push"][choose("What would you like to do?", [
142
+ "pull - read a browser's exported bookmarks file into the database",
143
+ "push - write the database out as a file for a browser to import",
144
+ ])]
145
+ args = ap.parse_args([action])
146
+ return args.func(args)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(main())
@@ -0,0 +1,302 @@
1
+ """Pull Netscape-format bookmark exports (.html or .zip) into a sqlite3 db."""
2
+ import os
3
+ import re
4
+ import sqlite3
5
+ import sys
6
+ import time
7
+ import zipfile
8
+ from html.parser import HTMLParser
9
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
+
11
+ DEFAULT_DB = os.path.join(os.path.expanduser("~"), ".befave", "favorites.db")
12
+ WEB_SCHEMES = ("http", "https", "ftp")
13
+
14
+ # Folders browsers ship by default; the whole subtree is skipped.
15
+ BUNDLED_FOLDERS = {"mozilla firefox"}
16
+ # Links Safari pre-populates in its top-level Favorites folder.
17
+ SAFARI_DEFAULTS = {
18
+ "https://www.apple.com/", "https://www.icloud.com/", "https://www.yahoo.com/",
19
+ "https://www.bing.com/", "https://www.google.com/?client=safari&channel=mac_bm",
20
+ "https://www.wikipedia.org/", "https://www.facebook.com/", "https://twitter.com/",
21
+ "https://www.linkedin.com/", "https://www.weather.com/", "https://www.yelp.com/",
22
+ "https://www.tripadvisor.com/",
23
+ }
24
+
25
+ BUNDLED_URL = re.compile(r"^https://www\.mozilla\.org/(?:[\w-]+/)?firefox/(?:customize|help)/?$")
26
+
27
+ # All browsers' toolbar folders are stored under this one name.
28
+ TOOLBAR = "Bookmarks Bar"
29
+
30
+ SCHEMA = """
31
+ CREATE TABLE IF NOT EXISTS bookmarks (
32
+ id INTEGER PRIMARY KEY,
33
+ url_norm TEXT NOT NULL UNIQUE,
34
+ url TEXT NOT NULL,
35
+ title TEXT,
36
+ folder TEXT,
37
+ add_date INTEGER,
38
+ last_modified INTEGER,
39
+ tags TEXT,
40
+ icon TEXT
41
+ );
42
+ CREATE TABLE IF NOT EXISTS sources (
43
+ id INTEGER PRIMARY KEY,
44
+ name TEXT NOT NULL UNIQUE
45
+ );
46
+ CREATE TABLE IF NOT EXISTS snapshots (
47
+ id INTEGER PRIMARY KEY,
48
+ source_id INTEGER NOT NULL REFERENCES sources(id),
49
+ taken_at INTEGER NOT NULL,
50
+ kind TEXT NOT NULL CHECK (kind IN ('pull', 'push'))
51
+ );
52
+ CREATE TABLE IF NOT EXISTS source_items (
53
+ snapshot_id INTEGER NOT NULL REFERENCES snapshots(id),
54
+ url_norm TEXT NOT NULL,
55
+ PRIMARY KEY (snapshot_id, url_norm)
56
+ );
57
+ CREATE TABLE IF NOT EXISTS tombstones (
58
+ url_norm TEXT PRIMARY KEY,
59
+ source_id INTEGER NOT NULL REFERENCES sources(id),
60
+ deleted_at INTEGER NOT NULL
61
+ );
62
+ """
63
+
64
+ # Guard against partial exports being mistaken for mass deletion.
65
+ MASS_DELETE_FRACTION = 0.3
66
+ MASS_DELETE_MIN_ITEMS = 10
67
+
68
+
69
+ def normalize_url(url):
70
+ """Lowercase scheme/host, drop fragment, drop utm_* query parameters."""
71
+ url = url.strip()
72
+ try:
73
+ parts = urlsplit(url)
74
+ except ValueError:
75
+ return url
76
+ if not parts.scheme or not parts.netloc:
77
+ return urlunsplit((parts.scheme.lower(), parts.netloc, parts.path, parts.query, ""))
78
+ host = parts.netloc
79
+ if "@" in host:
80
+ userinfo, _, hostport = host.rpartition("@")
81
+ host = userinfo + "@" + hostport.lower()
82
+ else:
83
+ host = host.lower()
84
+ query = parts.query
85
+ if "utm_" in query.lower():
86
+ pairs = parse_qsl(query, keep_blank_values=True)
87
+ query = urlencode([(k, v) for k, v in pairs if not k.lower().startswith("utm_")])
88
+ return urlunsplit((parts.scheme.lower(), host, parts.path, query, ""))
89
+
90
+
91
+ def to_int(value):
92
+ try:
93
+ return int(value)
94
+ except (TypeError, ValueError):
95
+ return None
96
+
97
+
98
+ class BookmarkParser(HTMLParser):
99
+ """Walks <DL>/<H3>/<A> nesting; yields dicts in file order."""
100
+
101
+ def __init__(self):
102
+ super().__init__(convert_charrefs=True)
103
+ self.bookmarks = []
104
+ self.folders = []
105
+ self.pending_folder = None
106
+ self.capture = None # "h3" or "a"
107
+ self.buf = []
108
+ self.attrs = {}
109
+ self.folder_attrs = {}
110
+
111
+ def handle_starttag(self, tag, attrs):
112
+ if tag == "h3":
113
+ self.capture, self.buf = "h3", []
114
+ self.folder_attrs = {k.lower(): v for k, v in attrs}
115
+ elif tag == "a":
116
+ self.capture, self.buf = "a", []
117
+ self.attrs = {k.lower(): v for k, v in attrs}
118
+ elif tag == "dl":
119
+ self.folders.append(self.pending_folder)
120
+ self.pending_folder = None
121
+
122
+ def handle_endtag(self, tag):
123
+ if tag == "h3" and self.capture == "h3":
124
+ name = "".join(self.buf).strip()
125
+ top_level = not any(self.folders)
126
+ if top_level and (
127
+ self.folder_attrs.get("personal_toolbar_folder", "").lower() == "true"
128
+ or name == "Favorites" # Safari's toolbar folder
129
+ ):
130
+ name = TOOLBAR
131
+ self.pending_folder = name
132
+ self.capture = None
133
+ elif tag == "a" and self.capture == "a":
134
+ self._finish_link()
135
+ elif tag == "dl" and self.folders:
136
+ self.folders.pop()
137
+
138
+ def handle_data(self, data):
139
+ if self.capture:
140
+ self.buf.append(data)
141
+
142
+ def _finish_link(self):
143
+ a = self.attrs
144
+ self.capture = None
145
+ url = a.get("href")
146
+ if not url or not url.strip():
147
+ return
148
+ self.bookmarks.append({
149
+ "url": url.strip(),
150
+ "title": "".join(self.buf).strip(),
151
+ "folder": "/".join(f for f in self.folders if f),
152
+ "add_date": to_int(a.get("add_date")),
153
+ "last_modified": to_int(a.get("last_modified")),
154
+ "tags": a.get("tags"),
155
+ "icon": a.get("icon"),
156
+ })
157
+
158
+
159
+ def is_bundled(b):
160
+ if any(p.lower() in BUNDLED_FOLDERS for p in b["folder"].split("/")):
161
+ return True
162
+ if BUNDLED_URL.match(b["url"]):
163
+ return True
164
+ return b["folder"] == TOOLBAR and b["url"] in SAFARI_DEFAULTS
165
+
166
+
167
+ def read_export(path):
168
+ """Return the HTML text of an export file, unzipping if needed."""
169
+ if zipfile.is_zipfile(path):
170
+ with zipfile.ZipFile(path) as z:
171
+ names = [n for n in z.namelist()
172
+ if n.lower().endswith((".html", ".htm")) and not n.startswith("__MACOSX")]
173
+ if not names:
174
+ raise ValueError(f"no html file found in {path}")
175
+ return z.read(names[0]).decode("utf-8", errors="replace")
176
+ with open(path, encoding="utf-8", errors="replace") as f:
177
+ return f.read()
178
+
179
+
180
+ def parse_export(path):
181
+ text = read_export(path)
182
+ if "NETSCAPE-BOOKMARK-FILE-1" not in text[:500].upper():
183
+ raise ValueError(f"{path} is not a Netscape bookmark export")
184
+ parser = BookmarkParser()
185
+ parser.feed(text)
186
+ parser.close()
187
+ return parser.bookmarks
188
+
189
+
190
+ def source_id(conn, name):
191
+ conn.execute("INSERT OR IGNORE INTO sources (name) VALUES (?)", (name,))
192
+ return conn.execute("SELECT id FROM sources WHERE name = ?", (name,)).fetchone()[0]
193
+
194
+
195
+ def baseline_items(conn, sid):
196
+ """URLs in the source's latest snapshot (None if it has none)."""
197
+ row = conn.execute(
198
+ "SELECT id FROM snapshots WHERE source_id = ? ORDER BY id DESC LIMIT 1", (sid,)
199
+ ).fetchone()
200
+ if not row:
201
+ return None
202
+ return {r[0] for r in conn.execute(
203
+ "SELECT url_norm FROM source_items WHERE snapshot_id = ?", (row[0],))}
204
+
205
+
206
+ def record_snapshot(conn, sid, kind, items):
207
+ cur = conn.execute(
208
+ "INSERT INTO snapshots (source_id, taken_at, kind) VALUES (?,?,?)",
209
+ (sid, int(time.time()), kind))
210
+ conn.executemany("INSERT INTO source_items VALUES (?,?)",
211
+ [(cur.lastrowid, u) for u in items])
212
+
213
+
214
+ def list_sources(db):
215
+ if not os.path.exists(db):
216
+ return []
217
+ conn = sqlite3.connect(db)
218
+ try:
219
+ return [r[0] for r in conn.execute("SELECT name FROM sources ORDER BY name")]
220
+ except sqlite3.OperationalError:
221
+ return []
222
+ finally:
223
+ conn.close()
224
+
225
+
226
+ def import_file(conn, path, source, assume_yes=False, confirm=None):
227
+ """Pull one browser export file as a full snapshot of `source`.
228
+
229
+ Returns (found, added, deleted). Raises ValueError if a confirmation is declined.
230
+ """
231
+ confirm = confirm or (lambda msg: False)
232
+ ok = lambda msg: assume_yes or confirm(msg)
233
+ rows = {}
234
+ for b in parse_export(path):
235
+ if is_bundled(b) or urlsplit(b["url"]).scheme.lower() not in WEB_SCHEMES:
236
+ continue
237
+ rows.setdefault(normalize_url(b["url"]), b)
238
+ items = set(rows)
239
+
240
+ sid = source_id(conn, source)
241
+ baseline = baseline_items(conn, sid) or set()
242
+ vanished = baseline - items
243
+ if (len(baseline) > MASS_DELETE_MIN_ITEMS
244
+ and len(vanished) / len(baseline) > MASS_DELETE_FRACTION
245
+ and not ok(f"{len(vanished)} of {len(baseline)} bookmarks from '{source}' are "
246
+ "missing from this file and would be deleted everywhere. "
247
+ "Is this a partial export? Delete them anyway?")):
248
+ raise ValueError(f"{path}: pull cancelled (too many deletions)")
249
+
250
+ tombstoned = {r[0] for r in conn.execute("SELECT url_norm FROM tombstones")}
251
+ # A tombstoned URL the source already had is just a stale copy; one it did not
252
+ # have before was re-added (or comes from a source never pulled before).
253
+ revived = (items & tombstoned) - baseline
254
+ if revived and not ok(f"{len(revived)} bookmark(s) in this file were deleted earlier. "
255
+ "Restore them?"):
256
+ revived = set()
257
+ blocked = (items & tombstoned) - revived
258
+
259
+ now = int(time.time())
260
+ for url in vanished:
261
+ conn.execute("INSERT OR REPLACE INTO tombstones VALUES (?,?,?)", (url, sid, now))
262
+ conn.execute("DELETE FROM bookmarks WHERE url_norm = ?", (url,))
263
+ for url in revived:
264
+ conn.execute("DELETE FROM tombstones WHERE url_norm = ?", (url,))
265
+ added = 0
266
+ for url in items - blocked:
267
+ b = rows[url]
268
+ cur = conn.execute(
269
+ "INSERT OR IGNORE INTO bookmarks "
270
+ "(url_norm, url, title, folder, add_date, last_modified, tags, icon) "
271
+ "VALUES (?,?,?,?,?,?,?,?)",
272
+ (url, b["url"], b["title"], b["folder"],
273
+ b["add_date"], b["last_modified"], b["tags"], b["icon"]),
274
+ )
275
+ added += cur.rowcount
276
+ record_snapshot(conn, sid, "pull", items)
277
+ conn.commit()
278
+ return len(rows), added, len(vanished)
279
+
280
+
281
+ def run_pull(files, db, source=None, assume_yes=False, confirm=None):
282
+ """Pull each file into db as a snapshot of `source`; returns an exit status.
283
+
284
+ `source` may be a name or a callable taking the file path.
285
+ """
286
+ os.makedirs(os.path.dirname(os.path.abspath(db)), exist_ok=True)
287
+ conn = sqlite3.connect(db)
288
+ conn.executescript(SCHEMA)
289
+ status = 0
290
+ for path in files:
291
+ try:
292
+ name = source(path) if callable(source) else source
293
+ found, added, deleted = import_file(conn, path, name, assume_yes, confirm)
294
+ except (OSError, ValueError, zipfile.BadZipFile) as e:
295
+ conn.rollback()
296
+ print(f"error: {e}", file=sys.stderr)
297
+ status = 1
298
+ continue
299
+ print(f"{path}: {found} found, {added} new, {found - added} duplicates skipped, "
300
+ f"{deleted} deleted")
301
+ conn.close()
302
+ return status
@@ -0,0 +1,84 @@
1
+ """Push the favorites db to a Netscape bookmark HTML file that any browser can import."""
2
+ import os
3
+ import sqlite3
4
+ import sys
5
+ from html import escape
6
+
7
+ from .puller import SCHEMA, TOOLBAR, baseline_items, record_snapshot, source_id
8
+
9
+ # Name each browser uses for its toolbar folder on import.
10
+ BROWSER_TOOLBAR = {
11
+ "chrome": "Bookmarks Bar",
12
+ "opera": "Bookmarks Bar",
13
+ "firefox": "Bookmarks Toolbar",
14
+ "safari": "Favorites",
15
+ }
16
+
17
+
18
+ def build_tree(rows):
19
+ root = {"folders": {}, "links": []}
20
+ for row in rows:
21
+ node = root
22
+ for name in filter(None, (row["folder"] or "").split("/")):
23
+ node = node["folders"].setdefault(name, {"folders": {}, "links": []})
24
+ node["links"].append(row)
25
+ return root
26
+
27
+
28
+ def attr(name, value):
29
+ return f' {name}="{escape(str(value), quote=True)}"' if value not in (None, "") else ""
30
+
31
+
32
+ def render(node, out, depth, toolbar_name=None):
33
+ pad = " " * depth
34
+ out.append(f"{pad}<DL><p>")
35
+ for name, child in node["folders"].items():
36
+ if toolbar_name and name == TOOLBAR:
37
+ flag = "" if toolbar_name == "Favorites" else ' PERSONAL_TOOLBAR_FOLDER="true"'
38
+ out.append(f"{pad} <DT><H3{flag}>{escape(toolbar_name)}</H3>")
39
+ else:
40
+ out.append(f"{pad} <DT><H3>{escape(name)}</H3>")
41
+ render(child, out, depth + 1)
42
+ for r in node["links"]:
43
+ out.append(
44
+ f"{pad} <DT><A{attr('HREF', r['url'])}{attr('ADD_DATE', r['add_date'])}"
45
+ f"{attr('LAST_MODIFIED', r['last_modified'])}{attr('TAGS', r['tags'])}"
46
+ f"{attr('ICON', r['icon'])}>{escape(r['title'] or r['url'])}</A>"
47
+ )
48
+ out.append(f"{pad}</DL><p>")
49
+
50
+
51
+ def push(db, path, browser, source):
52
+ conn = sqlite3.connect(db)
53
+ conn.executescript(SCHEMA)
54
+ conn.row_factory = sqlite3.Row
55
+ rows = conn.execute("SELECT * FROM bookmarks ORDER BY id").fetchall()
56
+ out = [
57
+ "<!DOCTYPE NETSCAPE-Bookmark-file-1>",
58
+ '<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
59
+ "<TITLE>Bookmarks</TITLE>",
60
+ "<H1>Bookmarks</H1>",
61
+ ]
62
+ root = build_tree(rows)
63
+ if TOOLBAR in root["folders"]: # toolbar first
64
+ root["folders"] = {TOOLBAR: root["folders"].pop(TOOLBAR), **root["folders"]}
65
+ render(root, out, 0, BROWSER_TOOLBAR[browser])
66
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
67
+ f.write("\n".join(out) + "\n")
68
+ # Browsers add imported bookmarks to what they already have, so the source's
69
+ # new baseline is its previous one plus everything just pushed.
70
+ sid = source_id(conn, source)
71
+ record_snapshot(conn, sid, "push",
72
+ (baseline_items(conn, sid) or set()) | {r["url_norm"] for r in rows})
73
+ conn.commit()
74
+ conn.close()
75
+ return len(rows)
76
+
77
+
78
+ def run_push(output, browser, db, source):
79
+ """Push db to output for the target browser; returns a process exit status."""
80
+ if not os.path.exists(db):
81
+ print(f"error: database not found: {db}", file=sys.stderr)
82
+ return 1
83
+ print(f"{output}: {push(db, output, browser, source)} bookmarks pushed")
84
+ return 0