linkfolio 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.
- linkfolio-0.1.0/LICENSE +21 -0
- linkfolio-0.1.0/PKG-INFO +73 -0
- linkfolio-0.1.0/README.md +52 -0
- linkfolio-0.1.0/linkfolio.egg-info/PKG-INFO +73 -0
- linkfolio-0.1.0/linkfolio.egg-info/SOURCES.txt +10 -0
- linkfolio-0.1.0/linkfolio.egg-info/dependency_links.txt +1 -0
- linkfolio-0.1.0/linkfolio.egg-info/entry_points.txt +2 -0
- linkfolio-0.1.0/linkfolio.egg-info/requires.txt +1 -0
- linkfolio-0.1.0/linkfolio.egg-info/top_level.txt +1 -0
- linkfolio-0.1.0/linkfolio.py +525 -0
- linkfolio-0.1.0/pyproject.toml +33 -0
- linkfolio-0.1.0/setup.cfg +4 -0
linkfolio-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Bixby
|
|
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.
|
linkfolio-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkfolio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Netscape bookmarks into a newspaper-style homepage
|
|
5
|
+
Author-email: Eric Bixby <eric.k.bixby@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eric-bixby/linkfolio
|
|
8
|
+
Project-URL: Repository, https://github.com/eric-bixby/linkfolio
|
|
9
|
+
Keywords: bookmarks,netscape,homepage,html,firefox
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: bookmarks_parser
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# linkfolio
|
|
23
|
+
|
|
24
|
+
Convert Netscape bookmarks into a newspaper-style homepage.
|
|
25
|
+
|
|
26
|
+
`linkfolio.py` reads a browser bookmarks export (Netscape HTML format) and
|
|
27
|
+
generates a self-contained, dark-mode `index.html`: a fixed toolbar with hover
|
|
28
|
+
drop-downs built from your Bookmarks Toolbar, plus index/main tables built from
|
|
29
|
+
your Bookmarks Menu folders. Favicons are fetched and embedded inline, so the
|
|
30
|
+
generated page needs no network or external files to display.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -r requirements.txt
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
First, export your bookmarks from your browser to an HTML file (in Firefox:
|
|
43
|
+
**Bookmarks → Manage Bookmarks → Import and Backup → Export Bookmarks to HTML**).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
./linkfolio.py bookmarks.html # read bookmarks.html -> write index.html
|
|
47
|
+
./linkfolio.py # same, using the default bookmarks.html
|
|
48
|
+
./linkfolio.py export.html -o home.html # custom input and output filenames
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Open the generated `index.html` in any browser.
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
| Option | Description |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `input` | Bookmarks export to read (default: `bookmarks.html`). |
|
|
58
|
+
| `-o`, `--output` | HTML page to write (default: `index.html`). |
|
|
59
|
+
| `--no-icons` | Skip fetching missing favicons from the network (fast, works offline). |
|
|
60
|
+
| `--preserve-input` | Don't overwrite the input file with the sorted export; write `bookmarks-sorted.html` instead. |
|
|
61
|
+
|
|
62
|
+
By default linkfolio also rewrites the input file: it saves your bookmarks back in
|
|
63
|
+
Netscape format, sorted (folders first, then alphabetical) and with the fetched
|
|
64
|
+
favicons embedded. Keeping this sorted export means later runs reuse the icons
|
|
65
|
+
instead of re-downloading them. Use `--preserve-input` if you'd rather leave the
|
|
66
|
+
original export untouched.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -r requirements.txt # installs bookmarks_parser and pytest
|
|
72
|
+
python3 -m pytest # run the test suite (fully offline)
|
|
73
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# linkfolio
|
|
2
|
+
|
|
3
|
+
Convert Netscape bookmarks into a newspaper-style homepage.
|
|
4
|
+
|
|
5
|
+
`linkfolio.py` reads a browser bookmarks export (Netscape HTML format) and
|
|
6
|
+
generates a self-contained, dark-mode `index.html`: a fixed toolbar with hover
|
|
7
|
+
drop-downs built from your Bookmarks Toolbar, plus index/main tables built from
|
|
8
|
+
your Bookmarks Menu folders. Favicons are fetched and embedded inline, so the
|
|
9
|
+
generated page needs no network or external files to display.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -r requirements.txt
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10+.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
First, export your bookmarks from your browser to an HTML file (in Firefox:
|
|
22
|
+
**Bookmarks → Manage Bookmarks → Import and Backup → Export Bookmarks to HTML**).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
./linkfolio.py bookmarks.html # read bookmarks.html -> write index.html
|
|
26
|
+
./linkfolio.py # same, using the default bookmarks.html
|
|
27
|
+
./linkfolio.py export.html -o home.html # custom input and output filenames
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Open the generated `index.html` in any browser.
|
|
31
|
+
|
|
32
|
+
### Options
|
|
33
|
+
|
|
34
|
+
| Option | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `input` | Bookmarks export to read (default: `bookmarks.html`). |
|
|
37
|
+
| `-o`, `--output` | HTML page to write (default: `index.html`). |
|
|
38
|
+
| `--no-icons` | Skip fetching missing favicons from the network (fast, works offline). |
|
|
39
|
+
| `--preserve-input` | Don't overwrite the input file with the sorted export; write `bookmarks-sorted.html` instead. |
|
|
40
|
+
|
|
41
|
+
By default linkfolio also rewrites the input file: it saves your bookmarks back in
|
|
42
|
+
Netscape format, sorted (folders first, then alphabetical) and with the fetched
|
|
43
|
+
favicons embedded. Keeping this sorted export means later runs reuse the icons
|
|
44
|
+
instead of re-downloading them. Use `--preserve-input` if you'd rather leave the
|
|
45
|
+
original export untouched.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -r requirements.txt # installs bookmarks_parser and pytest
|
|
51
|
+
python3 -m pytest # run the test suite (fully offline)
|
|
52
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkfolio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Netscape bookmarks into a newspaper-style homepage
|
|
5
|
+
Author-email: Eric Bixby <eric.k.bixby@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eric-bixby/linkfolio
|
|
8
|
+
Project-URL: Repository, https://github.com/eric-bixby/linkfolio
|
|
9
|
+
Keywords: bookmarks,netscape,homepage,html,firefox
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: bookmarks_parser
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# linkfolio
|
|
23
|
+
|
|
24
|
+
Convert Netscape bookmarks into a newspaper-style homepage.
|
|
25
|
+
|
|
26
|
+
`linkfolio.py` reads a browser bookmarks export (Netscape HTML format) and
|
|
27
|
+
generates a self-contained, dark-mode `index.html`: a fixed toolbar with hover
|
|
28
|
+
drop-downs built from your Bookmarks Toolbar, plus index/main tables built from
|
|
29
|
+
your Bookmarks Menu folders. Favicons are fetched and embedded inline, so the
|
|
30
|
+
generated page needs no network or external files to display.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -r requirements.txt
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
First, export your bookmarks from your browser to an HTML file (in Firefox:
|
|
43
|
+
**Bookmarks → Manage Bookmarks → Import and Backup → Export Bookmarks to HTML**).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
./linkfolio.py bookmarks.html # read bookmarks.html -> write index.html
|
|
47
|
+
./linkfolio.py # same, using the default bookmarks.html
|
|
48
|
+
./linkfolio.py export.html -o home.html # custom input and output filenames
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Open the generated `index.html` in any browser.
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
| Option | Description |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `input` | Bookmarks export to read (default: `bookmarks.html`). |
|
|
58
|
+
| `-o`, `--output` | HTML page to write (default: `index.html`). |
|
|
59
|
+
| `--no-icons` | Skip fetching missing favicons from the network (fast, works offline). |
|
|
60
|
+
| `--preserve-input` | Don't overwrite the input file with the sorted export; write `bookmarks-sorted.html` instead. |
|
|
61
|
+
|
|
62
|
+
By default linkfolio also rewrites the input file: it saves your bookmarks back in
|
|
63
|
+
Netscape format, sorted (folders first, then alphabetical) and with the fetched
|
|
64
|
+
favicons embedded. Keeping this sorted export means later runs reuse the icons
|
|
65
|
+
instead of re-downloading them. Use `--preserve-input` if you'd rather leave the
|
|
66
|
+
original export untouched.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -r requirements.txt # installs bookmarks_parser and pytest
|
|
72
|
+
python3 -m pytest # run the test suite (fully offline)
|
|
73
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
linkfolio.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
linkfolio.egg-info/PKG-INFO
|
|
6
|
+
linkfolio.egg-info/SOURCES.txt
|
|
7
|
+
linkfolio.egg-info/dependency_links.txt
|
|
8
|
+
linkfolio.egg-info/entry_points.txt
|
|
9
|
+
linkfolio.egg-info/requires.txt
|
|
10
|
+
linkfolio.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bookmarks_parser
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linkfolio
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate **index.html**: a dark‑mode bookmarks page from a Netscape‑format export.
|
|
4
|
+
|
|
5
|
+
Layout
|
|
6
|
+
----
|
|
7
|
+
* **Horizontal toolbar** fixed at the top, built from the *Bookmarks Toolbar*
|
|
8
|
+
root. First‑level folders behave like menu buttons; hovering reveals a drop‑down
|
|
9
|
+
list. Bookmarks that aren't inside a folder are collected into a pseudo‑folder
|
|
10
|
+
with the toolbar's title.
|
|
11
|
+
* **Index/Main tables** built from *Bookmarks Menu* folders (if any). If the
|
|
12
|
+
menu contains no folders the page still renders—just without the tables.
|
|
13
|
+
* Prints clear error messages (✖) and exits with *non‑zero* status on fatal
|
|
14
|
+
problems (parse failure, missing roots, I/O errors).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# pip install bookmarks_parser
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import base64
|
|
23
|
+
import html
|
|
24
|
+
import itertools
|
|
25
|
+
import pathlib
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import urllib.request
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
30
|
+
from typing import NoReturn
|
|
31
|
+
from urllib.parse import urlparse
|
|
32
|
+
|
|
33
|
+
import bookmarks_parser
|
|
34
|
+
|
|
35
|
+
# ────
|
|
36
|
+
# Configuration
|
|
37
|
+
# ────
|
|
38
|
+
|
|
39
|
+
DEFAULT_INPUT = "bookmarks.html"
|
|
40
|
+
DEFAULT_OUTPUT = "index.html"
|
|
41
|
+
SORTED_OUTPUT = "bookmarks-sorted.html"
|
|
42
|
+
|
|
43
|
+
# Generic blue globe shown when a bookmark has no favicon (mirrors Google's default)
|
|
44
|
+
_DEFAULT_FAVICON = (
|
|
45
|
+
"data:image/svg+xml,"
|
|
46
|
+
"%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E"
|
|
47
|
+
"%3Ccircle cx='8' cy='8' r='7.5' fill='%234285f4'/%3E"
|
|
48
|
+
"%3Cellipse cx='8' cy='8' rx='3.5' ry='7.5' fill='none' stroke='white' stroke-width='.8'/%3E"
|
|
49
|
+
"%3Cline x1='.5' y1='8' x2='15.5' y2='8' stroke='white' stroke-width='.8'/%3E"
|
|
50
|
+
"%3Cline x1='2' y1='5' x2='14' y2='5' stroke='white' stroke-width='.8'/%3E"
|
|
51
|
+
"%3Cline x1='2' y1='11' x2='14' y2='11' stroke='white' stroke-width='.8'/%3E"
|
|
52
|
+
"%3C/svg%3E"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ────
|
|
56
|
+
# Helper functions
|
|
57
|
+
# ────
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sort_folders_first_alpha(nodes: list[dict]) -> list[dict]:
|
|
61
|
+
"""Sort nodes folders-first, each group alphabetically by title."""
|
|
62
|
+
folders = sorted(
|
|
63
|
+
[n for n in nodes if n.get("type") == "folder"],
|
|
64
|
+
key=lambda n: (n.get("title") or "").lower(),
|
|
65
|
+
)
|
|
66
|
+
bookmarks = sorted(
|
|
67
|
+
[n for n in nodes if n.get("type") == "bookmark"],
|
|
68
|
+
key=lambda n: (n.get("title") or "").lower(),
|
|
69
|
+
)
|
|
70
|
+
return folders + bookmarks
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sort_nodes(nodes: list[dict], recursive: bool = True) -> list[dict]:
|
|
74
|
+
"""Return *nodes* sorted folders-first; folder children are sorted in place when recursive=True."""
|
|
75
|
+
sorted_nodes = _sort_folders_first_alpha(nodes)
|
|
76
|
+
if recursive:
|
|
77
|
+
for node in sorted_nodes:
|
|
78
|
+
if node.get("type") == "folder" and node.get("children"):
|
|
79
|
+
node["children"] = sort_nodes(node["children"], recursive=True)
|
|
80
|
+
return sorted_nodes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _assign_folder_ids(nodes: list[dict], counter: itertools.count) -> None:
|
|
84
|
+
"""Recursively assign a unique integer ``_fid`` to every folder node."""
|
|
85
|
+
for node in nodes:
|
|
86
|
+
if node.get("type") == "folder":
|
|
87
|
+
node["_fid"] = next(counter)
|
|
88
|
+
_assign_folder_ids(node.get("children", []), counter)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ────
|
|
92
|
+
# Favicon fetching
|
|
93
|
+
# ────
|
|
94
|
+
|
|
95
|
+
_FAVICON_SERVICE = "https://www.google.com/s2/favicons?domain={domain}&sz=16"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fetch_favicon(domain: str) -> str | None:
|
|
99
|
+
"""Fetch a 16 px favicon for *domain* via Google's service; return a base64 data URI or None."""
|
|
100
|
+
try:
|
|
101
|
+
url = _FAVICON_SERVICE.format(domain=domain)
|
|
102
|
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
103
|
+
data = resp.read()
|
|
104
|
+
ct = resp.headers.get_content_type()
|
|
105
|
+
return f"data:{ct};base64,{base64.b64encode(data).decode()}"
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _collect_bookmarks(nodes: list[dict]) -> list[dict]:
|
|
111
|
+
"""Return a flat list of every bookmark node in the tree."""
|
|
112
|
+
out: list[dict] = []
|
|
113
|
+
for n in nodes:
|
|
114
|
+
if n.get("type") == "bookmark":
|
|
115
|
+
out.append(n)
|
|
116
|
+
elif n.get("type") == "folder":
|
|
117
|
+
out.extend(_collect_bookmarks(n.get("children", [])))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def update_icons(roots: list[dict], workers: int = 20) -> None:
|
|
122
|
+
"""Fetch and store favicons for bookmarks that don't already have one, one fetch per domain."""
|
|
123
|
+
bookmarks = []
|
|
124
|
+
for root in roots:
|
|
125
|
+
bookmarks.extend(_collect_bookmarks(root.get("children", [])))
|
|
126
|
+
|
|
127
|
+
by_domain: dict[str, list[dict]] = {}
|
|
128
|
+
for n in bookmarks:
|
|
129
|
+
if n.get("icon"):
|
|
130
|
+
continue # keep the icon from the export / a previous run
|
|
131
|
+
domain = urlparse(n.get("url", "")).netloc
|
|
132
|
+
if domain:
|
|
133
|
+
by_domain.setdefault(domain, []).append(n)
|
|
134
|
+
|
|
135
|
+
total = len(by_domain)
|
|
136
|
+
print(f"Fetching favicons for {total} domains ({sum(len(v) for v in by_domain.values())} bookmarks)…")
|
|
137
|
+
done = 0
|
|
138
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
139
|
+
futures = {
|
|
140
|
+
executor.submit(_fetch_favicon, domain): domain for domain in by_domain
|
|
141
|
+
}
|
|
142
|
+
for future in as_completed(futures):
|
|
143
|
+
icon = future.result()
|
|
144
|
+
if icon:
|
|
145
|
+
for node in by_domain[futures[future]]:
|
|
146
|
+
node["icon"] = icon
|
|
147
|
+
done += 1
|
|
148
|
+
if done % 50 == 0 or done == total:
|
|
149
|
+
print(f" {done}/{total}")
|
|
150
|
+
print("✔ Updated favicons")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ────
|
|
154
|
+
# Toolbar (horizontal) builder
|
|
155
|
+
# ────
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _bookmark_html(n: dict) -> str:
|
|
159
|
+
"""Render a bookmark node as an <a> with favicon; empty titles fall back to the URL."""
|
|
160
|
+
title = html.escape(n.get("title") or n.get("url", ""))
|
|
161
|
+
url = html.escape(n.get("url", "#"))
|
|
162
|
+
icon = (
|
|
163
|
+
n.get("icon")
|
|
164
|
+
or n.get("icon_uri")
|
|
165
|
+
or n.get("iconUri")
|
|
166
|
+
or n.get("ICON_URI")
|
|
167
|
+
or n.get("ICON")
|
|
168
|
+
or _DEFAULT_FAVICON
|
|
169
|
+
)
|
|
170
|
+
icon_html = f'<img src="{html.escape(icon)}" class="favicon" alt="" />'
|
|
171
|
+
return f'<a href="{url}">{icon_html}{title}</a>'
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _links_html(nodes: list[dict]) -> str:
|
|
175
|
+
"""Return <a> list for the given bookmark *nodes* joined by <br>."""
|
|
176
|
+
return "<br>\n".join(
|
|
177
|
+
_bookmark_html(n) for n in nodes if n.get("type") == "bookmark"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_toolbar(toolbar_root: dict | None) -> str:
|
|
182
|
+
"""Return the HTML markup for the horizontal toolbar."""
|
|
183
|
+
if toolbar_root is None:
|
|
184
|
+
return "" # nothing to render
|
|
185
|
+
|
|
186
|
+
items: list[str] = []
|
|
187
|
+
loose: list[dict] = [] # bookmarks not inside a folder
|
|
188
|
+
|
|
189
|
+
children = toolbar_root.get("children", [])
|
|
190
|
+
|
|
191
|
+
for child in children:
|
|
192
|
+
if child.get("type") == "folder":
|
|
193
|
+
title = html.escape(child.get("title", "(untitled)"))
|
|
194
|
+
items.append(
|
|
195
|
+
"<div class='tb-item'>"
|
|
196
|
+
f"<span class='tb-label'>{title}</span>"
|
|
197
|
+
f"<div class='tb-menu'>{_links_html(child.get('children', []))}</div>"
|
|
198
|
+
"</div>"
|
|
199
|
+
)
|
|
200
|
+
elif child.get("type") == "bookmark":
|
|
201
|
+
loose.append(child)
|
|
202
|
+
|
|
203
|
+
if loose: # add pseudo‑folder first
|
|
204
|
+
label = html.escape(toolbar_root.get("title", "Toolbar"))
|
|
205
|
+
items.insert(
|
|
206
|
+
0,
|
|
207
|
+
"<div class='tb-item'>"
|
|
208
|
+
f"<span class='tb-label'>{label}</span>"
|
|
209
|
+
f"<div class='tb-menu'>{_links_html(loose)}</div>"
|
|
210
|
+
"</div>",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return "<nav id='toolbar'>" + "\n".join(items) + "</nav>"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ────
|
|
217
|
+
# Bookmarks Menu helpers
|
|
218
|
+
# ────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def collect_index_items(nodes: list[dict]) -> list[str]:
|
|
222
|
+
out: list[str] = []
|
|
223
|
+
for n in nodes:
|
|
224
|
+
if n.get("type") == "folder":
|
|
225
|
+
fid = n["_fid"]
|
|
226
|
+
title = html.escape(n.get("title", "(untitled folder)"))
|
|
227
|
+
out.append(
|
|
228
|
+
f'<a id="index{fid}" href="#folder{fid}" class="folder">{title}</a>'
|
|
229
|
+
)
|
|
230
|
+
out.extend(collect_index_items(n.get("children", [])))
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def collect_main_items(nodes: list[dict]) -> list[str]:
|
|
235
|
+
out: list[str] = []
|
|
236
|
+
for n in nodes:
|
|
237
|
+
if n.get("type") == "folder":
|
|
238
|
+
fid = n["_fid"]
|
|
239
|
+
title = html.escape(n.get("title", "(untitled folder)"))
|
|
240
|
+
out.append(
|
|
241
|
+
f'<a id="folder{fid}" href="#top" class="folder">{title}</a>'
|
|
242
|
+
)
|
|
243
|
+
out.extend(collect_main_items(n.get("children", [])))
|
|
244
|
+
elif n.get("type") == "bookmark":
|
|
245
|
+
out.append(_bookmark_html(n))
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def table_html(cols: list[tuple[str, list[str]]]) -> str:
|
|
250
|
+
if not cols:
|
|
251
|
+
return "" # render nothing when list empty
|
|
252
|
+
heads = "".join(f"<th>{hdr}</th>" for hdr, _ in cols)
|
|
253
|
+
cells = []
|
|
254
|
+
for _, items in cols:
|
|
255
|
+
joined = "<br>\n".join(items)
|
|
256
|
+
cells.append(f"<td>{joined}</td>\n")
|
|
257
|
+
return f"<table>\n<tr>{heads}</tr>\n<tr>{''.join(cells)}</tr>\n</table>"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ────
|
|
261
|
+
# Netscape export
|
|
262
|
+
# ────
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _ns_escape(s: str) -> str:
|
|
266
|
+
"""Escape for Netscape bookmark output (same entities Firefox emits)."""
|
|
267
|
+
return (
|
|
268
|
+
s.replace("&", "&")
|
|
269
|
+
.replace('"', """)
|
|
270
|
+
.replace("<", "<")
|
|
271
|
+
.replace(">", ">")
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _folder_attrs(n: dict) -> str:
|
|
276
|
+
"""Return ADD_DATE/LAST_MODIFIED attribute string for a folder node."""
|
|
277
|
+
parts = []
|
|
278
|
+
if n.get("add_date"):
|
|
279
|
+
parts.append(f'ADD_DATE="{n["add_date"]}"')
|
|
280
|
+
if n.get("last_modified"):
|
|
281
|
+
parts.append(f'LAST_MODIFIED="{n["last_modified"]}"')
|
|
282
|
+
if n.get("ns_root") == "toolbar":
|
|
283
|
+
parts.append('PERSONAL_TOOLBAR_FOLDER="true"')
|
|
284
|
+
if n.get("ns_root") == "other_bookmarks":
|
|
285
|
+
parts.append('UNFILED_BOOKMARKS_FOLDER="true"')
|
|
286
|
+
return (" " + " ".join(parts)) if parts else ""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _bookmark_attrs(n: dict, now: int) -> str:
|
|
290
|
+
"""Return ADD_DATE/LAST_MODIFIED/ICON_URI/ICON attribute string for a bookmark node."""
|
|
291
|
+
parts = []
|
|
292
|
+
if n.get("add_date"):
|
|
293
|
+
parts.append(f'ADD_DATE="{n["add_date"]}"')
|
|
294
|
+
parts.append(f'LAST_MODIFIED="{now}"')
|
|
295
|
+
icon_uri = n.get("icon_uri") or n.get("iconUri") or n.get("ICON_URI")
|
|
296
|
+
if icon_uri:
|
|
297
|
+
parts.append(f'ICON_URI="{_ns_escape(icon_uri)}"')
|
|
298
|
+
icon = n.get("icon") or n.get("ICON")
|
|
299
|
+
if icon:
|
|
300
|
+
parts.append(f'ICON="{_ns_escape(icon)}"')
|
|
301
|
+
return (" " + " ".join(parts)) if parts else ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _netscape_nodes(nodes: list[dict], depth: int = 1, now: int = 0) -> list[str]:
|
|
305
|
+
"""Recursively render *nodes* as Netscape bookmark lines."""
|
|
306
|
+
pad = " " * depth
|
|
307
|
+
lines: list[str] = []
|
|
308
|
+
for n in nodes:
|
|
309
|
+
if n.get("type") == "folder":
|
|
310
|
+
title = _ns_escape(n.get("title", ""))
|
|
311
|
+
attrs = _folder_attrs(n)
|
|
312
|
+
lines.append(f"{pad}<DT><H3{attrs}>{title}</H3>")
|
|
313
|
+
lines.append(f"{pad}<DL><p>")
|
|
314
|
+
lines.extend(_netscape_nodes(n.get("children", []), depth + 1, now))
|
|
315
|
+
lines.append(f"{pad}</DL><p>")
|
|
316
|
+
elif n.get("type") == "bookmark":
|
|
317
|
+
title = _ns_escape(n.get("title", "") or n.get("url", ""))
|
|
318
|
+
url = _ns_escape(n.get("url", ""))
|
|
319
|
+
attrs = _bookmark_attrs(n, now)
|
|
320
|
+
lines.append(f'{pad}<DT><A HREF="{url}"{attrs}>{title}</A>')
|
|
321
|
+
return lines
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def write_netscape(parsed: list[dict], output_file: str) -> None:
|
|
325
|
+
"""Write the full sorted bookmark tree in Netscape HTML format."""
|
|
326
|
+
now = int(time.time())
|
|
327
|
+
lines = [
|
|
328
|
+
"<!DOCTYPE NETSCAPE-Bookmark-file-1>",
|
|
329
|
+
"<!-- This is an automatically generated file.",
|
|
330
|
+
" It will be read and overwritten.",
|
|
331
|
+
" DO NOT EDIT! -->",
|
|
332
|
+
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
|
|
333
|
+
"<TITLE>Bookmarks</TITLE>",
|
|
334
|
+
"<H1>Bookmarks</H1>",
|
|
335
|
+
"<DL><p>",
|
|
336
|
+
]
|
|
337
|
+
for root in parsed:
|
|
338
|
+
if root.get("ns_root") == "menu":
|
|
339
|
+
# Firefox exports menu items directly in the top-level <DL>, without a wrapper folder
|
|
340
|
+
lines.extend(_netscape_nodes(root.get("children", []), depth=1, now=now))
|
|
341
|
+
continue
|
|
342
|
+
title = _ns_escape(root.get("title", root.get("ns_root", "")))
|
|
343
|
+
attrs = _folder_attrs(root)
|
|
344
|
+
lines.append(f" <DT><H3{attrs}>{title}</H3>")
|
|
345
|
+
lines.append(" <DL><p>")
|
|
346
|
+
lines.extend(_netscape_nodes(root.get("children", []), depth=2, now=now))
|
|
347
|
+
lines.append(" </DL><p>")
|
|
348
|
+
lines.append("</DL><p>")
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
pathlib.Path(output_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
352
|
+
print(f"✔ Wrote {output_file}")
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
die(f"Failed to write '{output_file}': {exc}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ────
|
|
358
|
+
# Page builder
|
|
359
|
+
# ────
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def build_html(
|
|
363
|
+
toolbar: str,
|
|
364
|
+
idx_cols: list[tuple[str, list[str]]],
|
|
365
|
+
main_cols: list[tuple[str, list[str]]],
|
|
366
|
+
) -> str:
|
|
367
|
+
CSS = """
|
|
368
|
+
|
|
369
|
+
html{
|
|
370
|
+
scroll-padding-top: 3rem;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
html,body{
|
|
374
|
+
margin:0;
|
|
375
|
+
padding:0;
|
|
376
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;
|
|
377
|
+
background:#121212;
|
|
378
|
+
color:#e0e0e0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#toolbar{position:fixed;top:0;left:0;right:0;background:#1e1e1e;display:flex;gap:1.25em;padding:0.5em 1em;border-bottom:1px solid #333;z-index:1000;}
|
|
382
|
+
#toolbar .tb-item{position:relative;}
|
|
383
|
+
#toolbar .tb-label{color:#ffcb6b;font-weight:bold;cursor:pointer;}
|
|
384
|
+
#toolbar .tb-menu{display:none;position:absolute;top:100%;left:0;background:#1e1e1e;padding:0.5em;border:1px solid #333;max-width:320px;white-space:nowrap;}
|
|
385
|
+
#toolbar .tb-item:hover .tb-menu{display:block;}
|
|
386
|
+
#toolbar a{color:#4ea3ff;text-decoration:none;line-height:1.3;}
|
|
387
|
+
#toolbar a:hover{text-decoration:underline;}
|
|
388
|
+
|
|
389
|
+
main{margin-top:3rem;padding:1rem;}
|
|
390
|
+
|
|
391
|
+
table {
|
|
392
|
+
width: 1200px;
|
|
393
|
+
table-layout: fixed;
|
|
394
|
+
border-collapse: collapse;
|
|
395
|
+
border: 1px solid black;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
th,td{text-align:left;vertical-align:top;padding:2px;border:none}
|
|
399
|
+
th{background:#1e1e1e;font-weight:600;}
|
|
400
|
+
td, th {
|
|
401
|
+
padding: 5px;
|
|
402
|
+
border: 1px solid black;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
a{color:#4ea3ff;text-decoration:none;line-height:1.25;}
|
|
406
|
+
a:hover{text-decoration:underline;}
|
|
407
|
+
.folder{font-weight:bold;color:#ffcb6b;}
|
|
408
|
+
img.favicon{width:16px;height:16px;vertical-align:middle;margin-right:0.5em;border-radius:2px;}
|
|
409
|
+
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
return "\n".join(
|
|
413
|
+
[
|
|
414
|
+
"<!doctype html><html><head><meta charset='utf-8'><title>Bookmarks</title>",
|
|
415
|
+
f"<style>{CSS}</style></head><body><a id='top'></a>",
|
|
416
|
+
toolbar,
|
|
417
|
+
"<main>",
|
|
418
|
+
table_html(idx_cols),
|
|
419
|
+
table_html(main_cols),
|
|
420
|
+
"</main></body></html>",
|
|
421
|
+
]
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ────
|
|
426
|
+
# Main entry
|
|
427
|
+
# ────
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def die(msg: str) -> NoReturn:
|
|
431
|
+
"""Print msg to stderr and exit non‑zero."""
|
|
432
|
+
print(f"✖ {msg}", file=sys.stderr)
|
|
433
|
+
sys.exit(1)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def main() -> None:
|
|
437
|
+
parser = argparse.ArgumentParser(
|
|
438
|
+
description="Generate a styled dark-mode bookmarks page from a Netscape-format export."
|
|
439
|
+
)
|
|
440
|
+
parser.add_argument(
|
|
441
|
+
"input",
|
|
442
|
+
nargs="?",
|
|
443
|
+
default=DEFAULT_INPUT,
|
|
444
|
+
help=f"bookmarks export to read (default: {DEFAULT_INPUT})",
|
|
445
|
+
)
|
|
446
|
+
parser.add_argument(
|
|
447
|
+
"-o",
|
|
448
|
+
"--output",
|
|
449
|
+
default=DEFAULT_OUTPUT,
|
|
450
|
+
help=f"HTML page to write (default: {DEFAULT_OUTPUT})",
|
|
451
|
+
)
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--no-icons",
|
|
454
|
+
action="store_true",
|
|
455
|
+
help="skip fetching missing favicons from the network",
|
|
456
|
+
)
|
|
457
|
+
parser.add_argument(
|
|
458
|
+
"--preserve-input",
|
|
459
|
+
action="store_true",
|
|
460
|
+
help=f"don't overwrite the input file with the sorted export; write {SORTED_OUTPUT} instead",
|
|
461
|
+
)
|
|
462
|
+
args = parser.parse_args()
|
|
463
|
+
|
|
464
|
+
# Parse export ----
|
|
465
|
+
try:
|
|
466
|
+
parsed = bookmarks_parser.parse(args.input)
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
die(f"Failed to parse '{args.input}': {exc}")
|
|
469
|
+
|
|
470
|
+
menu_root = next(
|
|
471
|
+
(n for n in parsed if n.get("ns_root") in ("menu", "other_bookmarks")),
|
|
472
|
+
None,
|
|
473
|
+
)
|
|
474
|
+
if menu_root is None:
|
|
475
|
+
die("No 'menu' or 'other_bookmarks' root found in the export.")
|
|
476
|
+
|
|
477
|
+
# Sort bookmarks and export Netscape file ----
|
|
478
|
+
for root in parsed:
|
|
479
|
+
root["children"] = sort_nodes(root.get("children", []))
|
|
480
|
+
if not args.no_icons:
|
|
481
|
+
update_icons(parsed)
|
|
482
|
+
write_netscape(parsed, SORTED_OUTPUT if args.preserve_input else args.input)
|
|
483
|
+
|
|
484
|
+
toolbar_root = next((n for n in parsed if n.get("ns_root") == "toolbar"), None)
|
|
485
|
+
toolbar_html = build_toolbar(toolbar_root)
|
|
486
|
+
|
|
487
|
+
# Build Bookmarks Menu columns ----
|
|
488
|
+
menu_children = menu_root.get("children", [])
|
|
489
|
+
menu_folders = [n for n in menu_children if n.get("type") == "folder"]
|
|
490
|
+
|
|
491
|
+
index_cols: list[tuple[str, list[str]]] = []
|
|
492
|
+
main_cols: list[tuple[str, list[str]]] = []
|
|
493
|
+
|
|
494
|
+
if menu_folders:
|
|
495
|
+
_assign_folder_ids(menu_folders, itertools.count())
|
|
496
|
+
for folder in menu_folders:
|
|
497
|
+
children = folder.get("children", [])
|
|
498
|
+
fid = folder["_fid"]
|
|
499
|
+
title = html.escape(folder.get("title", "(untitled)"))
|
|
500
|
+
|
|
501
|
+
index_cols.append(
|
|
502
|
+
(
|
|
503
|
+
f'<a id="index{fid}" href="#folder{fid}" class="folder">{title}</a>',
|
|
504
|
+
collect_index_items(children),
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
main_cols.append(
|
|
508
|
+
(
|
|
509
|
+
f'<a id="folder{fid}" href="#top" class="folder">{title}</a>',
|
|
510
|
+
collect_main_items(children),
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Generate full HTML ----
|
|
515
|
+
html_output = build_html(toolbar_html, index_cols, main_cols)
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
pathlib.Path(args.output).write_text(html_output, encoding="utf-8")
|
|
519
|
+
print(f"✔ Wrote {args.output}")
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
die(f"Failed to write '{args.output}': {exc}")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "linkfolio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Convert Netscape bookmarks into a newspaper-style homepage"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Eric Bixby", email = "eric.k.bixby@gmail.com" }]
|
|
14
|
+
keywords = ["bookmarks", "netscape", "homepage", "html", "firefox"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
21
|
+
"Topic :: Utilities",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["bookmarks_parser"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/eric-bixby/linkfolio"
|
|
27
|
+
Repository = "https://github.com/eric-bixby/linkfolio"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
linkfolio = "linkfolio:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
py-modules = ["linkfolio"]
|