diff --git a/.gitignore b/.gitignore index a5157a0..aaebce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ **/*.swp flake.lock +scripts/node_modules/ diff --git a/cli/sh.nix b/cli/sh.nix index 9c37c6d..147b8b7 100644 --- a/cli/sh.nix +++ b/cli/sh.nix @@ -22,6 +22,10 @@ in recursive = true; source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/dotfiles/scripts"; }; + xdg.configFile."quickshell" = { + recursive = true; + source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/dotfiles/quickshell"; + }; home.packages = with pkgs; [ lm_sensors gnugrep diff --git a/gui/niri.nix b/gui/niri.nix index f39b437..d5ffc73 100644 --- a/gui/niri.nix +++ b/gui/niri.nix @@ -53,7 +53,7 @@ in "PATH=/run/current-system/sw/bin:%h/.nix-profile/bin" "WAYLAND_DISPLAY=wayland-1" ]; - ExecStart = "%h/.config/scripts/wallpaper.sh fetch"; + ExecStart = "%h/.config/scripts/wallpaper.mjs fetch"; }; }; @@ -82,7 +82,7 @@ in "PATH=/run/current-system/sw/bin:%h/.nix-profile/bin" "WAYLAND_DISPLAY=wayland-1" ]; - ExecStart = "%h/.config/scripts/wallpaper.sh next"; + ExecStart = "%h/.config/scripts/wallpaper.mjs next"; }; }; @@ -103,13 +103,14 @@ in "PATH=/run/current-system/sw/bin:%h/.nix-profile/bin" "WAYLAND_DISPLAY=wayland-1" ]; - ExecStart = "%h/.config/scripts/wallpaper.sh next"; + ExecStart = "%h/.config/scripts/wallpaper.mjs next"; }; Install.WantedBy = [ "sleep.target" ]; }; home.packages = with pkgs; [ awww + quickshell wtype wl-gammarelay-rs brightnessctl @@ -247,7 +248,7 @@ in "Mod+T".action = spawn "kitty"; "Mod+O".action = spawn "fuzzel"; - "Mod+Shift+W".action = spawn "bash" "-c" "~/.config/scripts/wallpaper.sh next"; + "Mod+Shift+W".action = spawn "bash" "-c" "~/.config/scripts/wallpaper.mjs next"; "Mod+8".action = spawn "bash" "-c" ("sudo /usr/local/sbin/power.sh powersave"); "Mod+9".action = spawn "bash" "-c" ("sudo /usr/local/sbin/power.sh performance"); @@ -412,12 +413,17 @@ in { argv = [ "${pkgs.awww}/bin/awww-daemon" ]; } + { + # Wallpaper overlay: clock, caption, timers (loads + # ~/.config/quickshell/shell.qml). + argv = [ "${pkgs.quickshell}/bin/quickshell" ]; + } { # Guardian photos-of-the-day slideshow; fetches the pool on first run argv = [ "bash" "-c" - "~/.config/scripts/wallpaper.sh next" + "~/.config/scripts/wallpaper.mjs next" ]; } { diff --git a/quickshell/shell.qml b/quickshell/shell.qml new file mode 100644 index 0000000..472e478 --- /dev/null +++ b/quickshell/shell.qml @@ -0,0 +1,253 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +// Wallpaper overlay, Google TV "spotlight" style: soft black gradient scrim +// at the bottom with the clock and photo caption in the bottom-right corner; +// systemd timers as a small dim readout top-right. Sits on the layer-shell +// bottom layer, so it renders above the awww wallpaper (background layer) +// but below windows, only visible on an empty workspace. +// +// The window color MUST be transparent or the default opaque white surface +// covers the wallpaper. + +Scope { + id: root + + property string captionText: "" + property string timersUserText: "" + property string timersAllText: "" + property string captionLoc: "" + property string captionDesc: "" + property bool timersHover: false + + // caption.txt is "LOCATION: description" or bare "description". Split the + // location off so it can be styled as an accent. + function splitCaption() { + var i = root.captionText.indexOf(": ") + if (i > 0) { + root.captionLoc = root.captionText.slice(0, i) + root.captionDesc = root.captionText.slice(i + 2) + } else { + root.captionLoc = "" + root.captionDesc = root.captionText + } + } + function esc(s) { + return s.replace(/&/g, "&").replace(//g, ">") + } + + PanelWindow { + id: win + anchors { + top: true + left: true + right: true + bottom: true + } + color: "transparent" + surfaceFormat.opaque: false + aboveWindows: false + focusable: false + exclusionMode: ExclusionMode.Ignore + + // Google TV spotlight scrim: dark fading up from the bottom so the text + // stays readable over any photo. + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + height: parent.height * 0.5 + gradient: Gradient { + GradientStop { position: 0.0; color: "#00000000" } + GradientStop { position: 0.7; color: "#50000000" } + GradientStop { position: 1.0; color: "#d4000000" } + } + } + + // Right-to-left blackish gradient down the whole right edge, so text on + // both corners reads against a darker backdrop. + Rectangle { + anchors { + top: parent.top + bottom: parent.bottom + right: parent.right + } + width: parent.width * 0.7 + gradient: Gradient { + orientation: Qt.Horizontal + GradientStop { position: 0.0; color: "#00000000" } + GradientStop { position: 0.5; color: "#40000000" } + GradientStop { position: 1.0; color: "#cc000000" } + } + } + + // Bottom-right: hero clock over the caption. + Column { + anchors { + right: parent.right + bottom: parent.bottom + rightMargin: 36 + bottomMargin: 32 + } + spacing: 12 + + Text { + anchors.right: parent.right + text: Qt.formatDateTime(clock.date, "HH:mm") + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 56 + font.weight: Font.Bold + color: "#ebdbb2" + } + + // Location above the description, styled as an orange accent. + Text { + id: captionLoc + visible: root.captionLoc !== "" + anchors.right: parent.right + text: root.esc(root.captionLoc) + textFormat: Text.RichText + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 17 + font.weight: Font.Bold + color: "#e78a4e" + horizontalAlignment: Text.AlignRight + lineHeight: 1.25 + } + + Text { + id: caption + anchors.right: parent.right + width: Math.min(1100, win.width * 0.95) + text: root.esc(root.captionDesc) + textFormat: Text.RichText + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 17 + color: "#d4be98" + wrapMode: Text.Wrap + maximumLineCount: 3 + elide: Text.ElideRight + horizontalAlignment: Text.AlignRight + lineHeight: 1.25 + } + } + + // Top-right: faint scrim + small timers readout. + Rectangle { + anchors { + top: parent.top + left: parent.left + right: parent.right + } + height: parent.height * 0.24 + gradient: Gradient { + GradientStop { position: 0.0; color: "#6b000000" } + GradientStop { position: 1.0; color: "#00000000" } + } + } + // Fixed-size hover box (decoupled from the text size) so the trigger + // region never moves under the cursor; otherwise the expand/collapse + // resize feeds back into enter/exit and flickers. + Item { + id: timersBox + anchors { + top: parent.top + right: parent.right + margins: 32 + } + width: 460 + height: 340 + // Both states cached by their own 60s poll; hover just toggles which + // one is visible, so there is no process-restart race to flicker. + Text { + id: timersText + visible: !root.timersHover + anchors { + top: parent.top + right: parent.right + } + text: root.timersUserText + textFormat: Text.RichText + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 16 + color: "#d4be98" + horizontalAlignment: Text.AlignRight + lineHeight: 1.5 + } + Text { + visible: root.timersHover + anchors { + top: parent.top + right: parent.right + } + text: root.timersAllText + textFormat: Text.RichText + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 16 + color: "#d4be98" + horizontalAlignment: Text.AlignRight + lineHeight: 1.5 + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.timersHover = true + onExited: root.timersHover = false + } + } + + SystemClock { + id: clock + precision: SystemClock.Minutes + } + } + + onCaptionTextChanged: root.splitCaption() + + // Caption: wallpaper.sh writes this file on every wallpaper change. + Process { + id: captionProc + command: [ "sh", "-c", "cat ~/.cache/wallpaper-guardian/caption.txt" ] + running: true + stdout: StdioCollector { + onStreamFinished: root.captionText = text.trim() + } + } + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: captionProc.running = true + } + + // Timers: USER always cached, USER+SYSTEM cached on the same 60s tick. + // Hover only flips visibility, so it is deterministic (no command swap). + Process { + id: userProc + command: [ "sh", "-c", "~/.config/scripts/timers.mjs" ] + running: true + stdout: StdioCollector { + onStreamFinished: root.timersUserText = text + } + } + Process { + id: allProc + command: [ "sh", "-c", "~/.config/scripts/timers.mjs all" ] + running: true + stdout: StdioCollector { + onStreamFinished: root.timersAllText = text + } + } + Timer { + interval: 60000 + running: true + repeat: true + onTriggered: { + userProc.running = true + allProc.running = true + } + } +} diff --git a/scripts/lib/applier.mjs b/scripts/lib/applier.mjs new file mode 100755 index 0000000..45084b2 --- /dev/null +++ b/scripts/lib/applier.mjs @@ -0,0 +1,35 @@ +// Adapter at the wallpaper-applier seam: awww (the running daemon) and awww +// (the set command). exec/spawnFn are injected so the module is testable; the +// composition root supplies the real child_process functions. + +export function awwwApplier({ exec, spawnFn, log = () => {} } = {}) { + function daemonRunning() { + try { + exec("awww", ["query"], { stdio: "ignore" }); + return true; + } catch { + return false; + } + } + + // Ensure the awww daemon is up; spawn it detached + stdio ignored so the + // long-lived daemon neither holds our lock nor dies when the parent exits. + async function ensure() { + if (daemonRunning()) return true; + log("starting awww-daemon"); + const child = spawnFn("awww-daemon", [], { stdio: "ignore", detached: true }); + child.on("error", () => {}); // ENOENT etc must not crash the CLI + child.unref(); + for (let i = 0; i < 10; i++) { + if (daemonRunning()) return true; + await new Promise((r) => setTimeout(r, 300)); + } + return daemonRunning(); + } + + function setWallpaper(file) { + exec("awww", ["img", "--transition-type", "fade", "--transition-duration", "2", file]); + } + + return { ensure, setWallpaper }; +} diff --git a/scripts/lib/http.mjs b/scripts/lib/http.mjs new file mode 100755 index 0000000..ae27a39 --- /dev/null +++ b/scripts/lib/http.mjs @@ -0,0 +1,45 @@ +import process from "node:process"; + +// Shared network helpers. Retry + timeout + a browser User-Agent, so callers +// don't repeat the failure handling. Two functions, nothing else. + +const UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; + +export async function fetchText(url, tries = 2) { + for (let i = 0; i < tries; i++) { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 45000); + try { + const r = await fetch(url, { headers: { "User-Agent": UA }, signal: ac.signal }); + if (!r.ok) throw new Error("HTTP " + r.status); + return await r.text(); + } catch { + if (i === tries - 1) return null; + await new Promise((r) => setTimeout(r, 1000 * (i + 1))); + } finally { + clearTimeout(t); + } + } + return null; +} + +export async function fetchBuffer(url, tries = 3) { + for (let i = 0; i < tries; i++) { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 120000); + try { + const r = await fetch(url, { headers: { "User-Agent": UA }, signal: ac.signal }); + if (!r.ok) throw new Error("HTTP " + r.status); + return Buffer.from(await r.arrayBuffer()); + } catch { + if (i === tries - 1) return null; + await new Promise((r) => setTimeout(r, 1500 * (i + 1))); + } finally { + clearTimeout(t); + } + } + return null; +} + +void process; // (kept for clarity if logging is later added here) diff --git a/scripts/lib/lock.mjs b/scripts/lib/lock.mjs new file mode 100755 index 0000000..822b901 --- /dev/null +++ b/scripts/lib/lock.mjs @@ -0,0 +1,20 @@ +// Serialize fetch/next over the pool dir via proper-lockfile. Returns false +// (and logs) when another run holds the lock, true after fn runs and releases. + +import lockfile from "proper-lockfile"; + +export async function withLock(dir, fn, { log = () => {} } = {}) { + let release; + try { + release = await lockfile.lock(dir, { stale: 60000, update: 30000, realpath: false }); + } catch { + log("another run is in progress, skipping"); + return false; + } + try { + await fn(); + } finally { + await release(); + } + return true; +} diff --git a/scripts/lib/pool.mjs b/scripts/lib/pool.mjs new file mode 100755 index 0000000..5ea44a7 --- /dev/null +++ b/scripts/lib/pool.mjs @@ -0,0 +1,127 @@ +// Photo store for the wallpaper pipeline. Hides the pool dir layout +// (. files, mtime pruning), the captions.tsv index, and the +// caption.txt overlay file behind a small object interface. Injectable +// `download` keeps it testable without a network. + +import * as fs from "node:fs"; +import path from "node:path"; +import { fetchBuffer } from "./http.mjs"; + +const EXT_RE = /\/master\/\d+\.(jpg|png)/; + +export function openPool(dir, { minWidth = 600, download = fetchBuffer, log = () => {} } = {}) { + fs.mkdirSync(dir, { recursive: true }); + + // Photos on disk: . files, plus current / caption.txt / captions.tsv. + function list() { + const out = []; + for (const f of fs.readdirSync(dir)) + if (/\.(jpg|png)$/.test(f)) out.push(path.join(dir, f)); + return out; + } + + function count() { + return list().length; + } + + // Download photos that aren't already on disk (or are empty/corrupt). Skips + // anything narrower than minWidth. Returns the number actually downloaded. + async function save(photos) { + let n = 0; + for (const p of photos) { + if (p.width < minWidth) continue; + const ext = (EXT_RE.exec(p.url) || [])[1] || "jpg"; + const f = path.join(dir, `${p.hash}.${ext}`); + if (fs.existsSync(f) && fs.statSync(f).size > 0) continue; + const buf = await download(p.url); + if (buf) { + fs.writeFileSync(f, buf); + n++; + } + } + return n; + } + + // Remove photo files older than maxAgeMs (mtime-based). Returns count removed. + function prune(maxAgeMs) { + const cutoff = Date.now() - maxAgeMs; + let removed = 0; + for (const f of fs.readdirSync(dir)) { + if (!/\.(jpg|png)$/.test(f)) continue; + const fp = path.join(dir, f); + try { + if (fs.statSync(fp).mtimeMs < cutoff) { + fs.rmSync(fp); + removed++; + } + } catch { + /* gone */ + } + } + return removed; + } + + function current() { + try { + return fs.readFileSync(path.join(dir, "current"), "utf8").trim(); + } catch { + return ""; + } + } + + function setCurrent(f) { + fs.writeFileSync(path.join(dir, "current"), f + "\n"); + } + + // Write the captions.tsv index: one row per hash, first non-empty desc wins. + // Rows: \t\t. + function indexMeta(photos) { + const seen = new Set(); + const lines = []; + for (const p of photos) { + if (seen.has(p.hash)) continue; + if (!p.desc) continue; // skip empties so the first non-empty wins + seen.add(p.hash); + lines.push(`${p.hash}\t${p.loc || ""}\t${p.desc}`); + } + fs.writeFileSync(path.join(dir, "captions.tsv"), lines.length ? lines.join("\n") + "\n" : ""); + } + + function readIndex() { + try { + return fs + .readFileSync(path.join(dir, "captions.tsv"), "utf8") + .split("\n") + .filter(Boolean) + .map((l) => { + const [hash, loc, ...rest] = l.split("\t"); + return { hash, loc: loc || "", desc: rest.join("\t") }; + }); + } catch { + return []; + } + } + + function meta(hash) { + return readIndex().find((c) => c.hash === hash); + } + + // Refresh the caption.txt overlay (read by Quickshell): "LOC: desc" when the + // photo has a location, bare desc otherwise, or remove the file if none. + function writeCaptionFor(hash) { + const c = meta(hash); + const capFile = path.join(dir, "caption.txt"); + if (c && c.desc) { + fs.writeFileSync(capFile, (c.loc ? `${c.loc}: ${c.desc}` : c.desc) + "\n"); + } else { + try { + fs.rmSync(capFile); + } catch { + /* none */ + } + } + } + + void log; + return { save, list, count, prune, current, setCurrent, indexMeta, meta, writeCaptionFor }; +} diff --git a/scripts/lib/source.mjs b/scripts/lib/source.mjs new file mode 100755 index 0000000..0e1c998 --- /dev/null +++ b/scripts/lib/source.mjs @@ -0,0 +1,131 @@ +// Guardian "photos of the day" source: RSS feed discovery + gallery HTML +// parsing. This is the deep module of the wallpaper pipeline: all cheerio and +// rss-parser complexity hides behind parseGalleryPage (pure, feed it HTML) and +// fetchPhotos (orchestrates RSS -> gallery pages -> dedup). +// +// Photo = { hash, url, width, loc, desc } +// hash i.guim.co.uk media hash (40 hex chars), the pool file name +// url the srcSet variant to download (largest ?width=) +// width the CDN query width, which controls the actual downloaded resolution +// loc photo location, from the figure's

