diff options
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | bookmarks/Bookmarks.qml | 601 | ||||
| l--------- | bookmarks/Glow.qml | 1 | ||||
| -rw-r--r-- | bookmarks/README.md | 64 | ||||
| l--------- | bookmarks/Theme.qml | 1 | ||||
| -rwxr-xr-x | bookmarks/bookmarks.py | 296 | ||||
| -rw-r--r-- | bookmarks/shell.qml | 26 |
8 files changed, 992 insertions, 0 deletions
@@ -25,6 +25,8 @@ modules. keybinds/ live keybind reminder, on SUPER+k hosts/ the vault's LAN map with ssh aliases, on ALT+SHIFT+Return; replaces the rofi SSH menu + bookmarks/ Firefox's bookmarks by folder, with favicons and + dead-link badges, on SUPER+b notifications/ balloons for the notification daemon's published files They are started from `~/.config/hypr/sections/autostart.lua` and keep running @@ -21,6 +21,7 @@ repos stay independent, this one has no build-time dependency on that one. island/ top-centre pills: clock, volume, tray, layout, privacy window-switcher/ open windows as live previews in a grid, on ALT+TAB keybinds/ live keybind reminder, on SUPER+k + bookmarks/ Firefox bookmarks with favicons and dead links, on SUPER+b Each directory has its own README covering what it does and how to run it. diff --git a/bookmarks/Bookmarks.qml b/bookmarks/Bookmarks.qml new file mode 100644 index 0000000..edb1ac5 --- /dev/null +++ b/bookmarks/Bookmarks.qml @@ -0,0 +1,601 @@ +// 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. + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick + +Scope { + id: root + + property bool open: false + property string monitor: "DP-1" + property var items: [] + property string checked: "" + property string query: "" + property bool deadOnly: false + property bool checking: false + property int sortMode: 0 + // Cards per row, set by the grid from its width. + property int columns: 6 + // Index into `flat`. + property int current: 0 + // Non-empty while loading or after a failure; the grid shows only when + // this is empty, so a stale list from the last opening never shows. + property string status: "Loading..." + + readonly property int deadCount: root.items.filter(b => b.state === "dead").length + readonly property int warnCount: root.items.filter(b => b.state === "warn").length + + function host(url) { return url.replace(/^[a-z]+:\/\//, "").split("/")[0]; } + + readonly property var stateRank: ({ dead: 0, warn: 1, ok: 2, "": 3 }) + readonly property var stateName: ({ dead: "Dead", warn: "Unsure", ok: "Fine", "": "Not checked" }) + + // Each sort: its glyph (Inconsolata Nerd Font, checked in its cmap), a + // name for the info line, the header a bookmark falls under ("" for + // none) and a comparison (null keeps Firefox's order). + readonly property var modes: [ + { glyph: "", name: "folder", + group: b => b.folder, cmp: null }, + { glyph: "", name: "newest", + group: b => `${new Date(b.added * 1000).getFullYear()}`, + cmp: (a, b) => b.added - a.added }, + { glyph: "", name: "title", + group: b => /[a-z]/i.test(b.title.charAt(0)) ? b.title.charAt(0).toUpperCase() : "#", + cmp: (a, b) => a.title.toLowerCase() < b.title.toLowerCase() ? -1 + : a.title.toLowerCase() > b.title.toLowerCase() ? 1 : 0 }, + { glyph: "", name: "site", + group: b => "", + cmp: (a, b) => { + const x = root.host(a.url).replace(/^www\./, ""), y = root.host(b.url).replace(/^www\./, ""); + return x < y ? -1 : x > y ? 1 : 0; + } }, + { glyph: "", name: "most visited", + group: b => "", cmp: (a, b) => b.visits - a.visits }, + { glyph: "", name: "link state", + group: b => root.stateName[b.state], + cmp: (a, b) => root.stateRank[a.state] - root.stateRank[b.state] }, + ] + + // The bookmarks that match, in display order. Every word must appear in + // the title, url or folder. + readonly property var flat: { + const words = root.query.toLowerCase().split(/\s+/).filter(w => w); + const out = root.items.filter(b => { + if (root.deadOnly && b.state !== "dead") return false; + const hay = `${b.title} ${b.url} ${b.folder}`.toLowerCase(); + return words.every(w => hay.includes(w)); + }); + const cmp = root.modes[root.sortMode].cmp; + if (!cmp) return out; + // Ties keep Firefox's order, whatever the engine's sort does with them. + const at = new Map(out.map((b, i) => [b, i])); + return out.sort((a, b) => cmp(a, b) || at.get(a) - at.get(b)); + } + onFlatChanged: root.current = 0 + + // What the grid draws: a header row where the group changes, then that + // group's cards, `columns` to a row. rowOf and colOf place each card. + readonly property var layout: { + const rows = [], rowOf = [], colOf = []; + const group = root.modes[root.sortMode].group; + let name = null, row = null; + root.flat.forEach((b, i) => { + const g = group(b); + if (g !== name) { + if (g !== "") rows.push({ header: g }); + name = g; + row = null; + } + if (!row || row.cards.length === root.columns) { + row = { cards: [] }; + rows.push(row); + } + rowOf.push(rows.length - 1); + colOf.push(row.cards.length); + row.cards.push({ b: b, i: i }); + }); + return { rows: rows, rowOf: rowOf, colOf: colOf }; + } + + readonly property var screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + + function toggle() { root.open = !root.open; } + function close() { root.open = false; } + + // Keyboard moves scroll the card into view; the pointer only selects, + // so a card half off the edge does not yank the grid under it. + signal reveal() + + function select(i) { + if (i < 0 || i >= root.flat.length) return; + root.current = i; + root.reveal(); + } + + // Up and down keep the column, across group boundaries, landing on the + // last card of a shorter row. + function vertical(step) { + const rows = root.layout.rows; + let r = root.layout.rowOf[root.current], target = -1; + const col = root.layout.colOf[root.current]; + while (step !== 0) { + r += step > 0 ? 1 : -1; + if (r < 0 || r >= rows.length) break; + if (rows[r].header !== undefined) continue; + target = rows[r].cards[Math.min(col, rows[r].cards.length - 1)].i; + step -= step > 0 ? 1 : -1; + } + if (target >= 0) root.select(target); + } + + function openUrl(b) { + if (!b) return; + // Through the desktop's handler, not `firefox <url>`: a plain + // firefox does not reach an instance started with -P, and opened + // the default profile in a new process beside it. firefox.desktop + // runs a wrapper that hands the url to the running profile. + Quickshell.execDetached(["xdg-open", b.url]); + root.close(); + } + + function reload() { + // A Process already running ignores running = true. + proc.running = false; + proc.running = true; + } + + function checkLinks() { + if (root.checking) return; + root.checking = true; + checker.running = true; + } + + // Read afresh on every opening, so a bookmark added in Firefox shows the + // next time. It measured 0.15s for 504 bookmarks, icons included. + onOpenChanged: if (open) { + root.status = "Loading..."; + root.query = ""; + root.reload(); + } + + Process { + id: proc + command: ["/usr/bin/python3", `${Quickshell.shellDir}/bookmarks.py`] + stdout: StdioCollector { + onStreamFinished: { + try { + const d = JSON.parse(text); + root.items = d.items; + root.checked = d.checked; + root.status = ""; + } catch (e) { + root.status = "bookmarks.py gave nothing readable"; + } + } + } + } + + // Takes about 20s over the network, so it runs only when asked and + // its results are cached; the grid shows the last check's. + Process { + id: checker + command: ["/usr/bin/python3", `${Quickshell.shellDir}/bookmarks.py`, "--links"] + onExited: { + root.checking = false; + if (root.open) root.reload(); + } + } + + // A config with no visible window exits, reporting nothing. This 1x1 + // click-through window holds the shell open while the overlay is hidden. + PanelWindow { + anchors { top: true; left: true } + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + LazyLoader { + active: root.open + + PanelWindow { + screen: root.screenObj + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "keystone-bookmarks" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + Rectangle { + anchors.fill: parent + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82) + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Column { + anchors.fill: parent + anchors.margins: 40 + spacing: 14 + + Rectangle { + width: 720 + anchors.horizontalCenter: parent.horizontalCenter + height: 52 + radius: 14 + color: Theme.surface + + // Keys reach a focused item, not a window, so the + // field takes all of them. + TextInput { + id: field + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + verticalAlignment: TextInput.AlignVCenter + focus: true + activeFocusOnTab: true + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 20 + text: root.query + onTextEdited: root.query = text + + Keys.onEscapePressed: root.close() + // The arrows move through the grid, not the + // text: a query is short enough to retype. + Keys.onLeftPressed: root.select(root.current - 1) + Keys.onRightPressed: root.select(root.current + 1) + Keys.onUpPressed: root.vertical(-1) + Keys.onDownPressed: root.vertical(1) + Keys.onPressed: event => { + const ctrl = event.modifiers & Qt.ControlModifier; + if (event.key === Qt.Key_PageUp) root.vertical(-5); + else if (event.key === Qt.Key_PageDown) root.vertical(5); + else if (event.key === Qt.Key_Tab) root.deadOnly = !root.deadOnly; + else if (event.key === Qt.Key_R && ctrl) root.checkLinks(); + else if (event.key === Qt.Key_S && ctrl) + root.sortMode = (root.sortMode + 1) % root.modes.length; + else return; + event.accepted = true; + } + Keys.onReturnPressed: root.openUrl(root.flat[root.current]) + Keys.onEnterPressed: root.openUrl(root.flat[root.current]) + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: field.text === "" + text: "title, address, folder..." + color: Theme.overlay + font: field.font + } + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + + Repeater { + model: root.modes + + Rectangle { + id: sortButton + required property var modelData + required property int index + readonly property bool on: root.sortMode === index + width: 32 + height: 32 + radius: 8 + color: sortMouse.containsMouse ? Theme.accent + : on ? Theme.surfaceAlt : Theme.surface + border.width: on ? 1 : 0 + border.color: Theme.accent + + Text { + anchors.centerIn: parent + text: sortButton.modelData.glyph + color: sortMouse.containsMouse ? Theme.base : Theme.text + font.family: Theme.iconFamily + font.pixelSize: 17 + } + MouseArea { + id: sortMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.sortMode = sortButton.index + } + } + } + + Item { width: 12; height: 1 } + + Repeater { + model: [ + { label: root.deadOnly ? "show all (Tab)" : "dead only (Tab)", + act: () => root.deadOnly = !root.deadOnly, on: root.deadOnly }, + { label: "check links (Ctrl+R)", + act: () => root.checkLinks(), on: root.checking }, + ] + + Rectangle { + id: chip + required property var modelData + anchors.verticalCenter: parent.verticalCenter + width: chipLabel.width + 20 + height: 26 + radius: 13 + color: chipMouse.containsMouse ? Theme.accent : Theme.surface + border.width: chip.modelData.on ? 1 : 0 + border.color: Theme.accent + + Text { + id: chipLabel + anchors.centerIn: parent + text: chip.modelData.label + color: chipMouse.containsMouse ? Theme.base : Theme.text + font.family: Theme.fontFamily + font.pixelSize: 13 + } + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + onClicked: chip.modelData.act() + } + } + } + + Item { width: 12; height: 1 } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: [`${root.flat.length} of ${root.items.length}`, + `by ${root.modes[root.sortMode].name} (Ctrl+S)`, + root.checked ? `${root.deadCount} dead, ${root.warnCount} unsure` : "", + root.checking ? "checking links..." + : root.checked ? `checked ${root.checked}` : "links never checked"] + .filter(s => s).join(" · ") + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 13 + } + } + + Text { + visible: root.status !== "" + anchors.horizontalCenter: parent.horizontalCenter + text: root.status + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 18 + } + + Item { + visible: root.status === "" + width: parent.width + height: parent.height - y + + // One delegate per row of cards, so only the rows on + // screen exist, whatever the bookmark count. + ListView { + id: grid + readonly property int gap: 14 + readonly property real cardWidth: + (width - leftMargin - rightMargin - gap * (root.columns - 1)) / root.columns + anchors.fill: parent + clip: true + model: root.layout.rows + reuseItems: true + cacheBuffer: 800 + boundsBehavior: Flickable.StopAtBounds + // The margins keep the selected card's glow inside the clip. + leftMargin: 16 + rightMargin: 16 + topMargin: 16 + bottomMargin: 16 + onWidthChanged: root.columns = Math.max(1, Math.floor((width - 32 + gap) / (340 + gap))) + + Connections { + target: root + function onReveal() { + const r = root.layout.rowOf[root.current]; + // The group's header too, when moving onto its first row. + if (r > 0 && root.layout.rows[r - 1].header !== undefined) + grid.positionViewAtIndex(r - 1, ListView.Contain); + grid.positionViewAtIndex(r, ListView.Contain); + } + function onFlatChanged() { grid.positionViewAtBeginning(); } + } + + delegate: Item { + id: row + required property var modelData + readonly property bool isHeader: modelData.header !== undefined + width: grid.width - grid.leftMargin - grid.rightMargin + height: isHeader ? 46 : 72 + grid.gap + + Text { + visible: row.isHeader + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + x: 4 + text: row.modelData.header ?? "" + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 16 + font.bold: true + } + + Row { + visible: !row.isHeader + spacing: grid.gap + + Repeater { + model: row.modelData.cards ?? [] + + Rectangle { + id: card + required property var modelData + readonly property var b: modelData.b + readonly property bool selected: modelData.i === root.current + readonly property color stateColor: b.state === "dead" ? Theme.red + : b.state === "warn" ? Theme.yellow : "transparent" + width: grid.cardWidth + height: 72 + radius: 12 + color: selected ? Theme.surfaceAlt : Theme.surface + + // Only the selected card carries a glow: a shadow + // on every card, faded out, still costs a pass each. + Loader { + anchors.fill: parent + z: -1 + active: card.selected + sourceComponent: Glow { radius: 12; on: true } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.current = card.modelData.i + onClicked: root.openUrl(card.b) + } + + // The favicon, or the host's initial when + // Firefox holds none. + Rectangle { + id: icon + x: 14 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + radius: 7 + color: card.b.icon ? "transparent" : Theme.surfaceAlt + + Image { + anchors.fill: parent + visible: !!card.b.icon + source: card.b.icon ? `file://${card.b.icon}` : "" + sourceSize: Qt.size(56, 56) + fillMode: Image.PreserveAspectFit + asynchronous: true + } + Text { + anchors.centerIn: parent + visible: !card.b.icon + text: root.host(card.b.url).replace(/^www\./, "").charAt(0).toUpperCase() + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 14 + font.bold: true + } + } + + Column { + anchors.left: icon.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + width: parent.width + elide: Text.ElideRight + text: card.b.title + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 15 + } + + Item { + width: parent.width + height: 20 + + // The folder when the grid is not grouped by it. + Text { + anchors.left: parent.left + anchors.right: badge.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + text: root.host(card.b.url) + + (root.sortMode !== 0 ? ` · ${card.b.folder}` : "") + color: Theme.overlay + font.family: Theme.fontFamily + font.pixelSize: 12 + } + + // Red is gone, yellow is unsure: a 403 or a + // timeout may only be a site refusing scripts. + Rectangle { + id: badge + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: card.b.state === "dead" || card.b.state === "warn" + width: visible ? reason.width + 14 : 0 + height: 20 + radius: 10 + color: "transparent" + border.width: 1 + border.color: card.stateColor + + Text { + id: reason + anchors.centerIn: parent + text: card.b.reason + color: card.stateColor + font.family: Theme.fontFamily + font.pixelSize: 11 + } + } + } + } + } + } + } + } + } + + // Flickable's own wheel step is a few pixels a notch + // and dragged on 500 bookmarks. This scrolls 3 rows of + // cards a notch instead; buttons pass through to the + // cards. A MouseArea, as WheelHandler never fired in + // the island. + MouseArea { + anchors.fill: grid + acceptedButtons: Qt.NoButton + onWheel: wheel => { + const step = wheel.pixelDelta.y !== 0 ? wheel.pixelDelta.y * 2 + : wheel.angleDelta.y / 120 * 3 * (72 + grid.gap); + const top = grid.originY - grid.topMargin; + const bottom = Math.max(top, grid.originY + grid.contentHeight + + grid.bottomMargin - grid.height); + grid.contentY = Math.max(top, Math.min(bottom, grid.contentY - step)); + } + } + } + } + } + } + } +} diff --git a/bookmarks/Glow.qml b/bookmarks/Glow.qml new file mode 120000 index 0000000..779c66a --- /dev/null +++ b/bookmarks/Glow.qml @@ -0,0 +1 @@ +../shared/Glow.qml
\ No newline at end of file diff --git a/bookmarks/README.md b/bookmarks/README.md new file mode 100644 index 0000000..61efb2a --- /dev/null +++ b/bookmarks/README.md @@ -0,0 +1,64 @@ +# bookmarks + +Firefox's bookmarks as a grid of cards across DP-1, grouped by folder, over +a dimmed screen. SUPER+b opens it, Escape or a click outside closes it. + +Each card shows the favicon, the title, the host and, after a link check, a +badge: red for a link that is gone (404, 410, a host that no longer +resolves, a refused connection), yellow for one that may only be refusing a +script (403, 429, a timeout, a TLS error, a 5xx). + +Type to filter (every word must match title, address or folder), arrows +and PageUp/PageDown to move. Enter opens the bookmark with `xdg-open`, so it +lands in the running Firefox profile rather than a new one. Tab shows +only the dead ones; Ctrl+R checks every link again. + +The icon buttons sort, and Ctrl+S cycles them: Firefox's folder order, +newest first (headed by year), title (headed by letter), site, most visited, +and link state (dead, unsure, fine, not checked). Ties keep folder order. +Outside folder order each card names its folder. + +The wheel scrolls three rows of cards a notch, set in the `MouseArea` over +the grid; Flickable's own step was a few pixels. + +`bookmarks.py` builds the list on every opening, so a bookmark added in +Firefox shows the next time. It reads `places.sqlite` and `favicons.sqlite` +of the profile a running Firefox holds, found by its `lock` symlink's pid, +else the one with the largest `places.sqlite` (`BOOKMARKS_PROFILE` overrides +both). Firefox holds them in exclusive lock mode, so they are copied with +their `-wal` to a temporary directory and the copy is read: the log is where +Firefox puts recent changes, a deletion included, until a checkpoint. +Nothing is ever written to them: deleting a dead bookmark is done in Firefox. + +Folders keep Firefox's order, toolbar first, but a folder's own bookmarks +come before its subfolders rather than interleaved with them. Tags are left +out. Favicons are extracted to `~/.cache/keystone/bookmarks/icons/`, named by +content hash; a bookmark with no icon of its own takes any icon Firefox holds +for the same host, and one with none at all shows the host's initial. + +The link check (`bookmarks.py --links`) sends one request to every http(s) +bookmark, 32 at a time, HEAD and then GET if HEAD fails, and writes +`~/.cache/keystone/bookmarks/links.json`. It took 22s over 503 links. It runs +only when asked, and a site down at that moment reads dead until the next +check. + +## Running + + qs -p ./bookmarks + qs -p ./bookmarks ipc call bookmarks toggle + +## Blur + +Optional, in the Hyprland config: + + hl.layer_rule({ + name = "blur-bookmarks", + match = { namespace = "^(keystone-bookmarks)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, + }) + +## Check + + /usr/bin/python3 bookmarks/bookmarks.py --check diff --git a/bookmarks/Theme.qml b/bookmarks/Theme.qml new file mode 120000 index 0000000..3d2e40f --- /dev/null +++ b/bookmarks/Theme.qml @@ -0,0 +1 @@ +../shared/Theme.qml
\ No newline at end of file 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() diff --git a/bookmarks/shell.qml b/bookmarks/shell.qml new file mode 100644 index 0000000..3212e06 --- /dev/null +++ b/bookmarks/shell.qml @@ -0,0 +1,26 @@ +// 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. + +import Quickshell +import Quickshell.Io + +ShellRoot { + Bookmarks { id: bookmarks } + + // Bound to SUPER + b in Hyprland: + // `qs -p <this dir> ipc call bookmarks toggle` + IpcHandler { + target: "bookmarks" + function toggle() { bookmarks.toggle(); } + function open() { bookmarks.open = true; } + function close() { bookmarks.close(); } + } +} |
