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.
@@ -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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ linkfolio = linkfolio:main
@@ -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("&", "&amp;")
269
+ .replace('"', "&quot;")
270
+ .replace("<", "&lt;")
271
+ .replace(">", "&gt;")
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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+