diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-26 13:07:08 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-26 13:07:08 +0100 |
| commit | e320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2 (patch) | |
| tree | 34847129057c55116bc056f1af61c892342582f3 /bookmarks/bookmarks.py | |
| parent | f161ba41344c6834f10eaa5064dc02bc9c1cfdf6 (diff) | |
| download | quickshell-e320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2.tar.gz quickshell-e320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2.zip | |
bookmarks: Firefox bookmarks as a full-width overlay
A new standalone shell, shaped like hosts, on SUPER+b. bookmarks.py reads
the bookmark tree and favicons of the profile a running Firefox holds
(found through the pid in its lock symlink; the last-modified places.sqlite
was tried first and picked the guest profile after a stray launch touched
it). Firefox keeps both databases in exclusive lock mode, so they are
copied with their -wal and the copy is read: an immutable=1 open of the
original skipped the log and kept showing bookmarks deleted minutes before.
The grid spans DP-1, a ListView of card rows so only visible rows exist,
grouped by folder with each folder's own bookmarks before its subfolders.
Six icon buttons (Ctrl+S cycles) sort by folder, newest, title, site, most
visited or link state. The wheel scrolls three rows a notch through a
MouseArea, since Flickable's own step crawled; only the selected card
carries a Glow, through a Loader.
Favicons are extracted by content hash; a page without one takes any icon
Firefox holds for its host, which found 52 more of 504.
bookmarks.py --links (Ctrl+R) checks every http(s) bookmark, HEAD then GET,
32 at a time, in about 22s, and caches the result. Dead means 404/410, an
NXDOMAIN or a refused connection; 403, 429, timeouts, TLS and resolver
failures are only "unsure", because a 32-thread burst produced EAI_AGAIN
that a serial retry resolved.
Bookmarks open with xdg-open: a plain `firefox <url>` does not reach an
instance started with -P and opened the default profile beside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bookmarks/bookmarks.py')
| -rwxr-xr-x | bookmarks/bookmarks.py | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/bookmarks/bookmarks.py b/bookmarks/bookmarks.py new file mode 100755 index 0000000..36db7b6 --- /dev/null +++ b/bookmarks/bookmarks.py @@ -0,0 +1,296 @@ +#!/usr/bin/python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +"""Firefox bookmarks as JSON, grouped by folder in Firefox's own order, each +with its favicon extracted to a cache file and the result of the last link +check. + + bookmarks.py print the list + bookmarks.py --links check every http(s) bookmark, write the results + bookmarks.py --check run the self-check + +The databases are read from a copy: Firefox holds them in exclusive lock +mode while it runs, so even a read-only open fails, and this only reads. A +write would need Firefox closed and is not attempted. +""" + +import concurrent.futures +import hashlib +import json +import os +import shutil +import socket +import sqlite3 +import ssl +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +FIREFOX = Path("~/.mozilla/firefox").expanduser() +CACHE = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "keystone/bookmarks" +LINKS = CACHE / "links.json" + +# Root folders by guid, in the order they are shown. Tags are left out: they +# are not bookmarks, and each one repeats a bookmark found elsewhere. +ROOTS = {"toolbar_____": "Toolbar", "menu________": "Menu", + "unfiled_____": "Other Bookmarks", "mobile______": "Mobile"} + +MAGIC = [(b"\x89PNG", "png"), (b"<svg", "svg"), (b"<?xml", "svg"), + (b"\x00\x00\x01\x00", "ico"), (b"GIF8", "gif"), (b"\xff\xd8", "jpg"), + (b"RIFF", "webp")] + + +def running(prof): + """Whether a live Firefox holds this profile. Its `lock` symlink points at + "<ip>:+<pid>" and outlives a crash, so the pid is checked too.""" + try: + pid = os.readlink(prof / "lock").rpartition("+")[2] + return "firefox" in Path(f"/proc/{pid}/comm").read_text() + except OSError: + return False + + +def profile(): + """BOOKMARKS_PROFILE, else the profile a running Firefox holds, else the + one with the largest places.sqlite. Not installs.ini's Default, which + names whichever profile the last plain launch picked, here the near-empty + guest one; nor the last-modified places.sqlite, which a stray launch of + that guest profile makes newest.""" + if os.environ.get("BOOKMARKS_PROFILE"): + return Path(os.environ["BOOKMARKS_PROFILE"]).expanduser() + places = sorted(FIREFOX.glob("*/places.sqlite"), key=lambda p: p.stat().st_size) + if not places: + sys.exit("no Firefox profile with a places.sqlite") + live = [p for p in places if running(p.parent)] + # ponytail: two profiles running at once, the bigger wins; a toggle in + # the overlay if both are ever wanted. + return (live or places)[-1].parent + + +_copies = tempfile.TemporaryDirectory() + + +def connect(path): + """A copy of the database with its write-ahead log. Firefox writes every + change to the -wal first and folds it in at a checkpoint, maybe minutes + later. `immutable=1` on the original skipped the log, so a bookmark + deleted in Firefox stayed listed until then. Copied into a directory of + this process's own, so the list and a link check never share one.""" + # ponytail: a copy taken mid-checkpoint could be torn; a reopen fixes it. + dst = Path(_copies.name) / path.name + for suffix in ("", "-wal"): + if Path(f"{path}{suffix}").exists(): + shutil.copyfile(f"{path}{suffix}", f"{dst}{suffix}") + return sqlite3.connect(dst) + + +def tree(rows): + """rows: (id, parent, type, title, position, guid, url, added, visits). + Returns [(folder path, [(title, url, added, visits)])], one group per folder holding bookmarks: + the folder's own bookmarks first, then its subfolders, each in Firefox's + order. A folder's bookmarks and subfolders interleave in Firefox; grouping + them is what makes the list readable.""" + kids = {} + for r in sorted(rows, key=lambda r: r[4]): + kids.setdefault(r[1], []).append(r) + groups = [] + + def walk(fid, path): + links = [(r[3] or r[6], r[6], r[7], r[8]) for r in kids.get(fid, []) if r[2] == 1 and r[6]] + if links: + groups.append((" / ".join(path), links)) + for r in kids.get(fid, []): + if r[2] == 2: + walk(r[0], path + [r[3] or "(untitled)"]) + + by_guid = {r[5]: r for r in rows} + for guid, name in ROOTS.items(): + if guid in by_guid: + walk(by_guid[guid][0], [name]) + return groups + + +def pick_icon(icons): + """icons: [(width, data)]. The smallest at least 32px wide, else the + largest. An SVG has width 65535, so it wins only when nothing is 32-64.""" + big = [i for i in icons if i[0] >= 32] + return min(big)[1] if big else max(icons)[1] + + +def icon_file(data): + """Write data to the cache under its hash, return the path, or "" for a + format Qt cannot show. Content-addressed, so a changed icon is a new file + and nothing needs invalidating.""" + ext = next((e for m, e in MAGIC if data.startswith(m)), None) + if not ext: + return "" + path = CACHE / "icons" / f"{hashlib.sha1(data).hexdigest()}.{ext}" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return str(path) + + +def icons(prof, urls): + """{url: icon path}: the page's own icon, else any icon Firefox holds for + the same host, from another page or the site's root /favicon.ico (stored + once per site, linked to no page). The host fallback, scheme ignored, + found 52 more of 504 bookmarks; the rest have no icon in Firefox at all, + which expires them.""" + db = connect(prof / "favicons.sqlite") + by_page, by_host = {}, {} + for page, width, data in db.execute( + "select p.page_url, i.width, i.data from moz_pages_w_icons p " + "join moz_icons_to_pages ip on ip.page_id = p.id " + "join moz_icons i on i.id = ip.icon_id where i.data is not null"): + by_page.setdefault(page, []).append((width, data)) + by_host.setdefault(host(page), []).append((width, data)) + for url, width, data in db.execute( + "select icon_url, width, data from moz_icons where root = 1 and data is not null"): + by_host.setdefault(host(url), []).append((width, data)) + db.close() + out = {} + for u in urls: + found = by_page.get(u) or by_host.get(host(u)) + if found: + out[u] = icon_file(pick_icon(found)) + return out + + +def host(url): + return url.partition("://")[2].split("/", 1)[0] + + +def main(): + prof = profile() + db = connect(prof / "places.sqlite") + rows = db.execute( + "select b.id, b.parent, b.type, b.title, b.position, b.guid, p.url, " + "b.dateAdded / 1000000, coalesce(p.visit_count, 0) " + "from moz_bookmarks b left join moz_places p on p.id = b.fk").fetchall() + db.close() + groups = tree(rows) + found = icons(prof, [e[1] for _, entries in groups for e in entries]) + links = json.loads(LINKS.read_text()) if LINKS.exists() else {} + items = [{"folder": folder, "title": title, "url": url, + "added": added, "visits": visits, + "icon": found.get(url, ""), + "state": links.get(url, {}).get("state", ""), + "reason": links.get(url, {}).get("reason", "")} + for folder, entries in groups for title, url, added, visits in entries] + checked = time.strftime("%Y-%m-%d %H:%M", time.localtime(LINKS.stat().st_mtime)) \ + if LINKS.exists() else "" + json.dump({"checked": checked, "items": items}, sys.stdout) + + +def classify(error=None, code=None): + """(state, reason) for one request: "ok", "dead" for a page or host that + is gone, "warn" for anything that may only be refusing a script. + A 403 or 429 is usually a bot wall and a timeout may be a slow site, so + neither is called dead.""" + if code is not None: + if code < 400: + return "ok", "" + return ("dead" if code in (404, 410) else "warn"), f"HTTP {code}" + reason = getattr(error, "reason", error) + if isinstance(reason, socket.gaierror): + # Only "name unknown" is an answer; EAI_AGAIN is the resolver + # failing, which says nothing about the site. + if reason.errno in (socket.EAI_NONAME, getattr(socket, "EAI_NODATA", None)): + return "dead", "no such host" + return "warn", "DNS failure" + if isinstance(reason, ConnectionRefusedError): + return "dead", "connection refused" + if isinstance(reason, (TimeoutError, socket.timeout)): + return "warn", "timeout" + if isinstance(reason, ssl.SSLError): + return "warn", "TLS error" + return "warn", (getattr(reason, "strerror", None) or str(reason))[:60] + + +def probe(url): + """HEAD first; any HEAD error gets a GET, body unread, so a server that + mishandles HEAD is not called dead for it.""" + for method in ("HEAD", "GET"): + req = urllib.request.Request(url, method=method, headers={ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return classify(code=r.status) + except urllib.error.HTTPError as e: + if method == "HEAD": + continue + return classify(code=e.code) + except Exception as e: + return classify(error=e) + + +def check_links(): + db = connect(profile() / "places.sqlite") + urls = sorted({u for (u,) in db.execute( + "select p.url from moz_bookmarks b join moz_places p on p.id = b.fk " + "where b.type = 1 and (p.url like 'http://%' or p.url like 'https://%')")}) + db.close() + # ponytail: 32 at once, one pass, no retry; a site down for a minute reads + # dead until the next check. + with concurrent.futures.ThreadPoolExecutor(32) as pool: + results = dict(zip(urls, pool.map(probe, urls))) + LINKS.parent.mkdir(parents=True, exist_ok=True) + tmp = LINKS.with_suffix(".tmp") + tmp.write_text(json.dumps({u: {"state": s, "reason": r} for u, (s, r) in results.items()})) + tmp.replace(LINKS) + dead = sum(s == "dead" for s, _ in results.values()) + print(f"{len(urls)} checked, {dead} dead") + + +def check(): + rows = [(1, 0, 2, "", 0, "root________", None, 0, 0), + (2, 1, 2, "menu", 0, "menu________", None, 0, 0), + (3, 1, 2, "toolbar", 1, "toolbar_____", None, 0, 0), + (4, 1, 2, "tags", 2, "tags________", None, 0, 0), + (10, 3, 1, "B", 1, "b", "https://b.example.org/", 0, 0), + (11, 3, 2, "Dev", 0, "dev", None, 0, 0), + (12, 11, 1, "", 0, "c", "https://c.example.org/x", 0, 0), + (13, 3, 1, "A", 2, "a", "https://a.example.org/", 0, 0), + (14, 3, 3, "", 3, "sep", None, 0, 0), + (15, 4, 1, "tagged", 0, "t", "https://t.example.org/", 0, 0), + (16, 2, 2, "Empty", 0, "e", None, 0, 0)] + assert tree(rows) == [ + ("Toolbar", [("B", "https://b.example.org/", 0, 0), ("A", "https://a.example.org/", 0, 0)]), + ("Toolbar / Dev", [("https://c.example.org/x", "https://c.example.org/x", 0, 0)]), + ], tree(rows) + + assert pick_icon([(16, b"s"), (32, b"m"), (64, b"l"), (65535, b"v")]) == b"m" + assert pick_icon([(16, b"s"), (24, b"m")]) == b"m" + assert host("https://a.example.org/x/y?z") == "a.example.org" + + assert classify(code=200) == ("ok", "") + assert classify(code=404) == ("dead", "HTTP 404") + assert classify(code=403) == ("warn", "HTTP 403") + assert classify(error=urllib.error.URLError(socket.gaierror(-2, "x"))) == ("dead", "no such host") + assert classify(error=urllib.error.URLError(socket.gaierror(socket.EAI_AGAIN, "x"))) == ("warn", "DNS failure") + assert classify(error=urllib.error.URLError(ConnectionRefusedError())) == ("dead", "connection refused") + assert classify(error=TimeoutError()) == ("warn", "timeout") + assert classify(error=urllib.error.URLError(OSError(113, "No route to host"))) == ("warn", "No route to host") + print("ok") + + +if __name__ == "__main__": + if "--check" in sys.argv: + check() + elif "--links" in sys.argv: + check_links() + else: + main() |
