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 +21 -0
- befave-0.1.0/PKG-INFO +189 -0
- befave-0.1.0/README.md +178 -0
- befave-0.1.0/pyproject.toml +20 -0
- befave-0.1.0/pyproject.toml.orig +19 -0
- befave-0.1.0/src/befave/__init__.py +1 -0
- befave-0.1.0/src/befave/cli.py +150 -0
- befave-0.1.0/src/befave/puller.py +302 -0
- befave-0.1.0/src/befave/pusher.py +84 -0
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
|