+// desc photo description, the figcaption's bare text minus location/credit +// +// A gallery figure is
with a
: location in its

, +// description as the caption text, credit in , share button in a +// . The hero/lead figure has an empty figcaption and is skipped. +// Images live in /. + +import { load } from "cheerio"; +import Parser from "rss-parser"; +import { fetchText } from "./http.mjs"; + +const SERIES_DEFAULT = + "https://www.theguardian.com/news/series/ten-best-photographs-of-the-day"; +const GUIM = /i\.guim\.co\.uk/; +const DROP_VARIANT = /overlay|precrop/; +const HASH_RE = /\/media\/([a-f0-9]{40})\//; +const QUERY_W_RE = /[?&]width=(\d+)/; + +// Pick the widest srcSet variant for a figure. The CDN's ?width= query sets the +// actual downloaded resolution; master/ is only the source's native size, so +// it is ignored. Among equal-width variants prefer one without an explicit +// height (odd crops). Returns { hash, url, width } or null. +function pickVariant($, $f) { + let best = null; + $f.find("img, source").each((_, im) => { + const $im = $(im); + const urls = []; + const src = $im.attr("src"); + if (src) urls.push(src); + const ss = $im.attr("srcset"); + if (ss) for (const part of ss.split(",")) urls.push(part.trim().split(/\s+/)[0]); + for (const u of urls) { + if (!GUIM.test(u)) continue; + if (DROP_VARIANT.test(u)) continue; + const hash = HASH_RE.exec(u); + if (!hash) continue; + const qw = QUERY_W_RE.exec(u); + const width = qw ? +qw[1] : 0; + const noHeight = /[?&]height=/.test(u) ? 0 : 1; + if (!best || width > best.width || (width === best.width && noHeight > best.noHeight)) + best = { hash: hash[1], url: u, width }; + } + }); + return best; +} + +// Extract the location (first h1/h2/h3 in the figcaption) and the bare +// description (caption text with chrome stripped: headings, credit, share +// button, images). +function captionFields($, $cap) { + const loc = $cap.find("h1, h2, h3").first().text().replace(/\s+/g, " ").trim(); + const $c = $cap.clone(); + $c.find("h1, h2, h3, small, gu-island, button, svg, picture").remove(); + $c.find("[class]") + .filter((_, d) => /share/i.test($(d).attr("class") || "")) + .remove(); + let desc = $c.text().replace(/\s+/g, " ").trim(); + desc = desc.replace(/View image in fullscreen/gi, "").replace(/Share\s*$/i, "").trim(); + return { loc, desc }; +} + +// Pure: parse one gallery page into its Photo list. No network, feed it HTML. +export function parseGalleryPage(html) { + const $ = load(html); + const photos = []; + $("figure").each((_, el) => { + const $f = $(el); + const $cap = $f.find("figcaption"); + if (!$cap.length) return; // hero/lead figure has none + const best = pickVariant($, $f); + if (!best) return; + const { loc, desc } = captionFields($, $cap); + photos.push({ hash: best.hash, url: best.url, width: best.width, loc, desc }); + }); + return photos; +} + +// Orchestrator: read the RSS feed, pick galleries in the window, fetch each +// page, dedup across galleries (widest url per hash, first non-empty caption +// per hash). Throws on RSS failure so the caller can abort without wiping the +// pool; a single gallery page whose fetch fails is skipped. +export async function fetchPhotos({ days, seriesUrl = SERIES_DEFAULT, log = () => {} }) { + const parser = new Parser(); + let feed; + try { + feed = await parser.parseURL(`${seriesUrl}/rss`); + } catch (e) { + throw new Error(`RSS fetch failed: ${e && e.message ? e.message : e}`); + } + + const cutoff = Date.now() - days * 86400_000; + const links = []; + for (const item of feed.items) { + if (!/photos? of the day|photos? of the weekend/i.test(item.title || "")) continue; + if (!/\/gallery\//.test(item.link || "")) continue; + const ts = new Date(item.isoDate || item.pubDate || item.date || 0).getTime(); + if (Number.isFinite(ts) && ts >= cutoff) links.push(item.link); + } + const galleries = [...new Set(links)]; + log(`${galleries.length} galleries in the ${days}-day window`); + + const byHash = new Map(); // hash -> widest {hash,url,width} + const caps = new Map(); // hash -> first non-empty {loc,desc} + for (const g of galleries) { + let html = await fetchText(g); + if (html == null) html = await fetchText(g + ".json"); // some pages serve via .json + if (html == null) continue; + for (const p of parseGalleryPage(html)) { + const cur = byHash.get(p.hash); + if (!cur || p.width > cur.width) byHash.set(p.hash, p); + if (!caps.has(p.hash) && (p.loc || p.desc)) caps.set(p.hash, { loc: p.loc, desc: p.desc }); + } + } + + const photos = []; + for (const p of byHash.values()) { + const c = caps.get(p.hash) || { loc: p.loc, desc: p.desc }; + photos.push({ hash: p.hash, url: p.url, width: p.width, loc: c.loc, desc: c.desc }); + } + return photos; +} diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..47db257 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,394 @@ +{ + "name": "wallpaper-guardian", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wallpaper-guardian", + "dependencies": { + "cheerio": "^1.0.0", + "proper-lockfile": "^4.1.2", + "rss-parser": "^3.13.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, + "node_modules/rss-parser/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..3981a21 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "wallpaper-guardian", + "private": true, + "type": "module", + "dependencies": { + "cheerio": "^1.0.0", + "proper-lockfile": "^4.1.2", + "rss-parser": "^3.13.0" + } +} diff --git a/scripts/timers.mjs b/scripts/timers.mjs new file mode 100755 index 0000000..56a268b --- /dev/null +++ b/scripts/timers.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Wallpaper-overlay timers block. Quickshell runs this every 60s and renders +// stdout as Text.RichText. Two groups (USER, SYSTEM); each timer is a table row +// with the name, next-trigger and last-trigger in aligned columns. Relative +// times parsed straight from systemctl's LEFT/PASSED columns (no date math or +// timezone work). Color roles: name warm-white bold, next-trigger light, +// last-trigger dim grey, group header orange. +import { execFileSync } from "node:child_process"; + +// Compact systemctl's unit words ("1day 3h", "2 weeks") to 1d 3h / 2w. +// mark is "left" (future => "in X") or "ago". +function rel(s, mark) { + const out = s + .replace(/years?/g, "y") + .replace(/months?/g, "mo") + .replace(/weeks?/g, "w") + .replace(/days?/g, "d") + .replace(/hours?/g, "h") + .replace(/minutes?/g, "min") + .replace(/seconds?/g, "s") + .replace(/\s+/g, " ") + // drop the space between value and unit ("2 d" -> "2d") + .replace(/(\d) ([a-zA-Z])/g, "$1$2") + .trim(); + return mark === "left" ? `in ${out}` : `${out} ago`; +} + +// One timer line from `systemctl [--user] list-timers --all --no-pager`. +// LAST/PASSED may be `-` for a timer that has not fired yet, so LAST accepts +// either a datetime or a dash, and LEFT is matched explicitly (not `.*`) so it +// cannot swallow the trailing dashes. +const dt = "[A-Z][a-z]{2} [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} [A-Z]{2,4}"; +const leftRe = "(?:\\d+[a-z]+(?: \\d+[a-z]+)*|now|n/a|-)"; +const lineRe = new RegExp( + "^(" + dt + ") +(" + leftRe + ") +(" + dt + "|-) +(.*) +([^ ]+)\\.timer +([^ ]+)\\.service$" +); + +function parseTimers(body) { + const rows = []; + for (const line of body.split("\n")) { + const m = line.match(lineRe); + if (!m) continue; + const name = m[5]; + const leftRaw = m[2].trim(); + const left = / ago$/.test(leftRaw) + ? rel(leftRaw.replace(/ ago$/, ""), "ago") + : rel(leftRaw.replace(/ *left$/, ""), "left"); + const passedRaw = m[4].trim(); + const passed = / ago$/.test(passedRaw) + ? rel(passedRaw.replace(/ ago$/, ""), "ago") + : ""; + rows.push( + `` + + `${name}` + + `${left}` + + `${passed}` + + `` + ); + } + return rows.join(""); +} + +function emitGroup(header, ...args) { + let body; + try { + body = execFileSync("systemctl", [...args, "--all", "--no-pager"], { + encoding: "utf8", + }); + } catch { + return ""; + } + const timers = parseTimers(String(body)); + if (!timers) return ""; + return ( + `${header}` + + timers + ); +} + +const showSystem = process.argv[2] === "all"; +const spacer = ``; +const out = + `` + + emitGroup("USER", "--user", "list-timers") + + (showSystem ? spacer + emitGroup("SYSTEM", "list-timers") : "") + + `
`; +process.stdout.write(out); diff --git a/scripts/wallpaper.mjs b/scripts/wallpaper.mjs new file mode 100755 index 0000000..41a929d --- /dev/null +++ b/scripts/wallpaper.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Guardian "photos of the day" wallpaper pool and slideshow. Thin composition +// root: wires the deep modules (source, pool, applier, lock) and dispatches the +// CLI. All parsing/download/locking complexity lives in lib/. +// +// Pool: ${XDG_CACHE_HOME:-$HOME/.cache}/wallpaper-guardian/ +// One file per photo, named by the i.guim.co.uk media hash. The pool rolls +// over a 5-day window: the daily `fetch` adds new galleries and prunes files +// older than 5 days. +// +// Commands: +// fetch pull galleries from the last 5 days, download new photos, prune old +// next pick a random photo other than the current one and apply it +// apply F set the wallpaper to file F +// status pool stats + +import * as fs from "node:fs"; +import { realpathSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync, spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { fetchPhotos } from "./lib/source.mjs"; +import { openPool } from "./lib/pool.mjs"; +import { awwwApplier } from "./lib/applier.mjs"; +import { withLock } from "./lib/lock.mjs"; + +const CACHE = `${process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache")}/wallpaper-guardian`; +const DAYS = 5; +const MIN_WIDTH = 600; // related-list and og:image thumbs are <= 465px wide +const log = (m) => process.stderr.write("wallpaper: " + m + "\n"); + +fs.mkdirSync(path.join(CACHE, "tmp"), { recursive: true }); + +const pool = openPool(CACHE, { minWidth: MIN_WIDTH, log }); +const applier = awwwApplier({ + exec: (bin, args, opts) => execFileSync(bin, args, opts), + spawnFn: (bin, args, opts) => spawn(bin, args, opts), + log, +}); + +// Shared fetch flow used by both `fetch` and `next` (empty pool). fetchPhotos +// throws on RSS failure so we keep the current pool and do NOT prune. +async function fetchFlow() { + let photos; + try { + photos = await fetchPhotos({ days: DAYS, log }); + } catch (e) { + log("RSS fetch failed, keeping current pool: " + e.message); + return; + } + pool.indexMeta(photos); + const n = await pool.save(photos); + pool.prune(DAYS * 86400_000); + log(`${n} new photos, pool is now ${pool.count()} files`); +} + +async function apply(file) { + if (!(await applier.ensure())) { + log("no compositor session, awww query failed"); + return false; + } + try { + applier.setWallpaper(file); + } catch (e) { + log("awww img failed: " + e.message); + return false; + } + pool.setCurrent(file); + const hash = path.basename(file).replace(/\.[^.]*$/, ""); + pool.writeCaptionFor(hash); + return true; +} + +function du(dir) { + let total = 0; + for (const f of fs.readdirSync(dir)) { + const p = path.join(dir, f); + const st = fs.statSync(p); + total += st.isDirectory() ? du(p) : st.size; + } + return total; +} + +function status() { + log(`pool: ${pool.count()} photos in ${CACHE}`); + log(`size: ${(du(CACHE) / 1024 / 1024).toFixed(1)}M`); + log(`current: ${pool.current() || "none"}`); +} + +async function main() { + const cmd = process.argv[2] || "next"; + switch (cmd) { + case "fetch": + return withLock(CACHE, fetchFlow, { log }); + case "next": + return withLock(CACHE, async () => { + let files = pool.list(); + if (files.length === 0) { + log("pool empty, fetching..."); + await fetchFlow(); + files = pool.list(); + } + if (files.length === 0) { + log("pool still empty after fetch"); + return; + } + const cur = pool.current(); + // shuffle, pick a photo other than the current one + for (let i = files.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [files[i], files[j]] = [files[j], files[i]]; + } + await apply(files.find((f) => f !== cur) || files[0]); + }, { log }); + case "apply": + return apply(process.argv[3]); + case "status": + return status(); + default: + log("usage: wallpaper.mjs "); + process.exit(1); + } +} + +const isMain = + process.argv[1] && + realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); +if (isMain) main(); + +export { CACHE, DAYS, fetchPhotos, fetchFlow, apply, status, pool }; diff --git a/scripts/wallpaper.sh b/scripts/wallpaper.sh deleted file mode 100755 index 202a98c..0000000 --- a/scripts/wallpaper.sh +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Guardian "photos of the day" wallpaper pool and slideshow. -# -# Pool: ${XDG_CACHE_HOME:-$HOME/.cache}/wallpaper-guardian/ -# One file per photo, named by the i.guim.co.uk media hash. The pool rolls -# over a 5-day window: the daily `fetch` adds new galleries and prunes files -# older than 5 days. -# -# curl on this machine is the jailed wrapper (bwrap): network + cwd + the XDG -# dirs rw-bind. Every file this script touches lives under the pool dir, so -# the wrapper's mounts cover it. Never use /tmp here. -# -# Downloads use image URLs exactly as the Guardian page embeds them: the CDN -# signatures cannot be altered, so the largest embedded width per photo wins. -# -# Commands: -# fetch pull galleries from the last 5 days, download new photos, prune old -# next pick a random photo other than the current one and apply it -# apply F set the wallpaper to file F -# status pool stats - -CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/wallpaper-guardian" -SERIES="https://www.theguardian.com/news/series/ten-best-photographs-of-the-day" -DAYS=5 -MIN_WIDTH=600 # related-list and og:image thumbs are <= 465px wide - -mkdir -p "$CACHE/tmp" - -# --- helpers ---------------------------------------------------------------- - -lock() { - exec 9>"$CACHE/lock" - if ! flock -n 9; then - echo "wallpaper: another run is in progress, skipping" >&2 - exit 0 - fi -} - -ensure_daemon() { - if ! awww query > /dev/null 2>&1; then - echo "wallpaper: starting awww-daemon" >&2 - # 9>&- keeps the daemon from inheriting the flock fd (exec 9> in lock()); - # otherwise the long-lived daemon holds the lock forever and every - # subsequent run reports "another run is in progress". - awww-daemon > /dev/null 2>&1 9>&- & - sleep 1 - fi - awww query > /dev/null 2>&1 -} - -parse_html() { - # stdin: page HTML. stdout: hashwidthurl per embedded image. - sed 's/&/\&/g' | - grep -oE 'https://i\.guim\.co\.uk/img/media/[a-f0-9]+/[0-9_]+/master/[0-9]+\.(jpg|png)[^"<> ]*' | - while IFS= read -r url; do - # og:image promo cards carry overlay-base64 (Guardian logo) + precrop; - # the same photo always has clean full-aspect variants, drop these. - case "$url" in *overlay*|*precrop*) continue ;; esac - hash=$(printf '%s' "$url" | sed -n 's:.*/media/\([a-f0-9]*\)/.*:\1:p') - width=$(printf '%s' "$url" | sed -n 's:.*[?&]width=\([0-9]*\).*:\1:p') - [ -n "$hash" ] || continue - [ -n "$width" ] || width=0 - printf '%s\t%s\t%s\n' "$hash" "$width" "$url" - done || true -} - -scrape_page() { - # $1 = gallery URL. Print hashwidthurl for every embedded image. - local html - html=$(curl -sf --max-time 30 "$1" 2>/dev/null || true) - if [ -z "$html" ]; then - html=$(curl -sf --max-time 30 "$1.json" 2>/dev/null || true) - fi - [ -n "$html" ] || return 0 - printf '%s\n' "$html" | parse_html -} - -# --- RSS -------------------------------------------------------------------- - -select_galleries() { - # $1 = rss file, $2 = output file of gallery URLs within the $DAYS window. - local cutoff - cutoff=$(date -d "$DAYS days ago" +%s) - - # Items as dc:datetitlelink; keep "photos of the day|weekend" - # galleries published within the last $DAYS days. - awk ' - // { blk = ""; on = 1 } - on { blk = blk $0 "\n" } - /<\/item>/ { - on = 0 - link = blk; sub(/^.*/, "", link); sub(/<\/link>.*$/, "", link) - title = blk; sub(/^.*/, "", title); sub(/<\/title>.*$/, "", title) - d = blk; sub(/^.*<dc:date>/, "", d); sub(/<\/dc:date>.*$/, "", d) - if (link ~ /\/gallery\//) print d "\t" title "\t" link - }' "$1" > "$1.items" - - : > "$2" - while IFS=$'\t' read -r d title link; do - [ -n "$link" ] || continue - local lc - lc=$(printf '%s' "$title" | tr 'A-Z' 'a-z') - case "$lc" in - *"photos of the day"*|*"photos of the weekend"*) ;; - *) continue ;; - esac - ts=$(date -d "$d" +%s 2>/dev/null || echo 0) - [ "$ts" -ge "$cutoff" ] || continue - echo "$link" - done < "$1.items" | sort -u > "$2" - rm -f "$1.items" -} - -# --- fetch ------------------------------------------------------------------ - -fetch_unlocked() { - local rss="$CACHE/tmp/rss.xml" - if ! curl -sf --max-time 30 "$SERIES/rss" > "$rss"; then - echo "wallpaper: RSS fetch failed, keeping current pool" >&2 - return 1 - fi - - select_galleries "$rss" "$CACHE/tmp/galleries.txt" - - local ng - ng=$(wc -l < "$CACHE/tmp/galleries.txt" | tr -d ' ') - echo "wallpaper: $ng galleries in the $DAYS-day window" >&2 - - # Scrape every gallery, keep the widest variant per photo hash. - while IFS= read -r gal; do - scrape_page "$gal" - done < "$CACHE/tmp/galleries.txt" > "$CACHE/tmp/scraped.tsv" - - # Widest per hash; on a width tie prefer no explicit height (full aspect). - awk -F'\t' ' - { - key = $1; w = $2 + 0 - nh = ($3 ~ /[?&]height=/) ? 0 : 1 - if (!(key in line) || w > best[key] || (w == best[key] && nh > nhbest[key])) - { line[key] = $0; best[key] = w; nhbest[key] = nh } - } - END { for (k in line) print line[k] } - ' "$CACHE/tmp/scraped.tsv" > "$CACHE/tmp/wanted.tsv" - - local nw - nw=$(wc -l < "$CACHE/tmp/wanted.tsv" | tr -d ' ') - echo "wallpaper: $nw unique photos scraped" >&2 - - # Download new photos; skip existing, junk under MIN_WIDTH, bad extensions. - local found=0 - while IFS=$'\t' read -r hash width url; do - [ "$width" -ge "$MIN_WIDTH" ] || continue - local ext - ext=$(printf '%s' "$url" | sed -n 's:.*/master/[0-9]*\.\(jpg\|png\).*:\1:p') - [ -n "$ext" ] || continue - local f="$CACHE/$hash.$ext" - if [ ! -s "$f" ]; then - if curl -sfL --retry 3 --max-time 90 > "$f" "$url"; then - found=$((found + 1)) - else - rm -f "$f" - fi - fi - done < "$CACHE/tmp/wanted.tsv" - - # Prune photos older than the window, never the state files (current, lock). - # Failure-safe: a missed day lingers until it ages out instead of being - # wiped by a transient fetch failure. - find "$CACHE" -maxdepth 1 -type f \ - \( -name '*.jpg' -o -name '*.png' \) -mtime +"$DAYS" -delete - - echo "wallpaper: $found new photos, pool is now $(pool_count) files" -} - -fetch() { - lock - fetch_unlocked -} - -# --- apply ------------------------------------------------------------------ - -pool_count() { - find "$CACHE" -maxdepth 1 -type f \( -name '*.jpg' -o -name '*.png' \) | wc -l -} - -next() { - lock - ensure_daemon || { echo "wallpaper: no compositor session, awww query failed" >&2; exit 0; } - local pool cur pick - pool=$(find "$CACHE" -maxdepth 1 -type f \( -name '*.jpg' -o -name '*.png' \) | shuf) - if [ -z "$pool" ]; then - echo "wallpaper: pool empty, fetching..." >&2 - fetch_unlocked || exit 1 - pool=$(find "$CACHE" -maxdepth 1 -type f \( -name '*.jpg' -o -name '*.png' \) | shuf) - fi - [ -n "$pool" ] || { echo "wallpaper: pool still empty after fetch" >&2; exit 1; } - cur=$(cat "$CACHE/current" 2>/dev/null || true) - pick=$(printf '%s\n' "$pool" | grep -vxF "$cur" | head -1) - [ -n "$pick" ] || pick=$(printf '%s\n' "$pool" | head -1) - apply "$pick" -} - -apply() { - ensure_daemon || exit 0 - awww img --transition-type fade --transition-duration 2 "$1" - printf '%s\n' "$1" > "$CACHE/current" -} - -status() { - echo "pool: $(pool_count) photos in $CACHE" - echo "size: $(du -sh "$CACHE" 2>/dev/null | cut -f1)" - echo "current: $(cat "$CACHE/current" 2>/dev/null || echo none)" -} - -case "${1:-next}" in - fetch) fetch ;; - next) next ;; - apply) shift; apply "$1" ;; - status) status ;; - *) echo "usage: wallpaper.sh <fetch|next|apply FILE|status>"; exit 1 ;; -esac