aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--README.md1
-rw-r--r--bookmarks/Bookmarks.qml601
l---------bookmarks/Glow.qml1
-rw-r--r--bookmarks/README.md64
l---------bookmarks/Theme.qml1
-rwxr-xr-xbookmarks/bookmarks.py296
-rw-r--r--bookmarks/shell.qml26
8 files changed, 992 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6d0dc98..a50a617 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/README.md b/README.md
index d06723c..71f3387 100644
--- a/README.md
+++ b/README.md
@@ -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(); }
+ }
+}