From f59478fcfebb83f99729a6d7a0b7a1b50bd1779d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:53:33 +0000 Subject: [PATCH 01/10] System Monitor, Notifications, Drives, System Updates and Desktop Settings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/desktop-settings/backslash.toml | 96 ++ .../desktop-settings/commands/settings.ts | 188 ++++ extensions/desktop-settings/lib.ts | 865 ++++++++++++++++++ extensions/desktop-settings/lib_test.ts | 93 ++ extensions/drives/backslash.toml | 38 + extensions/drives/commands/drives.ts | 355 +++++++ extensions/drives/lib.ts | 225 +++++ extensions/drives/lib_test.ts | 209 +++++ extensions/notifications/backends.ts | 226 +++++ extensions/notifications/backslash.toml | 56 ++ .../notifications/commands/notifications.ts | 224 +++++ .../commands/toggle-do-not-disturb.ts | 12 + extensions/notifications/lib.ts | 180 ++++ extensions/notifications/lib_test.ts | 130 +++ extensions/system-monitor/backslash.toml | 50 + extensions/system-monitor/commands/monitor.ts | 378 ++++++++ .../system-monitor/commands/processes.ts | 3 + extensions/system-monitor/lib.ts | 433 +++++++++ extensions/system-monitor/lib_test.ts | 242 +++++ extensions/system-monitor/processes.ts | 194 ++++ extensions/system-monitor/system.ts | 222 +++++ extensions/updates/backslash.toml | 68 ++ extensions/updates/commands/updates.ts | 380 ++++++++ extensions/updates/lib.ts | 369 ++++++++ extensions/updates/lib_test.ts | 190 ++++ 25 files changed, 5426 insertions(+) create mode 100644 extensions/desktop-settings/backslash.toml create mode 100644 extensions/desktop-settings/commands/settings.ts create mode 100644 extensions/desktop-settings/lib.ts create mode 100644 extensions/desktop-settings/lib_test.ts create mode 100644 extensions/drives/backslash.toml create mode 100644 extensions/drives/commands/drives.ts create mode 100644 extensions/drives/lib.ts create mode 100644 extensions/drives/lib_test.ts create mode 100644 extensions/notifications/backends.ts create mode 100644 extensions/notifications/backslash.toml create mode 100644 extensions/notifications/commands/notifications.ts create mode 100644 extensions/notifications/commands/toggle-do-not-disturb.ts create mode 100644 extensions/notifications/lib.ts create mode 100644 extensions/notifications/lib_test.ts create mode 100644 extensions/system-monitor/backslash.toml create mode 100644 extensions/system-monitor/commands/monitor.ts create mode 100644 extensions/system-monitor/commands/processes.ts create mode 100644 extensions/system-monitor/lib.ts create mode 100644 extensions/system-monitor/lib_test.ts create mode 100644 extensions/system-monitor/processes.ts create mode 100644 extensions/system-monitor/system.ts create mode 100644 extensions/updates/backslash.toml create mode 100644 extensions/updates/commands/updates.ts create mode 100644 extensions/updates/lib.ts create mode 100644 extensions/updates/lib_test.ts diff --git a/extensions/desktop-settings/backslash.toml b/extensions/desktop-settings/backslash.toml new file mode 100644 index 0000000..ad79baa --- /dev/null +++ b/extensions/desktop-settings/backslash.toml @@ -0,0 +1,96 @@ +[extension] +id = "desktop-settings" +title = "Desktop Settings" +description = "Search every settings panel of GNOME, KDE Plasma, Xfce or Cinnamon in English or French, and flip quick toggles" +version = "1.0.0" +author = "Backslash" +icon = "bs-settings" +license = "MIT" + +# XDG_CURRENT_DESKTOP tells the desktop; PATH and the bin folders tell which settings apps are +# installed. The programs below open settings panels; gsettings, kreadconfig and +# plasma-apply-colorscheme read and flip the quick toggles. +[permissions] +env = ["XDG_CURRENT_DESKTOP", "PATH"] +read = [ + "/usr/bin", + "/usr/local/bin", + "/bin", + "/usr/sbin", + "/sbin", + "/usr/games", + "~/.local/bin", + "/run/current-system/sw/bin", + "~/.nix-profile/bin", + "/snap/bin", +] +run = [ + "gsettings", + "kreadconfig6", + "kreadconfig5", + "plasma-apply-colorscheme", + "gnome-control-center", + "systemsettings", + "systemsettings5", + "kcmshell6", + "kcmshell5", + "cinnamon-settings", + "xfce4-settings-manager", + "xfce4-appearance-settings", + "xfce4-display-settings", + "xfce4-color-settings", + "xfce4-keyboard-settings", + "xfce4-mouse-settings", + "xfce4-power-manager-settings", + "xfce4-notifyd-config", + "xfce4-mime-settings", + "exo-preferred-applications", + "xfce4-session-settings", + "xfce4-screensaver-preferences", + "light-locker-settings", + "xfce4-accessibility-settings", + "xfce4-about", + "xfce4-settings-editor", + "xfce4-panel", + "xfdesktop-settings", + "xfwm4-settings", + "xfwm4-tweaks-settings", + "xfwm4-workspace-settings", + "thunar-volman-settings", + "nm-connection-editor", + "blueman-manager", + "blueberry", + "overskride", + "pavucontrol", + "pwvucontrol", + "pavucontrol-qt", + "nwg-displays", + "wdisplays", + "arandr", + "nwg-look", + "lxappearance", + "qt6ct", + "qt5ct", + "system-config-printer", +] + +[[commands]] +name = "settings" +title = "Desktop Settings" +description = "Open any settings panel: Wi-Fi, displays, sound, keyboard, printers…" +icon = "bs-settings" +keywords = [ + "settings", + "preferences", + "control center", + "system settings", + "réglages", + "paramètres", + "préférences", + "configuration", + "gnome-control-center", + "systemsettings", + "kcm", + "dark mode", + "mode sombre", +] diff --git a/extensions/desktop-settings/commands/settings.ts b/extensions/desktop-settings/commands/settings.ts new file mode 100644 index 0000000..b1fa730 --- /dev/null +++ b/extensions/desktop-settings/commands/settings.ts @@ -0,0 +1,188 @@ +import { action, close, copy, exec, Icons, type Item, list, meta, refresh, toast } from "@backslash/api"; +import { + CATEGORIES, + CINNAMON_TOGGLES, + commandLine, + type Desktop, + DESKTOP_NAMES, + detectDesktop, + GNOME_TOGGLES, + isDarkScheme, + isOn, + type Panel, + panelsFor, + parseGnomeVersion, + type Toggle, +} from "../lib.ts"; + +const PATH = (Deno.env.get("PATH") ?? "").split(":").filter(Boolean); + +/** + * Whether a program is on PATH. Folders outside the read permission can't be looked into: when + * none can, assume it is there. + */ +async function installed(program: string): Promise { + let looked = false; + for (const dir of PATH) { + try { + const info = await Deno.stat(`${dir}/${program}`); + looked = true; + if (info.isFile) return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) looked = true; + } + } + return !looked; +} + +/** Starts the settings window detached from Backslash, with the first command that is installed. */ +function launch(panel: Panel): string { + for (const [program, ...args] of panel.commands) { + try { + new Deno.Command(program, { + args, + env: panel.env, + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn().unref(); + return program; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable)) throw err; + } + } + throw new Error(`${panel.commands.map((c) => c[0]).join(" or ")} is not installed`); +} + +function panelItem(p: Panel, desktop: Desktop): Item { + const command = commandLine(p.commands[0]); + return { + id: p.id, + title: p.title, + subtitle: p.description, + icon: { source: p.icon, tint: p.tint }, + section: p.category, + keywords: [...p.keywords, p.category, ...p.commands.flat()], + detail: { + markdown: p.description, + metadata: [ + meta.tags("Category", [{ text: p.category, color: p.tint }]), + meta.text("Desktop", p.env ? "GNOME (installed apart)" : DESKTOP_NAMES[desktop], { icon: Icons.monitor }), + meta.text("Opens", command, { icon: Icons.terminal }), + ...(p.commands.length > 1 + ? [meta.text("Or", p.commands.slice(1).map((c) => c[0]).join(", "), { color: "gray" })] + : []), + ], + }, + actions: [ + action("Open", () => { + launch(p); + return close(); + }, { icon: Icons.external }), + copy(command, "Copy Command", { section: "Copy", shortcut: "c" }), + ], + }; +} + +function toggleItem(t: Toggle, on: boolean, set: (on: boolean) => Promise): Item { + return { + id: `toggle:${t.id}`, + title: t.title, + subtitle: t.description, + icon: { source: on ? t.icon[0] : t.icon[1], tint: on ? t.tint : "gray" }, + section: "Quick Toggles", + keywords: [...t.keywords, "toggle", "switch", "basculer", "activer", "désactiver"], + accessories: [{ tag: on ? "On" : "Off", color: on ? t.tint : "gray" }], + detail: { + markdown: t.description, + metadata: [ + meta.text("State", on ? "On" : "Off", { icon: on ? Icons.check : Icons.cross, color: on ? "green" : "gray" }), + ...(t.schema ? [meta.text("Setting", `${t.schema} ${t.key}`, { icon: Icons.braces })] : []), + ], + }, + actions: [ + action(on ? `Turn Off ${t.title}` : `Turn On ${t.title}`, async () => { + await set(!on); + await toast(`${t.title} ${on ? "off" : "on"}`, { style: "success" }); + return refresh(); + }, { icon: on ? t.icon[1] : t.icon[0] }), + ], + }; +} + +/** gsettings switches; keys this version doesn't have are left out. */ +async function gsettingsToggles(toggles: Toggle[]): Promise { + const items = await Promise.all(toggles.map(async (t) => { + const value = await exec("gsettings", ["get", t.schema, t.key]).catch(() => undefined); + if (value === undefined) return undefined; + return toggleItem(t, isOn(t, value), async (on) => { + await exec("gsettings", ["set", t.schema, t.key, on ? t.on : t.off]); + }); + })); + return items.filter((i): i is Item => i !== undefined); +} + +/** Plasma's dark mode: switch between Breeze Dark and Breeze Light. */ +async function plasmaToggles(): Promise { + const read = (program: string) => + exec(program, ["--file", "kdeglobals", "--group", "General", "--key", "ColorScheme"]); + const scheme = await read("kreadconfig6").catch(() => read("kreadconfig5")).catch(() => undefined); + if (scheme === undefined) return []; + const toggle: Toggle = { + id: "dark", + title: "Dark Colors", + description: `Switch between Breeze Dark and Breeze Light (now: ${scheme || "Breeze"})`, + schema: "", + key: "", + on: "BreezeDark", + off: "BreezeLight", + icon: [Icons.moon, Icons.sun], + tint: "purple", + keywords: ["dark mode", "dark theme", "mode sombre", "thème sombre", "breeze"], + }; + return [toggleItem(toggle, isDarkScheme(scheme), async (on) => { + await exec("plasma-apply-colorscheme", [on ? "BreezeDark" : "BreezeLight"]); + })]; +} + +export default async () => { + const desktop = detectDesktop(Deno.env.get("XDG_CURRENT_DESKTOP") ?? ""); + const gnomeInstalled = desktop === "gnome" || desktop === "other" ? await installed("gnome-control-center") : false; + const gnomeVersion = gnomeInstalled + ? await exec("gnome-control-center", ["--version"]).then(parseGnomeVersion).catch(() => undefined) + : undefined; + const all = panelsFor(desktop, { gnomeVersion, gnomeInstalled }); + // Only panels whose program is here (pavucontrol, blueman… are optional on Xfce and window managers). + const available = await Promise.all( + all.map(async (p) => (await Promise.all(p.commands.map((c) => installed(c[0])))).some(Boolean)), + ); + const panels = all.filter((_, i) => available[i]); + const toggles = desktop === "gnome" + ? await gsettingsToggles(GNOME_TOGGLES) + : desktop === "cinnamon" + ? await gsettingsToggles(CINNAMON_TOGGLES) + : desktop === "kde" + ? await plasmaToggles() + : []; + + const order = (p: Panel) => CATEGORIES.indexOf(p.category); + const items = [ + ...toggles, + ...panels.map((p, i) => ({ p, i })).sort((a, b) => order(a.p) - order(b.p) || a.i - b.i).map(({ p }) => + panelItem(p, desktop) + ), + ]; + const name = DESKTOP_NAMES[desktop]; + return list({ + placeholder: `Search ${desktop === "other" ? "" : `${name} `}settings…`, + empty: { + title: desktop === "other" ? "No settings app found" : "No settings panel matches", + description: desktop === "other" + ? "Install nm-connection-editor, blueman, pavucontrol, nwg-look or GNOME Settings to see them here" + : `Try another word, in English or in French`, + icon: Icons.settings, + }, + status: `${name} · ${panels.length} panels${toggles.length ? ` · ${toggles.length} quick toggles` : ""}`, + items, + }); +}; diff --git a/extensions/desktop-settings/lib.ts b/extensions/desktop-settings/lib.ts new file mode 100644 index 0000000..6e3a7bd --- /dev/null +++ b/extensions/desktop-settings/lib.ts @@ -0,0 +1,865 @@ +// The settings panels of each desktop, with what to run to open them. Pure, tested in lib_test.ts. + +import { type Color, Icons } from "@backslash/api"; + +export type Desktop = "gnome" | "kde" | "xfce" | "cinnamon" | "other"; + +export const DESKTOP_NAMES: Record = { + gnome: "GNOME", + kde: "KDE Plasma", + xfce: "Xfce", + cinnamon: "Cinnamon", + other: "Your desktop", +}; + +/** `XDG_CURRENT_DESKTOP` is a colon-separated list: `ubuntu:GNOME`, `KDE`, `X-Cinnamon`, `XFCE`. */ +export function detectDesktop(xdg: string): Desktop { + const names = xdg.toLowerCase().split(":"); + if (names.some((n) => n === "kde" || n === "plasma")) return "kde"; + if (names.some((n) => n.includes("cinnamon"))) return "cinnamon"; + if (names.some((n) => n === "xfce")) return "xfce"; + if (names.some((n) => ["gnome", "gnome-classic", "unity", "budgie", "pantheon", "zorin"].includes(n))) return "gnome"; + return "other"; +} + +export type Category = + | "Connectivity" + | "Appearance" + | "Display & Sound" + | "Devices" + | "Apps & Windows" + | "Privacy & Security" + | "System"; + +export const CATEGORIES: Category[] = [ + "Connectivity", + "Appearance", + "Display & Sound", + "Devices", + "Apps & Windows", + "Privacy & Security", + "System", +]; + +interface Topic { + title: string; + category: Category; + icon: string; + tint: Color; + description: string; + keywords: string[]; +} + +const t = ( + title: string, + category: Category, + icon: string, + tint: Color, + description: string, + keywords: string[], +): Topic => ({ title, category, icon, tint, description, keywords }); + +/** What each panel is about, whatever the desktop, with English and French words to find it. */ +export const TOPICS = { + all: t("All Settings", "System", Icons.settings, "gray", "The settings app's main window", [ + "settings", + "preferences", + "control center", + "réglages", + "paramètres", + "préférences", + "centre de contrôle", + ]), + wifi: t("Wi-Fi", "Connectivity", Icons.wifi, "blue", "Wireless networks, hotspot", [ + "wifi", + "wireless", + "wlan", + "hotspot", + "sans fil", + "réseau", + "point d'accès", + ]), + network: t("Network", "Connectivity", Icons.globe, "blue", "Wired connections, VPN and proxy", [ + "network", + "ethernet", + "wired", + "vpn", + "proxy", + "ip", + "dns", + "réseau", + "filaire", + "connexion", + ]), + mobile: t("Mobile Network", "Connectivity", Icons.globe, "blue", "Mobile broadband modems", [ + "mobile", + "wwan", + "4g", + "5g", + "lte", + "modem", + "sim", + "réseau mobile", + ]), + bluetooth: t("Bluetooth", "Connectivity", Icons.bluetooth, "blue", "Pair headphones, mice and keyboards", [ + "bluetooth", + "pair", + "headphones", + "casque", + "appairer", + "écouteurs", + ]), + sharing: t("Sharing", "Connectivity", Icons.link, "teal", "Screen sharing, remote desktop, file and media sharing", [ + "sharing", + "remote desktop", + "rdp", + "vnc", + "ssh", + "partage", + "bureau à distance", + ]), + onlineAccounts: t( + "Online Accounts", + "Connectivity", + Icons.cloud, + "teal", + "Google, Microsoft, Nextcloud and other accounts", + [ + "accounts", + "online", + "google", + "microsoft", + "nextcloud", + "comptes en ligne", + ], + ), + kdeconnect: t("KDE Connect", "Connectivity", Icons.link, "teal", "Your phone on your desktop", [ + "phone", + "android", + "kdeconnect", + "téléphone", + "smartphone", + ]), + firewall: t("Firewall", "Privacy & Security", Icons.shield, "red", "Incoming connections rules", [ + "firewall", + "ufw", + "pare-feu", + "sécurité", + ]), + background: t("Background", "Appearance", Icons.image, "pink", "Wallpaper of the desktop", [ + "background", + "wallpaper", + "desktop", + "fond d'écran", + "fond", + "arrière-plan", + "image", + ]), + appearance: t("Appearance", "Appearance", Icons.palette, "purple", "Light or dark style, accent color, theme", [ + "appearance", + "theme", + "dark", + "light", + "accent", + "style", + "apparence", + "thème", + "sombre", + "clair", + "mode sombre", + ]), + colors: t("Colors", "Appearance", Icons.droplet, "purple", "Color scheme of windows and apps", [ + "colors", + "color scheme", + "accent", + "couleurs", + "jeu de couleurs", + ]), + fonts: t("Fonts", "Appearance", Icons.text, "purple", "Interface and document fonts, size, antialiasing", [ + "fonts", + "text size", + "polices", + "police", + "taille du texte", + "lissage", + ]), + icons: t("Icons", "Appearance", Icons.apps, "purple", "Icon theme", [ + "icons", + "icon theme", + "icônes", + "thème d'icônes", + ]), + cursor: t("Cursors", "Appearance", Icons.dottedCircle, "purple", "Pointer theme and size", [ + "cursor", + "pointer", + "curseur", + "pointeur", + ]), + appStyle: t("Application Style", "Appearance", Icons.layers, "purple", "Widget style of apps", [ + "style", + "widgets", + "gtk", + "qt", + "breeze", + "style des applications", + ]), + windowDecorations: t("Window Decorations", "Appearance", Icons.window, "purple", "Title bars and their buttons", [ + "title bar", + "window buttons", + "borders", + "barre de titre", + "décorations des fenêtres", + "bordures", + ]), + splash: t("Splash Screen", "Appearance", Icons.sparkle, "purple", "What shows while the session starts", [ + "splash", + "boot", + "écran de démarrage", + ]), + effects: t("Desktop Effects", "Appearance", Icons.sparkle, "purple", "Animations and window effects", [ + "effects", + "animations", + "wobbly", + "blur", + "effets", + "flou", + ]), + desktopIcons: t("Desktop", "Appearance", Icons.home, "pink", "Desktop icons and their behavior", [ + "desktop", + "icons", + "bureau", + ]), + panel: t("Panel", "Appearance", Icons.layers, "gray", "Taskbar, applets and their layout", [ + "panel", + "taskbar", + "dock", + "applets", + "panneau", + "barre des tâches", + ]), + display: t("Displays", "Display & Sound", Icons.monitor, "teal", "Resolution, scaling, arrangement, refresh rate", [ + "display", + "monitor", + "screen", + "resolution", + "scaling", + "hidpi", + "refresh rate", + "écran", + "moniteur", + "affichage", + "résolution", + "mise à l'échelle", + ]), + nightLight: t("Night Light", "Display & Sound", Icons.moon, "orange", "Warmer colors at night", [ + "night light", + "blue light", + "redshift", + "veilleuse", + "éclairage nocturne", + "lumière bleue", + "mode nuit", + ]), + color: t("Color Profiles", "Display & Sound", Icons.pipette, "teal", "Calibration of screens and printers", [ + "color profile", + "icc", + "calibration", + "profil de couleur", + "étalonnage", + ]), + sound: t("Sound", "Display & Sound", Icons.speaker, "orange", "Output and input devices, volume, alert sound", [ + "sound", + "audio", + "volume", + "speakers", + "microphone", + "output", + "input", + "son", + "haut-parleurs", + "sortie", + "entrée", + ]), + power: t("Power", "Devices", Icons.power, "green", "Battery, power mode, screen blank and suspend", [ + "power", + "battery", + "energy", + "suspend", + "sleep", + "lid", + "énergie", + "batterie", + "alimentation", + "veille", + "mise en veille", + ]), + keyboard: t("Keyboard", "Devices", Icons.keyboard, "gray", "Layouts, input sources, repeat rate", [ + "keyboard", + "layout", + "input source", + "azerty", + "qwerty", + "clavier", + "disposition", + "langue de saisie", + ]), + shortcuts: t("Keyboard Shortcuts", "Devices", Icons.keyboard, "gray", "Global and custom shortcuts", [ + "shortcuts", + "hotkeys", + "keybindings", + "raccourcis", + "raccourcis clavier", + ]), + mouse: t( + "Mouse & Touchpad", + "Devices", + Icons.dottedCircle, + "gray", + "Pointer speed, natural scrolling, tap to click", + [ + "mouse", + "touchpad", + "trackpad", + "pointer", + "scroll", + "souris", + "pavé tactile", + "défilement", + ], + ), + touchpad: t("Touchpad", "Devices", Icons.square, "gray", "Tap to click, gestures, scrolling", [ + "touchpad", + "trackpad", + "gestures", + "pavé tactile", + "gestes", + ]), + printers: t("Printers", "Devices", Icons.file, "gray", "Add printers and manage print jobs", [ + "printer", + "print", + "scanner", + "cups", + "imprimante", + "imprimer", + "impression", + ]), + wacom: t("Drawing Tablet", "Devices", Icons.pipette, "gray", "Wacom and other pen tablets", [ + "wacom", + "tablet", + "stylus", + "pen", + "tablette graphique", + "stylet", + ]), + removable: t("Removable Media", "Devices", Icons.drive, "gray", "What happens when you plug in a drive or a phone", [ + "removable", + "usb", + "autorun", + "automount", + "médias amovibles", + "clé usb", + ]), + thunderbolt: t("Thunderbolt", "Devices", Icons.bolt, "yellow", "Authorize Thunderbolt devices", [ + "thunderbolt", + "usb4", + "dock", + ]), + gameControllers: t("Game Controllers", "Devices", Icons.gamepad, "gray", "Joysticks and gamepads", [ + "gamepad", + "joystick", + "controller", + "manette", + ]), + notifications: t("Notifications", "Apps & Windows", Icons.bell, "red", "Do Not Disturb, banners and per-app rules", [ + "notifications", + "do not disturb", + "dnd", + "ne pas déranger", + "alertes", + ]), + apps: t("Apps", "Apps & Windows", Icons.apps, "blue", "Permissions, storage and defaults of each app", [ + "apps", + "applications", + "permissions", + "flatpak", + "logiciels", + ]), + defaultApps: t("Default Apps", "Apps & Windows", Icons.star, "yellow", "Browser, mail, music, video and photos", [ + "default apps", + "default browser", + "applications par défaut", + "navigateur par défaut", + ]), + fileTypes: t("File Associations", "Apps & Windows", Icons.file, "blue", "Which app opens which kind of file", [ + "file types", + "mime", + "open with", + "associations", + "types de fichiers", + "ouvrir avec", + ]), + startup: t("Autostart", "Apps & Windows", Icons.rocket, "green", "Apps and scripts started when you log in", [ + "autostart", + "startup", + "login", + "démarrage", + "lancement automatique", + ]), + search: t("Search", "Apps & Windows", Icons.search, "blue", "What the desktop search looks into", [ + "search", + "indexing", + "recherche", + "indexation", + ]), + multitasking: t("Multitasking", "Apps & Windows", Icons.layers, "blue", "Hot corner, workspaces, app switching", [ + "multitasking", + "hot corner", + "workspaces", + "alt tab", + "multitâche", + "coin actif", + "espaces de travail", + ]), + workspaces: t("Workspaces", "Apps & Windows", Icons.layers, "blue", "Virtual desktops", [ + "workspaces", + "virtual desktops", + "espaces de travail", + "bureaux virtuels", + ]), + windows: t("Window Behavior", "Apps & Windows", Icons.window, "blue", "Focus, title bar actions, snapping", [ + "windows", + "focus", + "snapping", + "tiling", + "fenêtres", + "comportement des fenêtres", + ]), + privacy: t("Privacy", "Privacy & Security", Icons.shield, "red", "Location, camera, microphone, file history", [ + "privacy", + "confidentialité", + "vie privée", + ]), + location: t("Location Services", "Privacy & Security", Icons.pin, "red", "Which apps may know where you are", [ + "location", + "gps", + "localisation", + "position", + ]), + camera: t("Camera", "Privacy & Security", Icons.camera, "red", "Which apps may use the camera", [ + "camera", + "webcam", + "caméra", + ]), + microphone: t("Microphone", "Privacy & Security", Icons.mic, "red", "Which apps may use the microphone", [ + "microphone", + "mic", + "micro", + ]), + screenLock: t("Screen Lock", "Privacy & Security", Icons.lock, "red", "Automatic lock, blank screen delay", [ + "lock screen", + "screen lock", + "screensaver", + "verrouillage", + "écran de verrouillage", + "économiseur d'écran", + ]), + usage: t("File History & Trash", "Privacy & Security", Icons.history, "red", "Recent files, emptying the trash", [ + "history", + "recent files", + "trash", + "historique", + "fichiers récents", + "corbeille", + ]), + diagnostics: t("Diagnostics", "Privacy & Security", Icons.bug, "gray", "Problem reporting", [ + "diagnostics", + "crash reports", + "rapports", + "problèmes", + ]), + users: t("Users", "System", Icons.person, "blue", "Accounts, passwords, fingerprint, auto login", [ + "users", + "accounts", + "password", + "fingerprint", + "utilisateurs", + "comptes", + "mot de passe", + "empreinte", + ]), + datetime: t("Date & Time", "System", Icons.clock, "blue", "Time zone, automatic time, clock format", [ + "date", + "time", + "timezone", + "clock", + "heure", + "fuseau horaire", + "horloge", + ]), + region: t("Region & Language", "System", Icons.globe, "blue", "Language, formats of dates, numbers and money", [ + "language", + "region", + "locale", + "formats", + "langue", + "région", + "français", + ]), + accessibility: t("Accessibility", "System", Icons.eye, "blue", "Larger text, contrast, screen reader, zoom", [ + "accessibility", + "a11y", + "screen reader", + "zoom", + "contrast", + "accessibilité", + "lecteur d'écran", + "contraste", + "loupe", + ]), + about: t("About This Computer", "System", Icons.info, "gray", "Device name, hardware, system version", [ + "about", + "system info", + "hardware", + "version", + "device name", + "à propos", + "informations système", + "matériel", + "nom de l'appareil", + ]), + loginScreen: t("Login Screen", "System", Icons.key, "gray", "Theme and behavior of the login screen", [ + "login screen", + "sddm", + "lightdm", + "écran de connexion", + ]), + session: t("Session & Startup", "System", Icons.power, "gray", "What happens when you log in and out", [ + "session", + "logout", + "déconnexion", + ]), + settingsEditor: t("Settings Editor", "System", Icons.braces, "gray", "Every raw setting, for advanced changes", [ + "editor", + "xfconf", + "advanced", + "éditeur", + "avancé", + ]), +} as const; + +export type TopicId = keyof typeof TOPICS; + +export interface Panel extends Topic { + id: TopicId; + /** Ways to open it, tried in order until one is installed. */ + commands: string[][]; + /** Environment for the program (gnome-control-center outside GNOME). */ + env?: Record; +} + +type Entry = [TopicId, ...string[][]]; + +const gnomePanel = (id: TopicId, panel?: string): Entry => [id, ["gnome-control-center", ...(panel ? [panel] : [])]]; + +const GNOME: Entry[] = [ + gnomePanel("all"), + gnomePanel("wifi", "wifi"), + gnomePanel("network", "network"), + gnomePanel("mobile", "wwan"), + gnomePanel("bluetooth", "bluetooth"), + gnomePanel("sharing", "sharing"), + gnomePanel("onlineAccounts", "online-accounts"), + gnomePanel("appearance", "background"), + gnomePanel("background", "background"), + gnomePanel("display", "display"), + gnomePanel("nightLight", "display"), + gnomePanel("color", "color"), + gnomePanel("sound", "sound"), + gnomePanel("power", "power"), + gnomePanel("keyboard", "keyboard"), + gnomePanel("shortcuts", "keyboard"), + gnomePanel("mouse", "mouse"), + gnomePanel("printers", "printers"), + gnomePanel("wacom", "wacom"), + gnomePanel("removable", "removable-media"), + gnomePanel("thunderbolt", "thunderbolt"), + gnomePanel("notifications", "notifications"), + gnomePanel("apps", "applications"), + gnomePanel("defaultApps", "default-apps"), + gnomePanel("search", "search"), + gnomePanel("multitasking", "multitasking"), + gnomePanel("privacy", "privacy"), + gnomePanel("location", "location"), + gnomePanel("camera", "camera"), + gnomePanel("microphone", "microphone"), + gnomePanel("screenLock", "screen"), + gnomePanel("usage", "usage"), + gnomePanel("diagnostics", "diagnostics"), + gnomePanel("users", "users"), + gnomePanel("datetime", "datetime"), + gnomePanel("region", "region"), + gnomePanel("accessibility", "universal-access"), + gnomePanel("about", "info-overview"), +]; + +/** Plasma 6 and 5: System Settings on the page, else the page alone. */ +const kcm = (id: TopicId, name?: string): Entry => + name + ? [id, ["systemsettings", name], ["systemsettings5", name], ["kcmshell6", name], ["kcmshell5", name]] + : [id, ["systemsettings"], ["systemsettings5"]]; + +const KDE: Entry[] = [ + kcm("all"), + kcm("wifi", "kcm_networkmanagement"), + kcm("network", "kcm_networkmanagement"), + kcm("bluetooth", "kcm_bluetooth"), + kcm("onlineAccounts", "kcm_kaccounts"), + kcm("kdeconnect", "kcm_kdeconnect"), + kcm("firewall", "kcm_firewall"), + kcm("background", "kcm_wallpaper"), + kcm("appearance", "kcm_lookandfeel"), + kcm("colors", "kcm_colors"), + kcm("fonts", "kcm_fonts"), + kcm("icons", "kcm_icons"), + kcm("cursor", "kcm_cursortheme"), + kcm("appStyle", "kcm_style"), + kcm("windowDecorations", "kcm_kwindecoration"), + kcm("splash", "kcm_splashscreen"), + kcm("effects", "kcm_kwin_effects"), + kcm("display", "kcm_kscreen"), + kcm("nightLight", "kcm_nightcolor"), + kcm("sound", "kcm_pulseaudio"), + kcm("power", "kcm_powerdevilprofilesconfig"), + kcm("keyboard", "kcm_keyboard"), + kcm("shortcuts", "kcm_keys"), + kcm("mouse", "kcm_mouse"), + kcm("touchpad", "kcm_touchpad"), + kcm("printers", "kcm_printer_manager"), + kcm("removable", "kcm_device_automounter"), + kcm("thunderbolt", "kcm_bolt"), + kcm("gameControllers", "kcm_gamecontroller"), + kcm("notifications", "kcm_notifications"), + kcm("defaultApps", "kcm_componentchooser"), + kcm("fileTypes", "kcm_filetypes"), + kcm("startup", "kcm_autostart"), + kcm("search", "kcm_plasmasearch"), + kcm("workspaces", "kcm_kwin_virtualdesktops"), + kcm("windows", "kcm_kwinoptions"), + kcm("screenLock", "kcm_screenlocker"), + kcm("usage", "kcm_recentFiles"), + kcm("users", "kcm_users"), + kcm("datetime", "kcm_clock"), + kcm("region", "kcm_regionandlang"), + kcm("accessibility", "kcm_access"), + kcm("about", "kcm_about-distro"), + kcm("loginScreen", "kcm_sddm"), + kcm("session", "kcm_smserver"), +]; + +const XFCE: Entry[] = [ + ["all", ["xfce4-settings-manager"]], + ["network", ["nm-connection-editor"]], + ["bluetooth", ["blueman-manager"]], + ["background", ["xfdesktop-settings"]], + ["desktopIcons", ["xfdesktop-settings"]], + ["appearance", ["xfce4-appearance-settings"]], + ["windowDecorations", ["xfwm4-settings"]], + ["effects", ["xfwm4-tweaks-settings"]], + ["panel", ["xfce4-panel", "--preferences"]], + ["display", ["xfce4-display-settings"]], + ["color", ["xfce4-color-settings"]], + ["sound", ["pavucontrol"], ["pwvucontrol"]], + ["power", ["xfce4-power-manager-settings"]], + ["keyboard", ["xfce4-keyboard-settings"]], + ["shortcuts", ["xfce4-keyboard-settings"]], + ["mouse", ["xfce4-mouse-settings"]], + ["printers", ["system-config-printer"]], + ["removable", ["thunar-volman-settings"]], + ["notifications", ["xfce4-notifyd-config"]], + ["defaultApps", ["xfce4-mime-settings"], ["exo-preferred-applications"]], + ["workspaces", ["xfwm4-workspace-settings"]], + ["session", ["xfce4-session-settings"]], + ["startup", ["xfce4-session-settings"]], + ["screenLock", ["xfce4-screensaver-preferences"], ["light-locker-settings"]], + ["accessibility", ["xfce4-accessibility-settings"]], + ["about", ["xfce4-about"]], + ["settingsEditor", ["xfce4-settings-editor"]], +]; + +const cs = (id: TopicId, module?: string): Entry => [id, ["cinnamon-settings", ...(module ? [module] : [])]]; + +const CINNAMON: Entry[] = [ + cs("all"), + cs("network", "network"), + ["bluetooth", ["blueman-manager"], ["blueberry"]], + cs("background", "backgrounds"), + cs("appearance", "themes"), + cs("fonts", "fonts"), + cs("effects", "effects"), + cs("desktopIcons", "desktop"), + cs("panel", "panel"), + cs("display", "display"), + cs("nightLight", "nightlight"), + cs("color", "color"), + cs("sound", "sound"), + cs("power", "power"), + cs("keyboard", "keyboard"), + cs("shortcuts", "keyboard"), + cs("mouse", "mouse"), + ["printers", ["system-config-printer"]], + cs("wacom", "wacom"), + cs("notifications", "notifications"), + cs("defaultApps", "default"), + cs("startup", "startup"), + cs("workspaces", "workspaces"), + cs("windows", "windows"), + cs("privacy", "privacy"), + cs("screenLock", "screensaver"), + cs("users", "user"), + cs("datetime", "calendar"), + cs("accessibility", "accessibility"), + cs("about", "info"), +]; + +/** Standalone tools for window managers and desktops without a settings app. */ +const OTHER: Entry[] = [ + ["network", ["nm-connection-editor"]], + ["bluetooth", ["blueman-manager"], ["blueberry"], ["overskride"]], + ["sound", ["pavucontrol"], ["pwvucontrol"], ["pavucontrol-qt"]], + ["display", ["nwg-displays"], ["wdisplays"], ["arandr"]], + ["appearance", ["nwg-look"], ["lxappearance"], ["qt6ct"], ["qt5ct"]], + ["printers", ["system-config-printer"]], +]; + +const TABLES: Record = { gnome: GNOME, kde: KDE, xfce: XFCE, cinnamon: CINNAMON, other: OTHER }; + +/** GNOME renamed a few panels in version 46. */ +function gnomeVersioned(entries: Entry[], version?: number): Entry[] { + if (!version || version < 46) return entries; + return entries.map(([id, ...cmds]) => + id === "about" ? [id, ["gnome-control-center", "system"]] : [id, ...cmds] as Entry + ); +} + +/** Every panel of the desktop. Outside a full desktop, GNOME's panels come along when its settings app is installed. */ +export function panelsFor( + desktop: Desktop, + options: { gnomeVersion?: number; gnomeInstalled?: boolean } = {}, +): Panel[] { + const entries = desktop === "gnome" ? gnomeVersioned(GNOME, options.gnomeVersion) : TABLES[desktop]; + const panels: Panel[] = entries.map(([id, ...commands]) => ({ ...TOPICS[id], id, commands })); + if (desktop === "other" && options.gnomeInstalled) { + const own = new Set(panels.map((p) => p.id)); + for (const [id, ...commands] of gnomeVersioned(GNOME, options.gnomeVersion)) { + // gnome-control-center refuses to start outside GNOME unless told it is in GNOME. + if (!own.has(id)) panels.push({ ...TOPICS[id], id, commands, env: { XDG_CURRENT_DESKTOP: "GNOME" } }); + } + } + return panels; +} + +/** `gnome-control-center --version`: `gnome-control-center 46.2`. */ +export function parseGnomeVersion(out: string): number | undefined { + const m = out.match(/(\d+)(?:\.\d+)*/); + return m ? Number(m[1]) : undefined; +} + +export const commandLine = (argv: string[]) => argv.map((a) => (/^[\w./:=-]+$/.test(a) ? a : `'${a}'`)).join(" "); + +/** A quick switch backed by one gsettings key. */ +export interface Toggle { + id: string; + title: string; + description: string; + schema: string; + key: string; + on: string; + off: string; + icon: [on: string, off: string]; + tint: Color; + keywords: string[]; +} + +export const GNOME_TOGGLES: Toggle[] = [ + { + id: "dark", + title: "Dark Style", + description: "Dark windows and apps (GNOME 42 and later)", + schema: "org.gnome.desktop.interface", + key: "color-scheme", + on: "'prefer-dark'", + off: "'default'", + icon: [Icons.moon, Icons.sun], + tint: "purple", + keywords: ["dark mode", "dark theme", "mode sombre", "thème sombre", "clair", "light"], + }, + { + id: "night-light", + title: "Night Light", + description: "Warmer colors from sunset to sunrise", + schema: "org.gnome.settings-daemon.plugins.color", + key: "night-light-enabled", + on: "true", + off: "false", + icon: [Icons.moon, Icons.moon], + tint: "orange", + keywords: ["night light", "blue light", "veilleuse", "éclairage nocturne", "mode nuit"], + }, + { + id: "animations", + title: "Animations", + description: "Animate windows and the overview", + schema: "org.gnome.desktop.interface", + key: "enable-animations", + on: "true", + off: "false", + icon: [Icons.sparkle, Icons.sparkle], + tint: "blue", + keywords: ["animations", "effects", "effets", "reduce motion"], + }, + { + id: "hot-corner", + title: "Hot Corner", + description: "Open the overview by pushing the pointer in the top-left corner", + schema: "org.gnome.desktop.interface", + key: "enable-hot-corners", + on: "true", + off: "false", + icon: [Icons.layers, Icons.layers], + tint: "blue", + keywords: ["hot corner", "coin actif", "overview", "activités"], + }, + { + id: "battery-percentage", + title: "Battery Percentage", + description: "Show the battery level next to its icon in the top bar", + schema: "org.gnome.desktop.interface", + key: "show-battery-percentage", + on: "true", + off: "false", + icon: [Icons.battery, Icons.battery], + tint: "green", + keywords: ["battery", "percentage", "batterie", "pourcentage"], + }, +]; + +export const CINNAMON_TOGGLES: Toggle[] = [ + { + id: "night-light", + title: "Night Light", + description: "Warmer colors from sunset to sunrise", + schema: "org.cinnamon.settings-daemon.plugins.color", + key: "night-light-enabled", + on: "true", + off: "false", + icon: [Icons.moon, Icons.moon], + tint: "orange", + keywords: ["night light", "veilleuse", "éclairage nocturne", "mode nuit"], + }, +]; + +/** Whether a `gsettings get` value means on. */ +export const isOn = (toggle: Toggle, value: string) => value.trim() === toggle.on; + +/** `kreadconfig6 --group General --key ColorScheme`: is the Plasma color scheme a dark one? */ +export const isDarkScheme = (scheme: string) => /dark|night|black/i.test(scheme); diff --git a/extensions/desktop-settings/lib_test.ts b/extensions/desktop-settings/lib_test.ts new file mode 100644 index 0000000..158ba33 --- /dev/null +++ b/extensions/desktop-settings/lib_test.ts @@ -0,0 +1,93 @@ +import { assert, assertEquals } from "@std/assert"; +import { parse } from "@std/toml"; +import { + CATEGORIES, + commandLine, + type Desktop, + detectDesktop, + GNOME_TOGGLES, + isDarkScheme, + isOn, + panelsFor, + parseGnomeVersion, + TOPICS, +} from "./lib.ts"; + +const DESKTOPS: Desktop[] = ["gnome", "kde", "xfce", "cinnamon", "other"]; + +Deno.test("desktop detection from XDG_CURRENT_DESKTOP", () => { + assertEquals( + ["ubuntu:GNOME", "GNOME-Classic:GNOME", "KDE", "XFCE", "X-Cinnamon", "Budgie:GNOME", "sway", "Hyprland", ""].map( + detectDesktop, + ), + ["gnome", "gnome", "kde", "xfce", "cinnamon", "gnome", "other", "other", "other"], + ); +}); + +Deno.test("every desktop has its panels, ids are unique, categories known", () => { + for (const d of DESKTOPS) { + const panels = panelsFor(d); + assert(panels.length >= 6, d); + assertEquals(new Set(panels.map((p) => p.id)).size, panels.length, `duplicate panel on ${d}`); + for (const p of panels) { + assert(CATEGORIES.includes(p.category), p.id); + assert(p.commands.length > 0 && p.commands.every((c) => c.length > 0), p.id); + assert(p.icon.startsWith("bs-"), p.id); + } + } + assertEquals(panelsFor("gnome").find((p) => p.id === "accessibility")?.commands, [[ + "gnome-control-center", + "universal-access", + ]]); + assertEquals(panelsFor("kde").find((p) => p.id === "display")?.commands[0], ["systemsettings", "kcm_kscreen"]); + assertEquals(panelsFor("cinnamon").find((p) => p.id === "background")?.commands[0], [ + "cinnamon-settings", + "backgrounds", + ]); +}); + +Deno.test("GNOME 46 renamed About; GNOME panels join window managers when installed", () => { + assertEquals(panelsFor("gnome", { gnomeVersion: 45 }).find((p) => p.id === "about")?.commands[0][1], "info-overview"); + assertEquals(panelsFor("gnome", { gnomeVersion: 46 }).find((p) => p.id === "about")?.commands[0][1], "system"); + const sway = panelsFor("other", { gnomeInstalled: true }); + assertEquals(sway.find((p) => p.id === "sound")?.commands[0], ["pavucontrol"]); + assertEquals(sway.find((p) => p.id === "wifi")?.env, { XDG_CURRENT_DESKTOP: "GNOME" }); + assertEquals(panelsFor("other").some((p) => p.id === "wifi"), false); + assertEquals(parseGnomeVersion("gnome-control-center 46.2\n"), 46); + assertEquals(parseGnomeVersion(""), undefined); +}); + +Deno.test("French and English words find the right panel", () => { + const find = (word: string) => + Object.entries(TOPICS).filter(([, t]) => t.keywords.some((k) => k.includes(word))).map(([id]) => id); + assert(find("fond d'écran").includes("background")); + assert(find("son").includes("sound")); + assert(find("clavier").includes("keyboard")); + assert(find("souris").includes("mouse")); + assert(find("imprimante").includes("printers")); + assert(find("wallpaper").includes("background")); + assert(find("mode sombre").includes("appearance")); + for (const [id, t] of Object.entries(TOPICS)) assert(t.keywords.length >= 2, id); +}); + +Deno.test("every program the tables run is allowed by the manifest", async () => { + const manifest = parse(await Deno.readTextFile(new URL("./backslash.toml", import.meta.url))) as { + permissions: { run: string[] }; + }; + const allowed = new Set(manifest.permissions.run); + for (const d of DESKTOPS) { + for (const p of panelsFor(d, { gnomeInstalled: true })) { + for (const [program] of p.commands) assert(allowed.has(program), `${program} (${d}/${p.id}) is not in run`); + } + } +}); + +Deno.test("toggles", () => { + const dark = GNOME_TOGGLES.find((t) => t.id === "dark")!; + assertEquals(isOn(dark, "'prefer-dark'\n"), true); + assertEquals(isOn(dark, "'default'"), false); + assertEquals(isOn(GNOME_TOGGLES.find((t) => t.id === "night-light")!, "true"), true); + assertEquals([isDarkScheme("BreezeDark"), isDarkScheme("BreezeLight"), isDarkScheme("")], [true, false, false]); + assertEquals(commandLine(["systemsettings", "kcm_kscreen"]), "systemsettings kcm_kscreen"); + assertEquals(commandLine(["xfce4-panel", "--preferences"]), "xfce4-panel --preferences"); +}); diff --git a/extensions/drives/backslash.toml b/extensions/drives/backslash.toml new file mode 100644 index 0000000..282d601 --- /dev/null +++ b/extensions/drives/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "drives" +title = "Drives" +description = "Mount, open, unmount and safely eject USB sticks, SD cards and internal drives" +version = "1.0.0" +author = "Backslash" +icon = "bs-drive" +license = "MIT" + +# lsblk (util-linux) lists drives; udisksctl (udisks2, used by every file manager) mounts, +# unlocks and powers them off as your user, asking through polkit when needed. +[permissions] +run = ["lsblk", "udisksctl"] + +[[commands]] +name = "drives" +title = "Drives" +description = "Removable and internal drives with their space; mount, open or eject them" +icon = "bs-drive" +keywords = [ + "drive", + "disk", + "disque", + "usb", + "clé usb", + "sd card", + "carte sd", + "mount", + "monter", + "unmount", + "démonter", + "eject", + "éjecter", + "partition", + "volume", + "storage", + "stockage", +] diff --git a/extensions/drives/commands/drives.ts b/extensions/drives/commands/drives.ts new file mode 100644 index 0000000..ff01f9b --- /dev/null +++ b/extensions/drives/commands/drives.ts @@ -0,0 +1,355 @@ +import { + type Action, + action, + close, + type Color, + copy, + exec, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + openExternal, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + COLUMNS, + COLUMNS_SHORT, + type Disk, + diskName, + effective, + explainUdisksError, + fsName, + isEncrypted, + isPlumbing, + isSwap, + isSystemMount, + parseLsblk, + parseMounted, + parseUnlocked, + size, + transportName, + usageColor, + type Volume, + volumeName, +} from "../lib.ts"; + +async function lsblk(): Promise { + const run = (columns: string) => exec("lsblk", ["-J", "-b", "-o", columns]); + const out = await run(COLUMNS).catch(() => run(COLUMNS_SHORT)); + return parseLsblk(out); +} + +async function udisks(...args: string[]): Promise { + try { + return await exec("udisksctl", args); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + throw new Error("udisksctl was not found: install udisks2 (it ships with every desktop)"); + } + throw new Error(explainUdisksError(err instanceof Error ? err.message : String(err))); + } +} + +async function mount(v: Volume): Promise { + const out = await udisks("mount", "-b", v.path); + return parseMounted(out) ?? ""; +} + +const unmount = (v: Volume) => udisks("unmount", "-b", v.path); + +/** Unmounts every volume of the disk, locks encrypted ones, then cuts the power so it can be pulled out. */ +async function eject(d: Disk) { + for (const v of d.volumes) { + const e = effective(v); + if (e.mountpoint && !isSwap(e)) await unmount(e); + if (v.inner) await udisks("lock", "-b", v.path); + } + await udisks("power-off", "-b", d.path); +} + +const unlockForm = (v: Volume, title: string) => + form({ + title: `Unlock ${title}`, + submitTitle: "Unlock and Mount", + fields: [field.password("passphrase", "Passphrase", { required: true })], + async submit(values) { + await toast(`Unlocking ${title}…`); + // The passphrase goes through stdin, never on a command line. + const out = await exec("udisksctl", ["unlock", "-b", v.path, "--key-file", "/dev/stdin"], { + stdin: String(values.passphrase), + }).catch((err) => { + throw new Error(explainUdisksError(String(err?.message ?? err))); + }); + const cleartext = parseUnlocked(out); + if (!cleartext) return close(`${title} unlocked`); + const at = parseMounted(await udisks("mount", "-b", cleartext)); + if (at) await openExternal(at); + return close(at ? `${title} mounted at ${at}` : `${title} unlocked`); + }, + }); + +function actionsFor(v: Volume, d: Disk, title: string): Action[] { + const e = effective(v); + const mounted = e.mountpoint && !isSwap(e) ? e.mountpoint : undefined; + const system = isSystemMount(e.mountpoint); + const out: Action[] = []; + + if (mounted) { + out.push(open(mounted, "Open in File Manager", { icon: Icons.folder })); + if (!system) { + out.push(action("Unmount", async () => { + await unmount(e); + if (v.inner) await udisks("lock", "-b", v.path); + await toast(`${title} unmounted`, { style: "success" }); + return refresh(); + }, { icon: Icons.upload, shortcut: "u", section: "Drive" })); + } + } else if (isEncrypted(v) && !v.inner) { + out.push(action("Unlock…", () => unlockForm(v, title), { icon: Icons.key })); + } else if (!isSwap(e) && e.fstype && !isPlumbing(e)) { + out.push( + action("Mount and Open", async () => { + const at = await mount(e); + if (at) await openExternal(at); + return close(at ? `${title} mounted at ${at}` : `${title} mounted`); + }, { icon: Icons.folder }), + action("Mount", async () => { + const at = await mount(e); + await toast(at ? `Mounted at ${at}` : `${title} mounted`, { style: "success" }); + return refresh(); + }, { icon: Icons.download, shortcut: "m", section: "Drive" }), + ); + } + if (v.inner && !e.mountpoint) { + out.push(action("Lock", async () => { + await udisks("lock", "-b", v.path); + await toast(`${title} locked`, { style: "success" }); + return refresh(); + }, { icon: Icons.lock, section: "Drive" })); + } + if (d.removable && !system) { + out.push( + action("Eject", async () => { + await eject(d); + return close(`${diskName(d)} can be removed safely`); + }, { + icon: Icons.power, + shortcut: "e", + destructive: true, + confirm: `Eject ${diskName(d)}? All its volumes are unmounted and the drive is powered off.`, + section: "Drive", + }), + ); + } + + if (mounted) out.push(copy(mounted, "Copy Mount Point", { section: "Copy", shortcut: "c" })); + out.push(copy(e.path, "Copy Device Path", { section: "Copy" })); + const uuid = e.uuid ?? v.uuid; + if (uuid) out.push(copy(uuid, "Copy UUID", { section: "Copy" })); + out.push(action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" })); + return out; +} + +function volumeItem(v: Volume, d: Disk): Item { + const e = effective(v); + const title = volumeName(v, d); + const mounted = e.mountpoint && !isSwap(e) ? e.mountpoint : undefined; + const ratio = mounted && e.used !== undefined && e.size + ? e.used / (e.used + (e.available ?? 0) || e.size) + : undefined; + const locked = isEncrypted(v) && !v.inner; + const color: Color = ratio !== undefined ? usageColor(ratio) : locked ? "orange" : d.removable ? "blue" : "gray"; + const system = isSystemMount(e.mountpoint); + const state = mounted + ? system ? "System volume" : "Mounted" + : locked + ? "Locked" + : isSwap(e) + ? e.mountpoint ? "Swap in use" : "Swap" + : isPlumbing(e) + ? "Not browsable" + : "Not mounted"; + const metadata: Metadata[] = [ + ...(ratio !== undefined + ? [meta.progress("Usage", ratio, `${size(e.used ?? 0)} of ${size(e.size)} · ${Math.round(ratio * 100)} %`)] + : []), + meta.text("State", state, { + icon: mounted ? Icons.check : locked ? Icons.lock : Icons.dottedCircle, + color: mounted ? "green" : locked ? "orange" : "gray", + }), + ...(mounted ? [meta.text("Mount point", mounted, { icon: Icons.folder })] : []), + meta.tags("File system", [ + { text: fsName(e), color: "blue" }, + ...(v.inner ? [{ text: `in ${fsName(v)}`, color: "orange" as const }] : []), + ...(e.readonly ? [{ text: "Read-only", color: "gray" as const }] : []), + ]), + meta.separator(), + meta.text("Device", e.path, { icon: Icons.drive }), + ...(e.label ?? v.label ? [meta.text("Label", (e.label ?? v.label)!)] : []), + ...(e.uuid ?? v.uuid ? [meta.text("UUID", (e.uuid ?? v.uuid)!, { icon: Icons.hash })] : []), + meta.separator(), + meta.text("Disk", `${diskName(d)} · ${size(d.size)}`, { icon: Icons.drive }), + meta.text("Connection", transportName(d.transport), { icon: d.transport === "usb" ? Icons.plug : Icons.cpu }), + meta.text("Removable", d.removable ? "Yes" : "No"), + ]; + return { + id: v.path, + title, + subtitle: `${diskName(d)} · ${e.path.replace("/dev/", "")}`, + icon: { source: locked ? Icons.lock : Icons.drive, tint: color }, + section: d.removable ? "Removable" : "Internal", + keywords: [ + "disk", + "disque", + "drive", + "usb", + "clé", + e.path, + fsName(e), + e.uuid ?? "", + mounted ?? "", + d.model ?? "", + transportName(d.transport), + ], + accessories: [ + ...(locked + ? [{ icon: Icons.lock, tooltip: "Encrypted, locked" }] + : v.inner + ? [{ icon: Icons.key, tooltip: "Encrypted, unlocked" }] + : []), + { tag: fsName(e), color: "gray" }, + ratio !== undefined + ? { + text: `${size(e.available ?? 0)} free`, + color: usageColor(ratio), + tooltip: `${Math.round(ratio * 100)} % used`, + } + : { text: `${size(v.size)}`, tooltip: state }, + ], + detail: { + stats: ratio !== undefined + ? [ + { label: "Used", value: size(e.used ?? 0), color: usageColor(ratio) }, + { label: "Free", value: size(e.available ?? 0) }, + { label: "Size", value: size(e.size) }, + ] + : [{ label: "Size", value: size(v.size) }, { + label: "State", + value: state, + color: locked ? "orange" : undefined, + }], + metadata, + }, + actions: actionsFor(v, d, title), + }; +} + +/** A disk without any volume (blank or unpartitioned): still worth ejecting. */ +function emptyDiskItem(d: Disk): Item { + return { + id: d.path, + title: diskName(d), + subtitle: `${d.path.replace("/dev/", "")} · no volume`, + icon: { source: Icons.drive, tint: "gray" }, + section: d.removable ? "Removable" : "Internal", + keywords: ["disk", "disque", d.path, d.model ?? ""], + accessories: [{ tag: "Empty", color: "gray" }, { text: size(d.size) }], + detail: { + markdown: + "This drive has no partition or filesystem. Format it with GNOME Disks, KDE Partition Manager or GParted.", + metadata: [ + meta.text("Device", d.path, { icon: Icons.drive }), + meta.text("Size", size(d.size)), + meta.text("Connection", transportName(d.transport)), + ], + }, + actions: [ + ...(d.removable + ? [action("Power Off", async () => { + await udisks("power-off", "-b", d.path); + return close(`${diskName(d)} can be removed safely`); + }, { icon: Icons.power, destructive: true, confirm: `Power off ${diskName(d)}?` })] + : []), + copy(d.path, "Copy Device Path", { section: "Copy" }), + ], + }; +} + +function items(disks: Disk[], filter: string): Item[] { + const out: Item[] = []; + for (const d of disks) { + if (filter === "removable" && !d.removable) continue; + const vols = d.volumes.filter((v) => d.removable || !isPlumbing(effective(v))); + const shown = filter === "mounted" ? vols.filter((v) => effective(v).mountpoint) : vols; + if (!d.volumes.length && filter !== "mounted") out.push(emptyDiskItem(d)); + out.push(...shown.map((v) => volumeItem(v, d))); + } + // Removable first, then by name. + return out.sort((a, b) => (a.section === b.section ? 0 : a.section === "Removable" ? -1 : 1)); +} + +let summary = ""; + +export default list({ + placeholder: "Filter drives, volumes, mount points…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "removable", title: "Removable" }, + { value: "mounted", title: "Mounted" }, + ], + }, + empty: { + title: "No drives here", + description: "Plug in a USB stick or an SD card: it shows up within a few seconds", + icon: Icons.drive, + }, + status: () => summary, + async *items({ filter, signal }) { + let last = ""; + let subtitle: string | null | undefined; + while (!signal.aborted) { + let disks: Disk[]; + try { + disks = await lsblk(); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + throw new Error("lsblk was not found: install util-linux"); + } + throw err; + } + const key = JSON.stringify(disks); + if (key !== last) { + last = key; + const removable = disks.filter((d) => d.removable); + const mounted = disks.flatMap((d) => d.volumes).filter((v) => effective(v).mountpoint).length; + summary = `${disks.length} drive${ + disks.length === 1 ? "" : "s" + } · ${removable.length} removable · ${mounted} mounted`; + const next = removable.length + ? `${removable.length} removable drive${removable.length === 1 ? "" : "s"}` + : null; + if (next !== subtitle) { + subtitle = next; + updateCommandMetadata({ subtitle }).catch(() => {}); + } + yield items(disks, filter ?? "all"); + } + // Drives come and go: check again every few seconds. + await new Promise((resolve) => { + const t = setTimeout(resolve, 3000); + signal.addEventListener("abort", () => { + clearTimeout(t); + resolve(); + }, { once: true }); + }); + } + }, +}); diff --git a/extensions/drives/lib.ts b/extensions/drives/lib.ts new file mode 100644 index 0000000..18d868e --- /dev/null +++ b/extensions/drives/lib.ts @@ -0,0 +1,225 @@ +// Parsing lsblk and udisksctl output. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +/** Columns asked of lsblk; older util-linux (< 2.33) lacks some, then the short list is used. */ +export const COLUMNS = + "NAME,PATH,LABEL,UUID,SIZE,FSTYPE,FSVER,MOUNTPOINT,RM,HOTPLUG,MODEL,VENDOR,TRAN,FSUSED,FSAVAIL,TYPE,RO"; +export const COLUMNS_SHORT = "NAME,LABEL,SIZE,FSTYPE,MOUNTPOINT,RM,HOTPLUG,MODEL,TRAN,FSUSED,FSAVAIL,TYPE"; + +export interface Volume { + name: string; + path: string; + label?: string; + uuid?: string; + size: number; + fstype?: string; + fsver?: string; + mountpoint?: string; + used?: number; + available?: number; + type: string; + readonly: boolean; + /** For an unlocked LUKS volume: the filesystem inside. */ + inner?: Volume; +} + +export interface Disk { + name: string; + path: string; + model?: string; + vendor?: string; + size: number; + transport?: string; + removable: boolean; + type: string; + volumes: Volume[]; +} + +// lsblk prints booleans and numbers as JSON values since 2.33, as strings before. +const bool = (v: unknown) => v === true || v === 1 || v === "1" || v === "true"; +const num = (v: unknown) => (v === null || v === undefined || v === "" ? undefined : Number(v)); +const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined); + +interface Raw { + [key: string]: unknown; + children?: Raw[]; +} + +function volume(r: Raw): Volume { + const name = String(r.name); + const children = r.children ?? []; + // An open LUKS container has a "crypt" child holding the real filesystem. + const crypt = children.find((c) => c.type === "crypt" || c.type === "lvm"); + return { + name, + path: str(r.path) ?? `/dev/${name}`, + label: str(r.label), + uuid: str(r.uuid), + size: num(r.size) ?? 0, + fstype: str(r.fstype), + fsver: str(r.fsver), + mountpoint: str(r.mountpoint), + used: num(r.fsused), + available: num(r.fsavail), + type: String(r.type ?? "part"), + readonly: bool(r.ro), + inner: crypt ? volume(crypt) : undefined, + }; +} + +/** `lsblk -J -b -o …`: physical disks with their volumes; loop devices, zram and empty readers are left out. */ +export function parseLsblk(json: string): Disk[] { + const devices = (JSON.parse(json).blockdevices ?? []) as Raw[]; + return devices.flatMap((r) => { + const name = String(r.name); + const type = String(r.type ?? "disk"); + if (!["disk", "rom"].includes(type) || /^(zram|ram|loop)/.test(name)) return []; + const size = num(r.size) ?? 0; + if (size === 0) return []; + const parts = (r.children ?? []).filter((c) => c.type === "part" || c.type === "crypt" || c.type === "lvm"); + // A stick formatted without a partition table carries its filesystem on the disk itself. + const volumes = parts.length ? parts.map(volume) : str(r.fstype) || str(r.mountpoint) ? [volume(r)] : []; + const transport = str(r.tran); + return [{ + name, + path: str(r.path) ?? `/dev/${name}`, + model: str(r.model)?.replace(/_/g, " "), + // Virtual disks report a PCI id ("0x1af4") as vendor. + vendor: str(r.vendor)?.startsWith("0x") ? undefined : str(r.vendor), + size, + transport, + removable: bool(r.rm) || bool(r.hotplug) || transport === "usb" || transport === "mmc", + type, + volumes, + }]; + }); +} + +/** The volume that is mounted, or would be: the filesystem inside an unlocked LUKS container. */ +export const effective = (v: Volume): Volume => v.inner ?? v; + +export const isEncrypted = (v: Volume) => v.fstype === "crypto_LUKS" || v.fstype === "BitLocker"; +export const isSwap = (v: Volume) => v.fstype === "swap" || v.mountpoint === "[SWAP]"; + +const SYSTEM_MOUNTS = /^\/(boot(\/efi)?|efi|home|usr|var|opt|srv|nix|gnu|recovery)?$/; + +/** Mounted where the system needs it: don't offer to unmount it. */ +export const isSystemMount = (mountpoint?: string) => + mountpoint !== undefined && (SYSTEM_MOUNTS.test(mountpoint) || mountpoint === "[SWAP]"); + +/** Partitions that hold nothing to browse: EFI-reserved, BIOS boot, LVM members… */ +export const isPlumbing = (v: Volume) => + !v.mountpoint && + (!v.fstype ? v.size < 64 * 1024 * 1024 : ["LVM2_member", "linux_raid_member", "zfs_member"].includes(v.fstype)); + +const FS_NAMES: Record = { + vfat: "FAT", + exfat: "exFAT", + ntfs: "NTFS", + ntfs3: "NTFS", + ext2: "ext2", + ext3: "ext3", + ext4: "ext4", + btrfs: "Btrfs", + xfs: "XFS", + f2fs: "F2FS", + zfs_member: "ZFS", + hfsplus: "HFS+", + apfs: "APFS", + iso9660: "ISO 9660", + udf: "UDF", + swap: "Swap", + crypto_LUKS: "LUKS", + BitLocker: "BitLocker", + LVM2_member: "LVM", + squashfs: "SquashFS", +}; + +/** "FAT32", "ext4", "LUKS 2"… */ +export function fsName(v: Volume): string { + if (!v.fstype) return v.mountpoint ? "Unknown" : "Unformatted"; + const base = FS_NAMES[v.fstype] ?? v.fstype; + if (v.fstype === "vfat" && v.fsver) return v.fsver.replace(/^FAT/, "FAT"); + if (v.fstype === "crypto_LUKS" && v.fsver) return `LUKS ${v.fsver}`; + return base; +} + +const TRANSPORTS: Record = { + usb: "USB", + sata: "SATA", + ata: "ATA", + nvme: "NVMe", + mmc: "SD card", + scsi: "SCSI", + sas: "SAS", + virtio: "Virtual", + iscsi: "iSCSI", + ieee1394: "FireWire", +}; + +export const transportName = (t?: string) => (t ? TRANSPORTS[t] ?? t.toUpperCase() : "Unknown"); + +/** The disk's commercial name: "SanDisk Ultra", "Samsung SSD 980 PRO 1TB". */ +export function diskName(d: Disk): string { + const model = d.model ?? ""; + const vendor = d.vendor && !model.toLowerCase().startsWith(d.vendor.toLowerCase()) ? d.vendor : ""; + return [vendor, model].filter(Boolean).join(" ") || + (d.type === "rom" ? "Optical drive" : d.transport ? `${transportName(d.transport)} drive` : "Drive"); +} + +/** What the user calls a volume: its label, else where it is mounted, else its size and kind. */ +export function volumeName(v: Volume, d: Disk): string { + const e = effective(v); + if (e.label) return e.label; + if (v.label) return v.label; + if (e.mountpoint === "/") return "System"; + if (e.mountpoint && e.mountpoint !== "[SWAP]") return e.mountpoint.split("/").pop() || e.mountpoint; + if (isSwap(e)) return "Swap"; + return `${size(v.size)} ${ + d.removable ? "Volume" : fsName(e) === "Unformatted" ? "Partition" : `${fsName(e)} Volume` + }`; +} + +/** 32 GB, 1 TB: decimal units, as printed on drives. */ +export function size(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "0 B"; + const units = ["B", "kB", "MB", "GB", "TB", "PB"]; + let i = 0; + while (n >= 1000 && i < units.length - 1) { + n /= 1000; + i++; + } + return `${n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")} ${units[i]}`; +} + +export const usageColor = (ratio: number): Color => + ratio >= 0.95 ? "red" : ratio >= 0.85 ? "orange" : ratio >= 0.7 ? "yellow" : "green"; + +/** `udisksctl mount`: "Mounted /dev/sdb1 at /run/media/ada/STICK" (older versions end with a dot). */ +export function parseMounted(out: string): string | undefined { + return out.match(/^Mounted \S+ at (.+?)\.?$/m)?.[1]; +} + +/** `udisksctl unlock`: "Unlocked /dev/sdb1 as /dev/dm-3." */ +export function parseUnlocked(out: string): string | undefined { + return out.match(/^Unlocked \S+ as (\S+?)\.?$/m)?.[1]; +} + +/** Turns udisks' D-Bus errors into a sentence. */ +export function explainUdisksError(message: string): string { + if (/target is busy|device is busy|DeviceBusy/i.test(message)) { + return "The drive is in use: close the files and windows using it, then try again"; + } + if (/NotAuthorized|not authorized|Authentication is required/i.test(message)) { + return "Not allowed: your system's policy requires an administrator for this drive"; + } + if (/not mounted/i.test(message)) return "Already unmounted"; + if (/already mounted/i.test(message)) return "Already mounted"; + if (/Error connecting to the udisks daemon|Could not connect|ServiceUnknown/i.test(message)) { + return "The udisks service is not running (install and start udisks2)"; + } + if (/wrong passphrase|Failed to activate device|incorrect passphrase/i.test(message)) return "Wrong passphrase"; + const m = message.match(/GDBus\.Error:[\w.]+:\s*(.*)$/m); + return (m?.[1] ?? message).trim(); +} diff --git a/extensions/drives/lib_test.ts b/extensions/drives/lib_test.ts new file mode 100644 index 0000000..d2924cd --- /dev/null +++ b/extensions/drives/lib_test.ts @@ -0,0 +1,209 @@ +import { assertEquals } from "@std/assert"; +import { + diskName, + effective, + explainUdisksError, + fsName, + isPlumbing, + isSystemMount, + parseLsblk, + parseMounted, + parseUnlocked, + size, + volumeName, +} from "./lib.ts"; + +// util-linux 2.39 on a laptop: NVMe with EFI and an unlocked LUKS root, a USB stick, an empty SD reader. +const LAPTOP = JSON.stringify({ + blockdevices: [ + { + name: "zram0", + path: "/dev/zram0", + size: 8589934592, + type: "disk", + mountpoint: "[SWAP]", + fstype: "swap", + rm: false, + hotplug: false, + }, + { + name: "mmcblk0", + path: "/dev/mmcblk0", + size: 0, + type: "disk", + rm: true, + hotplug: true, + tran: "mmc", + }, + { + name: "nvme0n1", + path: "/dev/nvme0n1", + label: null, + size: 1000204886016, + fstype: null, + mountpoint: null, + rm: false, + hotplug: false, + model: "Samsung SSD 980 PRO 1TB", + vendor: null, + tran: "nvme", + type: "disk", + ro: false, + children: [ + { + name: "nvme0n1p1", + path: "/dev/nvme0n1p1", + label: "EFI", + uuid: "B2A1-1C3D", + size: 629145600, + fstype: "vfat", + fsver: "FAT32", + mountpoint: "/boot/efi", + fsused: 78643200, + fsavail: 550502400, + type: "part", + rm: false, + hotplug: false, + }, + { + name: "nvme0n1p2", + path: "/dev/nvme0n1p2", + uuid: "6c1b6e0e-luks", + size: 999574142976, + fstype: "crypto_LUKS", + fsver: "2", + mountpoint: null, + type: "part", + children: [ + { + name: "luks-6c1b", + path: "/dev/mapper/luks-6c1b", + label: "fedora", + uuid: "f00d-btrfs", + size: 999557365760, + fstype: "btrfs", + mountpoint: "/", + fsused: 400000000000, + fsavail: 590000000000, + type: "crypt", + }, + ], + }, + ], + }, + { + name: "sda", + path: "/dev/sda", + size: 61530439680, + rm: true, + hotplug: true, + model: "Ultra_Fit", + vendor: "SanDisk ", + tran: "usb", + type: "disk", + children: [ + { + name: "sda1", + path: "/dev/sda1", + label: null, + uuid: "1234-ABCD", + size: 61529391104, + fstype: "exfat", + fsver: "1.0", + mountpoint: null, + type: "part", + }, + ], + }, + ], +}); + +Deno.test("lsblk: disks, partitions and unlocked LUKS", () => { + const disks = parseLsblk(LAPTOP); + assertEquals(disks.map((d) => d.name), ["nvme0n1", "sda"]); + const [nvme, usb] = disks; + assertEquals(nvme.removable, false); + assertEquals(usb.removable, true); + assertEquals(diskName(nvme), "Samsung SSD 980 PRO 1TB"); + assertEquals(diskName(usb), "SanDisk Ultra Fit"); + assertEquals(nvme.volumes.length, 2); + const root = nvme.volumes[1]; + assertEquals(effective(root).mountpoint, "/"); + assertEquals(effective(root).path, "/dev/mapper/luks-6c1b"); + assertEquals(fsName(root), "LUKS 2"); + assertEquals(fsName(nvme.volumes[0]), "FAT32"); + assertEquals(volumeName(root, nvme), "fedora"); + assertEquals(volumeName(nvme.volumes[0], nvme), "EFI"); + assertEquals(volumeName(usb.volumes[0], usb), "61.5 GB Volume"); +}); + +Deno.test("lsblk before 2.33: strings for numbers and booleans, no path", () => { + const old = JSON.stringify({ + blockdevices: [{ + name: "sdb", + label: "KEY", + size: "15728640000", + fstype: "vfat", + mountpoint: "/run/media/ada/KEY", + rm: "1", + hotplug: "1", + model: "DataTraveler 3.0", + tran: "usb", + fsused: "1048576000", + fsavail: "14680064000", + type: "disk", + }], + }); + const [d] = parseLsblk(old); + assertEquals(d.path, "/dev/sdb"); + assertEquals(d.removable, true); + // Formatted without a partition table: the disk is its own volume. + assertEquals(d.volumes.length, 1); + assertEquals(d.volumes[0].used, 1048576000); + assertEquals(volumeName(d.volumes[0], d), "KEY"); +}); + +Deno.test("system mounts and plumbing partitions", () => { + assertEquals(["/", "/boot/efi", "/home", "[SWAP]", "/run/media/ada/KEY", "/mnt/data"].map(isSystemMount), [ + true, + true, + true, + true, + false, + false, + ]); + const base = { name: "x", path: "/dev/x", type: "part", readonly: false }; + assertEquals(isPlumbing({ ...base, size: 16 * 1024 * 1024 }), true); // Microsoft reserved + assertEquals(isPlumbing({ ...base, size: 16e9, fstype: "LVM2_member" }), true); + assertEquals(isPlumbing({ ...base, size: 16e9, fstype: "ext4" }), false); +}); + +Deno.test("udisksctl output and errors", () => { + assertEquals(parseMounted("Mounted /dev/sda1 at /run/media/ada/USB STICK"), "/run/media/ada/USB STICK"); + assertEquals(parseMounted("Mounted /dev/sdb1 at /media/ada/KEY."), "/media/ada/KEY"); + assertEquals(parseUnlocked("Unlocked /dev/sdb1 as /dev/dm-3."), "/dev/dm-3"); + assertEquals( + explainUdisksError( + "udisksctl exited with 1: Error unmounting /dev/sda1: GDBus.Error:org.freedesktop.UDisks2.Error.DeviceBusy: Error unmounting /dev/sda1: target is busy", + ), + "The drive is in use: close the files and windows using it, then try again", + ); + assertEquals( + explainUdisksError("Error connecting to the udisks daemon: Could not connect: No such file or directory"), + "The udisks service is not running (install and start udisks2)", + ); + assertEquals( + explainUdisksError("Error mounting: GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Unknown filesystem type"), + "Unknown filesystem type", + ); +}); + +Deno.test("sizes in decimal units, as printed on drives", () => { + assertEquals([size(0), size(999), size(61529391104), size(1000204886016), size(629145600)], [ + "0 B", + "999 B", + "61.5 GB", + "1 TB", + "629 MB", + ]); +}); diff --git a/extensions/notifications/backends.ts b/extensions/notifications/backends.ts new file mode 100644 index 0000000..9db734c --- /dev/null +++ b/extensions/notifications/backends.ts @@ -0,0 +1,226 @@ +// One adapter per notification daemon or desktop. Each says what it can do; the views adapt. + +import { exec } from "@backslash/api"; +import { + type BackendId, + detectBackend, + type Notification, + parseBootTime, + parseDunstHistory, + parseGdbusBool, + parseMakoHistory, + parseMakoModes, + parseServerInfo, +} from "./lib.ts"; + +export interface Backend { + id: BackendId; + title: string; + /** Daemon version, when known. */ + version?: string; + /** undefined: the state can't be read. */ + dnd(): Promise; + /** Missing when Do Not Disturb can't be changed from outside. */ + setDnd?(on: boolean): Promise; + /** Shown next to the toggle: how this desktop does it, what to configure. */ + dndNote?: string; + history?(): Promise; + /** Why there is no history, and where to find it instead. */ + historyNote?: string; + restore?(n: Notification): Promise; + remove?(n: Notification): Promise; + clear?(): Promise; + /** "Clear History" or "Dismiss All". */ + clearTitle?: string; + openCenter?(): Promise; + openSettings?(): Promise; +} + +const bool = (out: string) => out.trim() === "true"; + +/** Starts a settings window that outlives the command; tries each program in turn. */ +function launch(...candidates: string[][]): Promise { + for (const [program, ...args] of candidates) { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return Promise.resolve(); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable)) throw err; + } + } + return Promise.reject(new Error(`${candidates.map((c) => c[0]).join(" or ")} is not installed`)); +} + +const gsettings = (...args: string[]) => exec("gsettings", args); + +const mako: Omit = { + id: "mako", + title: "mako", + dnd: async () => parseMakoModes(await exec("makoctl", ["mode"])).includes("do-not-disturb"), + async setDnd(on) { + try { + await exec("makoctl", ["mode", on ? "-a" : "-r", "do-not-disturb"]); + } catch { + // mako before 1.8 + await exec("makoctl", ["set-mode", on ? "do-not-disturb" : "default"]); + } + }, + dndNote: "mako hides notifications in this mode only if its config has a `[mode=do-not-disturb]` section with " + + "`invisible=1`.", + async history() { + // mako 1.9+ prints text unless asked for JSON; older versions only know JSON. + const out = await exec("makoctl", ["history", "-j"]).catch(() => exec("makoctl", ["history"])); + return parseMakoHistory(out); + }, + restore: async () => { + await exec("makoctl", ["restore"]); + }, + clear: async () => { + await exec("makoctl", ["dismiss", "--all"]); + }, + clearTitle: "Dismiss All Visible", +}; + +const dunst: Omit = { + id: "dunst", + title: "Dunst", + dnd: async () => bool(await exec("dunstctl", ["is-paused"])), + setDnd: async (on) => { + await exec("dunstctl", ["set-paused", String(on)]); + }, + dndNote: "Dunst keeps notifications received while paused and shows them when you turn this off.", + async history() { + const [json, boot] = await Promise.all([ + exec("dunstctl", ["history"]), + exec("uptime", ["-s"]).then(parseBootTime).catch(() => undefined), + ]); + return parseDunstHistory(json, boot); + }, + restore: async (n) => { + await exec("dunstctl", ["history-pop", n.id]); + }, + remove: async (n) => { + await exec("dunstctl", ["history-rm", n.id]); + }, + clear: async () => { + await exec("dunstctl", ["history-clear"]); + }, + clearTitle: "Clear History", +}; + +const swaync: Omit = { + id: "swaync", + title: "SwayNotificationCenter", + dnd: async () => bool(await exec("swaync-client", ["-D", "-sw"])), + async setDnd(on) { + try { + await exec("swaync-client", [on ? "--dnd-on" : "--dnd-off", "-sw"]); + } catch { + // Older versions only toggle. + if (bool(await exec("swaync-client", ["-D", "-sw"])) !== on) await exec("swaync-client", ["-d", "-sw"]); + } + }, + historyNote: + "SwayNotificationCenter keeps your notifications in its panel and has no command to list them. Open the panel to see them.", + openCenter: async () => { + await exec("swaync-client", ["-t", "-sw"]); + }, + clear: async () => { + await exec("swaync-client", ["-C", "-sw"]); + }, + clearTitle: "Clear Notification Center", +}; + +const gnome: Omit = { + id: "gnome", + title: "GNOME", + dnd: async () => !bool(await gsettings("get", "org.gnome.desktop.notifications", "show-banners")), + setDnd: async (on) => { + await gsettings("set", "org.gnome.desktop.notifications", "show-banners", String(!on)); + }, + dndNote: "Same switch as in the calendar menu: banners are hidden, notifications still go to the list.", + historyNote: "GNOME Shell keeps notifications in the calendar menu (Super+V) and doesn't share them with other apps.", + openSettings: () => launch(["gnome-control-center", "notifications"]), +}; + +const kde: Omit = { + id: "kde", + title: "Plasma", + dnd: async () => + parseGdbusBool( + await exec("gdbus", [ + "call", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + "--method", + "org.freedesktop.DBus.Properties.Get", + "org.freedesktop.Notifications", + "Inhibited", + ]), + ), + dndNote: "Plasma ties Do Not Disturb to the app asking for it and ends it when that app quits, so it can't be " + + "switched from here. Use the Do Not Disturb switch of the Notifications applet in the panel.", + historyNote: "Plasma keeps your notification history in the Notifications applet of the panel.", + openSettings: () => + launch( + ["systemsettings", "kcm_notifications"], + ["kcmshell6", "kcm_notifications"], + ["kcmshell5", "kcm_notifications"], + ), +}; + +const xfceDnd = ["-c", "xfce4-notifyd", "-p", "/do-not-disturb"]; +const xfce: Omit = { + id: "xfce", + title: "Xfce", + dnd: async () => bool(await exec("xfconf-query", xfceDnd).catch(() => "false")), + setDnd: async (on) => { + await exec("xfconf-query", [...xfceDnd, "-n", "-t", "bool", "-s", String(on)]); + }, + historyNote: "The Xfce notification daemon keeps its log in the Notifications plugin of the panel.", + openSettings: () => launch(["xfce4-notifyd-config"]), +}; + +const cinnamon: Omit = { + id: "cinnamon", + title: "Cinnamon", + dnd: async () => !bool(await gsettings("get", "org.cinnamon.desktop.notifications", "display-notifications")), + setDnd: async (on) => { + await gsettings("set", "org.cinnamon.desktop.notifications", "display-notifications", String(!on)); + }, + historyNote: "Cinnamon keeps notifications in the Notifications applet of the panel.", + openSettings: () => launch(["cinnamon-settings", "notifications"]), +}; + +const BACKENDS: Record> = { mako, dunst, swaync, gnome, kde, xfce, cinnamon }; + +/** The daemon in charge: asked over D-Bus, else found by trying each tool, else guessed from the desktop. */ +export async function detect(): Promise { + const desktop = Deno.env.get("XDG_CURRENT_DESKTOP") ?? ""; + const info = await exec("gdbus", [ + "call", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + "--method", + "org.freedesktop.Notifications.GetServerInformation", + ]).then(parseServerInfo).catch(() => undefined); + const id = detectBackend(info?.name, desktop); + if (id) return { ...BACKENDS[id], version: info?.version }; + // No gdbus, or a daemon not running yet (they start on the first notification). + for ( + const [backend, program, args] of [ + [mako, "makoctl", ["mode"]], + [dunst, "dunstctl", ["is-paused"]], + [swaync, "swaync-client", ["-D", "-sw"]], + ] as const + ) { + if (await exec(program, [...args]).then(() => true, () => false)) return { ...backend }; + } + return undefined; +} diff --git a/extensions/notifications/backslash.toml b/extensions/notifications/backslash.toml new file mode 100644 index 0000000..278e884 --- /dev/null +++ b/extensions/notifications/backslash.toml @@ -0,0 +1,56 @@ +[extension] +id = "notifications" +title = "Notifications" +description = "Do Not Disturb and the notification history for mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon" +version = "1.0.0" +author = "Backslash" +icon = "bs-bell" +license = "MIT" + +# gdbus asks which daemon shows notifications; each daemon's own tool switches Do Not Disturb and +# reads its history; uptime -s dates Dunst's history; the settings apps open on request. +[permissions] +env = ["XDG_CURRENT_DESKTOP"] +run = [ + "gdbus", + "makoctl", + "dunstctl", + "swaync-client", + "gsettings", + "xfconf-query", + "uptime", + "gnome-control-center", + "systemsettings", + "kcmshell6", + "kcmshell5", + "xfce4-notifyd-config", + "cinnamon-settings", +] + +[[commands]] +name = "notifications" +title = "Notifications" +description = "Do Not Disturb, and the notifications you missed" +icon = "bs-bell" +keywords = [ + "notifications", + "notification", + "do not disturb", + "dnd", + "ne pas déranger", + "history", + "historique", + "mako", + "dunst", + "swaync", + "alerts", + "alertes", +] + +[[commands]] +name = "toggle-do-not-disturb" +title = "Toggle Do Not Disturb" +description = "Silence notifications, or bring them back" +icon = "bs-bell-off" +mode = "no-view" +keywords = ["dnd", "do not disturb", "ne pas déranger", "silence", "focus", "mute", "notifications"] diff --git a/extensions/notifications/commands/notifications.ts b/extensions/notifications/commands/notifications.ts new file mode 100644 index 0000000..8e5d61c --- /dev/null +++ b/extensions/notifications/commands/notifications.ts @@ -0,0 +1,224 @@ +import { + type Action, + action, + type Color, + copy, + Icons, + type Item, + list, + meta, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { type Backend, detect } from "../backends.ts"; +import { ago, escapeMarkdown, type Notification, plainText } from "../lib.ts"; + +const URGENCY: Record = { + low: ["Low", "gray"], + normal: ["Normal", "blue"], + critical: ["Critical", "red"], +}; + +const NOT_FOUND = { + title: "No notification daemon found", + description: "Backslash works with mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon", + icon: Icons.bellOff, +}; + +function toggleAction(b: Backend, on: boolean): Action { + return action(on ? "Turn Off Do Not Disturb" : "Turn On Do Not Disturb", async () => { + await b.setDnd!(!on); + await updateCommandMetadata({ subtitle: on ? null : "Do Not Disturb" }).catch(() => {}); + await toast(on ? "Notifications are back" : "Do Not Disturb is on", { style: "success" }); + return refresh(); + }, { icon: on ? Icons.bell : Icons.bellOff, shortcut: "d" }); +} + +function dndItem(b: Backend, on: boolean | undefined): Item { + const known = on !== undefined; + const settings = b.openSettings + ? [action("Open Notification Settings", async () => { + await b.openSettings!(); + }, { icon: Icons.settings, section: "Settings" })] + : []; + return { + id: "dnd", + title: "Do Not Disturb", + subtitle: !known ? "State unknown" : on ? "On: notifications are silenced" : "Off", + icon: { source: on ? Icons.bellOff : Icons.bell, tint: on ? "purple" : "gray" }, + section: "Status", + keywords: ["dnd", "ne pas déranger", "silence", "focus", "mute", "pause"], + accessories: [ + known ? { tag: on ? "On" : "Off", color: on ? "purple" : "gray" } : { tag: "Unknown", color: "gray" }, + ], + detail: { + markdown: b.dndNote, + metadata: [ + meta.text("Do Not Disturb", !known ? "Unknown" : on ? "On" : "Off", { + icon: on ? Icons.bellOff : Icons.bell, + color: on ? "purple" : undefined, + }), + meta.text("Handled by", b.version ? `${b.title} ${b.version}` : b.title, { icon: Icons.settings }), + meta.text("Can be switched here", b.setDnd ? "Yes" : "No", { color: b.setDnd ? "green" : "orange" }), + ], + }, + actions: [...(b.setDnd && known ? [toggleAction(b, on)] : []), ...settings], + }; +} + +function notificationItem(n: Notification, b: Backend, now: number): Item { + const summary = plainText(n.summary); + const body = plainText(n.body); + const [urgency, color] = URGENCY[n.urgency]; + const all = [summary, body].filter(Boolean).join("\n"); + const run = (title: string, fn: () => Promise, done: string, options = {}) => + action(title, async () => { + await fn(); + await toast(done, { style: "success" }); + return refresh(); + }, options); + return { + id: `n:${n.id}:${n.time ?? ""}`, + title: summary || n.app, + subtitle: body.split("\n")[0].slice(0, 120) || undefined, + icon: { source: n.urgency === "critical" ? Icons.alert : Icons.bell, tint: color }, + section: "History", + keywords: [n.app, body, n.category ?? ""], + accessories: [ + { tag: n.app, color: "gray" }, + ...(n.time ? [{ text: ago(n.time, now), tooltip: new Date(n.time).toLocaleString() }] : []), + ], + detail: { + markdown: [summary && `**${escapeMarkdown(summary)}**`, body && escapeMarkdown(body).replace(/\n/g, " \n")] + .filter(Boolean).join("\n\n"), + metadata: [ + meta.text("App", n.app, { icon: Icons.apps }), + ...(n.time ? [meta.text("Received", new Date(n.time).toLocaleString(), { icon: Icons.clock })] : []), + meta.tags("Urgency", [{ text: urgency, color }]), + ...(n.category ? [meta.text("Category", n.category, { icon: Icons.tag })] : []), + meta.text("ID", n.id, { icon: Icons.hash }), + ], + }, + actions: [ + copy(all, "Copy Text", { shortcut: "c", section: "Copy" }), + ...(body ? [copy(body, "Copy Body", { section: "Copy" })] : []), + ...(summary ? [copy(summary, "Copy Title", { section: "Copy" })] : []), + ...(b.restore && b.id !== "mako" + ? [run("Show Again", () => b.restore!(n), "Shown again", { icon: Icons.bell, section: "Notification" })] + : []), + ...(b.remove + ? [run("Remove from History", () => b.remove!(n), "Removed", { + icon: Icons.trash, + destructive: true, + section: "Notification", + shortcut: "Delete", + })] + : []), + ...(b.clear + ? [run(b.clearTitle ?? "Clear", b.clear, "Cleared", { + icon: Icons.trash, + destructive: true, + confirm: `${b.clearTitle ?? "Clear"}? This cannot be undone.`, + section: "Danger", + })] + : []), + ], + }; +} + +/** Explains where the history is when the daemon doesn't share it. */ +function noHistoryItem(b: Backend): Item { + return { + id: "no-history", + title: "History not available", + subtitle: b.title, + icon: { source: Icons.info, tint: "gray" }, + section: "History", + detail: { markdown: b.historyNote }, + actions: [ + ...(b.openCenter + ? [action("Open Notification Center", async () => { + await b.openCenter!(); + }, { icon: Icons.inbox })] + : []), + ...(b.clear + ? [action(b.clearTitle ?? "Clear", async () => { + await b.clear!(); + await toast("Cleared", { style: "success" }); + }, { icon: Icons.trash, destructive: true, confirm: `${b.clearTitle ?? "Clear"}?`, section: "Danger" })] + : []), + ...(b.openSettings + ? [action("Open Notification Settings", async () => { + await b.openSettings!(); + }, { icon: Icons.settings })] + : []), + ], + }; +} + +export default async () => { + const b = await detect(); + if (!b) return list({ empty: NOT_FOUND, items: [], status: "No notification daemon" }); + const [on, history] = await Promise.all([ + b.dnd().catch(() => undefined), + b.history ? b.history().catch((err) => err as Error) : Promise.resolve(undefined), + ]); + if (on !== undefined) updateCommandMetadata({ subtitle: on ? "Do Not Disturb" : null }).catch(() => {}); + + const now = Date.now(); + const items: Item[] = [dndItem(b, on)]; + if (history instanceof Error) { + items.push({ + id: "history-error", + title: "Could not read the history", + subtitle: history.message.split("\n")[0], + icon: { source: Icons.alert, tint: "orange" }, + section: "History", + detail: { markdown: `\`\`\`\n${history.message}\n\`\`\`` }, + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh })], + }); + } else if (history) { + // Newest first: dunst and mako already list them that way, timestamps settle it when there are some. + const sorted = history.some((n) => n.time) ? [...history].sort((a, b) => (b.time ?? 0) - (a.time ?? 0)) : history; + items.push(...sorted.map((n) => notificationItem(n, b, now))); + if (!sorted.length) { + items.push({ + id: "history-empty", + title: "No notifications yet", + subtitle: "Dismissed notifications show up here", + icon: { source: Icons.inbox, tint: "gray" }, + section: "History", + }); + } + } else { + items.push(noHistoryItem(b)); + } + + const count = history && !(history instanceof Error) ? history.length : undefined; + const actions: Action[] = [ + ...(b.setDnd && on !== undefined ? [toggleAction(b, on)] : []), + ...(b.openCenter + ? [action("Open Notification Center", async () => { + await b.openCenter!(); + }, { icon: Icons.inbox })] + : []), + ...(b.id === "mako" && b.restore + ? [action("Restore Last Notification", async () => { + await b.restore!({ id: "", app: "", summary: "", body: "", urgency: "normal" }); + }, { icon: Icons.history })] + : []), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ]; + return list({ + placeholder: "Filter notifications by app or text…", + empty: { title: "No notifications", description: "Nothing in the history yet", icon: Icons.bell }, + actions, + status: [ + b.version ? `${b.title} ${b.version}` : b.title, + on ? "Do Not Disturb on" : on === false ? "Do Not Disturb off" : "", + count !== undefined ? `${count} in history` : "", + ].filter(Boolean).join(" · "), + items, + }); +}; diff --git a/extensions/notifications/commands/toggle-do-not-disturb.ts b/extensions/notifications/commands/toggle-do-not-disturb.ts new file mode 100644 index 0000000..aad30f1 --- /dev/null +++ b/extensions/notifications/commands/toggle-do-not-disturb.ts @@ -0,0 +1,12 @@ +import { run, updateCommandMetadata } from "@backslash/api"; +import { detect } from "../backends.ts"; + +export default run(async () => { + const b = await detect(); + if (!b) throw new Error("No notification daemon found (mako, Dunst, SwayNotificationCenter, GNOME, Xfce, Cinnamon)"); + if (!b.setDnd) throw new Error(`${b.title} doesn't let other apps switch Do Not Disturb`); + const on = await b.dnd(); + await b.setDnd(!on); + await updateCommandMetadata({ subtitle: on ? null : "Do Not Disturb" }).catch(() => {}); + return on ? "Do Not Disturb off" : "Do Not Disturb on"; +}); diff --git a/extensions/notifications/lib.ts b/extensions/notifications/lib.ts new file mode 100644 index 0000000..75f86df --- /dev/null +++ b/extensions/notifications/lib.ts @@ -0,0 +1,180 @@ +// Parsing the output of notification daemons. Pure, tested in lib_test.ts. + +export type BackendId = "mako" | "dunst" | "swaync" | "gnome" | "kde" | "xfce" | "cinnamon"; + +export interface Notification { + id: string; + app: string; + summary: string; + body: string; + urgency: "low" | "normal" | "critical"; + category?: string; + /** Milliseconds since the epoch, when the daemon tells. */ + time?: number; +} + +/** `gdbus call … GetServerInformation`: `('mako', 'emersion', '1.9.0', '1.2')`. */ +export function parseServerInfo(out: string): { name: string; vendor: string; version: string } | undefined { + const m = out.match(/^\('((?:[^'\\]|\\.)*)', '((?:[^'\\]|\\.)*)', '((?:[^'\\]|\\.)*)'/); + return m ? { name: m[1], vendor: m[2], version: m[3] } : undefined; +} + +/** `gdbus call … Properties.Get …`: `(,)`. */ +export const parseGdbusBool = (out: string) => /|\btrue\b/.test(out); + +/** Which backend drives the notifications, from the D-Bus server name, else the desktop. */ +export function detectBackend(server: string | undefined, desktop: string): BackendId | undefined { + const s = (server ?? "").toLowerCase(); + if (s.includes("mako")) return "mako"; + if (s.includes("dunst")) return "dunst"; + if (s.includes("swaync") || s.includes("swaynotificationcenter")) return "swaync"; + if (s.includes("gnome-shell") || s === "gnome") return "gnome"; + if (s.includes("plasma") || s === "kde") return "kde"; + if (s.includes("xfce")) return "xfce"; + if (s.includes("cinnamon")) return "cinnamon"; + const d = desktop.toLowerCase(); + if (d.includes("gnome") || d.includes("unity") || d.includes("pantheon")) return "gnome"; + if (d.includes("kde")) return "kde"; + if (d.includes("xfce")) return "xfce"; + if (d.includes("cinnamon") || d.includes("x-cinnamon")) return "cinnamon"; + return undefined; +} + +/** Unwraps the `{ "type": "s", "data": … }` envelopes of D-Bus JSON dumps (dunstctl, old makoctl). */ +function unwrap(v: unknown): unknown { + if (Array.isArray(v)) return v.map(unwrap); + if (v && typeof v === "object") { + const o = v as Record; + if ("type" in o && "data" in o && Object.keys(o).length === 2) return unwrap(o.data); + return Object.fromEntries(Object.entries(o).map(([k, x]) => [k, unwrap(x)])); + } + return v; +} + +/** The list of notification records in a JSON dump, whatever its nesting. */ +function records(json: string): Record[] { + let v = unwrap(JSON.parse(json)); + if (v && typeof v === "object" && !Array.isArray(v)) { + const o = v as Record; + v = o.data ?? o.notifications ?? o.history ?? []; + } + const flat = (x: unknown): Record[] => + Array.isArray(x) ? x.flatMap(flat) : x && typeof x === "object" ? [x as Record] : []; + return flat(v); +} + +const text = (v: unknown) => (v === undefined || v === null ? "" : String(v)); + +const URGENCY: Record = { + "0": "low", + "1": "normal", + "2": "critical", + low: "low", + normal: "normal", + critical: "critical", +}; +const urgency = (v: unknown) => URGENCY[text(v).toLowerCase()] ?? "normal"; + +/** + * `dunstctl history`. Timestamps are microseconds of the monotonic clock: `bootTime` (ms since + * the epoch, from `uptime -s`) turns them into dates. + */ +export function parseDunstHistory(json: string, bootTime?: number): Notification[] { + return records(json).map((r) => { + const ts = Number(r.timestamp); + return { + id: text(r.id), + app: text(r.appname) || "Unknown app", + summary: text(r.summary), + body: text(r.body), + urgency: urgency(r.urgency), + category: text(r.category) || undefined, + time: bootTime !== undefined && ts > 0 ? bootTime + ts / 1000 : undefined, + }; + }); +} + +/** `makoctl history` as JSON: the D-Bus dump of mako < 1.9, or the plain objects of `-j` in 1.9+. */ +export function parseMakoJson(json: string): Notification[] { + return records(json).map((r) => ({ + id: text(r.id), + app: text(r["app-name"] ?? r.app_name ?? r.appName) || "Unknown app", + summary: text(r.summary), + body: text(r.body), + urgency: urgency(r.urgency), + category: text(r.category) || undefined, + })); +} + +/** + * `makoctl history` as text (mako 1.9+): + * ``` + * Notification 12: Build finished + * App name: kitty + * Urgency: normal + * ``` + */ +export function parseMakoText(out: string): Notification[] { + const list: Notification[] = []; + for (const line of out.split("\n")) { + const head = line.match(/^Notification (\d+): ?(.*)$/); + if (head) { + list.push({ id: head[1], app: "Unknown app", summary: head[2], body: "", urgency: "normal" }); + continue; + } + const cur = list.at(-1); + const kv = line.match(/^\s+([\w ]+?):\s*(.*)$/); + if (!cur || !kv) continue; + const key = kv[1].toLowerCase(); + if (key === "app name") cur.app = kv[2]; + else if (key === "urgency") cur.urgency = urgency(kv[2]); + else if (key === "category") cur.category = kv[2]; + else if (key === "body") cur.body = kv[2]; + } + return list; +} + +/** `makoctl history`, whichever format this mako prints. */ +export function parseMakoHistory(out: string): Notification[] { + const t = out.trim(); + if (!t) return []; + if (t.startsWith("{") || t.startsWith("[")) return parseMakoJson(t); + return parseMakoText(t); +} + +/** `makoctl mode`: one active mode per line. */ +export const parseMakoModes = (out: string) => out.split("\n").map((l) => l.trim()).filter(Boolean); + +/** Notification bodies may carry a little markup (``, ``, entities): plain text. */ +export function plainText(s: string): string { + return s + .replace(//gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&/g, "&") + .trim(); +} + +/** Escapes text for the markdown panel. */ +export const escapeMarkdown = (s: string) => s.replace(/([\\`*_[\]#<>|])/g, "\\$1"); + +/** "just now", "5 min ago", "3 h ago", "yesterday", or a date. */ +export function ago(time: number, now = Date.now()): string { + const s = Math.max(0, (now - time) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + if (s < 2 * 86400) return "yesterday"; + return new Date(time).toLocaleDateString([], { day: "numeric", month: "short" }); +} + +/** `uptime -s`: `2026-10-28 08:12:03`, local time. */ +export function parseBootTime(out: string): number | undefined { + const m = out.trim().match(/^(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)$/); + if (!m) return undefined; + const [y, mo, d, h, mi, s] = m.slice(1).map(Number); + return new Date(y, mo - 1, d, h, mi, s).getTime(); +} diff --git a/extensions/notifications/lib_test.ts b/extensions/notifications/lib_test.ts new file mode 100644 index 0000000..7fdeebe --- /dev/null +++ b/extensions/notifications/lib_test.ts @@ -0,0 +1,130 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + detectBackend, + escapeMarkdown, + parseBootTime, + parseDunstHistory, + parseGdbusBool, + parseMakoHistory, + parseMakoModes, + parseServerInfo, + plainText, +} from "./lib.ts"; + +Deno.test("server information and backend detection", () => { + assertEquals(parseServerInfo("('mako', 'emersion', '1.9.0', '1.2')\n"), { + name: "mako", + vendor: "emersion", + version: "1.9.0", + }); + assertEquals(parseServerInfo("Error: GDBus.Error:org.freedesktop.DBus.Error.ServiceUnknown"), undefined); + assertEquals(detectBackend("mako", ""), "mako"); + assertEquals(detectBackend("dunst", "Hyprland"), "dunst"); + assertEquals(detectBackend("SwayNotificationCenter", "sway"), "swaync"); + assertEquals(detectBackend("gnome-shell", "ubuntu:GNOME"), "gnome"); + assertEquals(detectBackend("Plasma", "KDE"), "kde"); + assertEquals(detectBackend("Xfce Notify Daemon", "XFCE"), "xfce"); + assertEquals(detectBackend(undefined, "X-Cinnamon"), "cinnamon"); + assertEquals(detectBackend(undefined, "sway"), undefined); + assertEquals(parseGdbusBool("(,)\n"), true); + assertEquals(parseGdbusBool("(,)\n"), false); +}); + +const DUNST = JSON.stringify({ + type: "aa{sv}", + data: [[ + { + body: { type: "s", data: "Your build passed & deployed" }, + summary: { type: "s", data: "CI finished" }, + appname: { type: "s", data: "GitHub" }, + category: { type: "s", data: "" }, + id: { type: "i", data: 12 }, + timestamp: { type: "x", data: 1_500_000_000 }, + urgency: { type: "s", data: "CRITICAL" }, + }, + { + body: { type: "s", data: "" }, + summary: { type: "s", data: "Battery low" }, + appname: { type: "s", data: "upower" }, + category: { type: "s", data: "device" }, + id: { type: "i", data: 11 }, + timestamp: { type: "x", data: 1_000_000_000 }, + urgency: { type: "s", data: "NORMAL" }, + }, + ]], +}); + +Deno.test("dunst history with dates from the boot time", () => { + const boot = new Date(2026, 9, 28, 8, 0, 0).getTime(); + const [ci, battery] = parseDunstHistory(DUNST, boot); + assertEquals(ci, { + id: "12", + app: "GitHub", + summary: "CI finished", + body: "Your build passed & deployed", + urgency: "critical", + category: undefined, + time: boot + 1_500_000, + }); + assertEquals(battery.category, "device"); + assertEquals(battery.urgency, "normal"); + assertEquals(parseDunstHistory(DUNST)[0].time, undefined); +}); + +Deno.test("mako history: D-Bus JSON (< 1.9), plain JSON and text (1.9+)", () => { + const old = JSON.stringify({ + type: "aa{sv}", + data: [[{ + "app-name": { type: "s", data: "Firefox" }, + "app-icon": { type: "s", data: "firefox" }, + category: { type: "s", data: "" }, + "desktop-entry": { type: "s", data: "firefox" }, + summary: { type: "s", data: "Download complete" }, + body: { type: "s", data: "report.pdf" }, + id: { type: "u", data: 7 }, + urgency: { type: "y", data: 1 }, + actions: { type: "a{ss}", data: {} }, + }]], + }); + assertEquals(parseMakoHistory(old), [{ + id: "7", + app: "Firefox", + summary: "Download complete", + body: "report.pdf", + urgency: "normal", + category: undefined, + }]); + const plain = JSON.stringify([{ id: 9, app_name: "kitty", summary: "Done", body: "make finished", urgency: 2 }]); + assertEquals(parseMakoHistory(plain)[0].urgency, "critical"); + assertEquals(parseMakoHistory(plain)[0].app, "kitty"); + const text = parseMakoHistory(`Notification 12: Build finished + App name: kitty + Urgency: normal +Notification 11: Meeting in 5 minutes + App name: Thunderbird + Category: im.received + Urgency: critical +`); + assertEquals(text.map((n) => [n.id, n.app, n.summary, n.urgency]), [ + ["12", "kitty", "Build finished", "normal"], + ["11", "Thunderbird", "Meeting in 5 minutes", "critical"], + ]); + assertEquals(parseMakoHistory(""), []); + assertEquals(parseMakoModes("default\ndo-not-disturb\n"), ["default", "do-not-disturb"]); +}); + +Deno.test("text helpers", () => { + assertEquals( + plainText("Your build passed & deployed
Next"), + "Your build passed & deployed\nNext", + ); + assertEquals(escapeMarkdown("a *b* `c`"), "a \\*b\\* \\`c\\`"); + const now = new Date(2026, 9, 28, 12, 0, 0).getTime(); + assertEquals(ago(now - 20_000, now), "just now"); + assertEquals(ago(now - 5 * 60_000, now), "5 min ago"); + assertEquals(ago(now - 3 * 3600_000, now), "3 h ago"); + assertEquals(ago(now - 30 * 3600_000, now), "yesterday"); + assertEquals(parseBootTime("2026-10-28 08:12:03\n"), new Date(2026, 9, 28, 8, 12, 3).getTime()); + assertEquals(parseBootTime("garbage"), undefined); +}); diff --git a/extensions/system-monitor/backslash.toml b/extensions/system-monitor/backslash.toml new file mode 100644 index 0000000..b976dcd --- /dev/null +++ b/extensions/system-monitor/backslash.toml @@ -0,0 +1,50 @@ +[extension] +id = "system-monitor" +title = "System Monitor" +description = "Live CPU, memory, disk, network, GPU and battery usage, and the processes behind it" +version = "1.0.0" +author = "Backslash" +icon = "bs-activity" +license = "MIT" + +# Deno opens /proc and /sys only with --allow-all, so their files are read in one batch by +# `grep -sH` (fixed paths under /proc and /sys only). ps names the owner and command of the top +# processes, df measures mounted filesystems, kill stops a process you picked, nvidia-smi is +# used only when an NVIDIA driver is installed. +[permissions] +read = ["/etc/os-release"] +run = ["grep", "ps", "df", "kill", "nvidia-smi"] + +[[commands]] +name = "monitor" +title = "System Monitor" +description = "CPU, memory, disks, network and uptime, refreshed every 2 seconds" +icon = "bs-activity" +keywords = [ + "cpu", + "ram", + "memory", + "mémoire", + "processeur", + "disk", + "disque", + "network", + "réseau", + "temperature", + "température", + "gpu", + "battery", + "batterie", + "uptime", + "stats", + "moniteur", + "système", + "htop", +] + +[[commands]] +name = "processes" +title = "Top Processes" +description = "The busiest processes by CPU or memory; quit or kill one" +icon = "bs-cpu" +keywords = ["process", "processus", "task manager", "gestionnaire des tâches", "kill", "tuer", "top", "htop", "pid"] diff --git a/extensions/system-monitor/commands/monitor.ts b/extensions/system-monitor/commands/monitor.ts new file mode 100644 index 0000000..63c0188 --- /dev/null +++ b/extensions/system-monitor/commands/monitor.ts @@ -0,0 +1,378 @@ +import { + action, + type Color, + copy, + Icons, + type Item, + list, + meta, + type Metadata, + open, + updateCommandMetadata, +} from "@backslash/api"; +import { + bytes, + cpuTemperature, + cpuUsage, + type Disk, + duration, + type Gpu, + interfaceKind, + isPhysicalInterface, + levelColor, + type LoadAvg, + type Memory, + type NetCounters, + percent, + push, + rate, + sparkline, + temperatureColor, +} from "../lib.ts"; +import { processView } from "../processes.ts"; +import { type Battery, disks, osName, sleep, type Snapshot, snapshot } from "../system.ts"; + +const INTERVAL = 2000; + +const topByCpu = action("Show Top Processes by CPU", () => processView("Processes by CPU", "cpu"), { + icon: Icons.activity, + shortcut: "p", +}); +const topByMemory = action("Show Top Processes by Memory", () => processView("Processes by Memory", "memory"), { + icon: Icons.layers, + shortcut: "p", +}); + +const history = (values: number[], max = 1) => values.length > 1 ? `\`${sparkline(values, max)}\`` : ""; + +function cpuItem( + usage: number, + cores: number[], + info: Snapshot["info"], + load: LoadAvg, + celsius: number | undefined, + past: number[], +): Item { + const color = levelColor(usage); + const threads = cores.length || info.threads; + const ghz = info.mhz ? `${(info.mhz / 1000).toFixed(2)} GHz` : undefined; + const summary = `CPU ${percent(usage)} · load ${load.one.toFixed(2)}${celsius ? ` · ${Math.round(celsius)} °C` : ""}`; + return { + id: "cpu", + title: "CPU", + subtitle: info.model, + icon: { source: Icons.cpu, tint: color }, + section: "Overview", + keywords: ["processor", "processeur", "load", "charge", "temperature", "température", info.model], + accessories: [ + ...(celsius + ? [{ text: `${Math.round(celsius)} °C`, icon: Icons.thermometer, color: temperatureColor(celsius) }] + : []), + { text: percent(usage), color, tooltip: `${threads} threads` }, + ], + detail: { + stats: [ + { label: "Usage", value: percent(usage), color }, + { label: "Load (1 min)", value: load.one.toFixed(2), color: load.one > threads ? "orange" : undefined }, + celsius + ? { label: "Temperature", value: `${Math.round(celsius)} °C`, color: temperatureColor(celsius) } + : { label: "Frequency", value: ghz ?? "–" }, + ], + markdown: past.length > 1 ? `**Last minute** ${history(past)}` : undefined, + metadata: [ + meta.text("Model", info.model, { icon: Icons.cpu }), + meta.text("Cores", info.cores ? `${info.cores} cores · ${threads} threads` : `${threads} threads`), + ...(ghz + ? [meta.text("Frequency", info.maxMhz ? `${ghz} (max ${(info.maxMhz / 1000).toFixed(2)} GHz)` : ghz, { + icon: Icons.bolt, + })] + : []), + ...(celsius + ? [meta.text("Temperature", `${celsius.toFixed(1)} °C`, { + icon: Icons.thermometer, + color: temperatureColor(celsius), + })] + : []), + meta.text("Load average", `${load.one.toFixed(2)} · ${load.five.toFixed(2)} · ${load.fifteen.toFixed(2)}`, { + icon: Icons.activity, + }), + meta.text("Processes", `${load.running} running of ${load.processes}`), + meta.separator(), + ...cores.map((c, i) => meta.progress(`Core ${i}`, c)), + ], + }, + actions: [topByCpu, topByMemory, copy(summary, "Copy Summary", { section: "Copy" })], + }; +} + +function memoryItem(m: Memory, past: number[]): Item { + const ratio = m.total ? m.used / m.total : 0; + const swap = m.swapTotal ? m.swapUsed / m.swapTotal : 0; + const color = levelColor(ratio); + const summary = `RAM ${bytes(m.used)} of ${bytes(m.total)} (${percent(ratio)})`; + return { + id: "memory", + title: "Memory", + subtitle: `${bytes(m.used)} of ${bytes(m.total)}`, + icon: { source: Icons.layers, tint: color }, + section: "Overview", + keywords: ["ram", "memory", "mémoire", "swap", "mem"], + accessories: [ + ...(m.swapTotal && m.swapUsed > 0 ? [{ text: `swap ${percent(swap)}`, color: levelColor(swap) }] : []), + { text: percent(ratio), color }, + ], + detail: { + stats: [ + { label: "Used", value: bytes(m.used), color }, + { label: "Available", value: bytes(m.available) }, + { label: "Swap", value: m.swapTotal ? bytes(m.swapUsed) : "None" }, + ], + markdown: past.length > 1 ? `**Last minute** ${history(past)}` : undefined, + metadata: [ + meta.progress("RAM", ratio, `${bytes(m.used)} / ${bytes(m.total)} · ${percent(ratio)}`), + ...(m.swapTotal + ? [meta.progress("Swap", swap, `${bytes(m.swapUsed)} / ${bytes(m.swapTotal)} · ${percent(swap)}`)] + : [meta.text("Swap", "No swap configured", { color: "gray" })]), + meta.separator(), + meta.text("Total", bytes(m.total), { icon: Icons.layers }), + meta.text("Cache", bytes(m.cached), { icon: Icons.archive }), + meta.text("Buffers", bytes(m.buffers)), + ], + }, + actions: [topByMemory, topByCpu, copy(summary, "Copy Summary", { section: "Copy" })], + }; +} + +function gpuItem(g: Gpu, i: number): Item { + const usage = g.usage ?? 0; + const vram = g.memoryTotal ? (g.memoryUsed ?? 0) / g.memoryTotal : undefined; + return { + id: `gpu-${i}`, + title: "GPU", + subtitle: g.name, + icon: { source: Icons.monitor, tint: levelColor(usage) }, + section: "Overview", + keywords: ["gpu", "graphics", "carte graphique", "vram", g.name], + accessories: [ + ...(g.celsius + ? [{ text: `${Math.round(g.celsius)} °C`, icon: Icons.thermometer, color: temperatureColor(g.celsius) }] + : []), + { text: percent(usage), color: levelColor(usage) }, + ], + detail: { + stats: [ + { label: "Usage", value: percent(usage), color: levelColor(usage) }, + ...(g.memoryUsed !== undefined ? [{ label: "VRAM", value: bytes(g.memoryUsed) }] : []), + ...(g.celsius + ? [{ label: "Temperature", value: `${Math.round(g.celsius)} °C`, color: temperatureColor(g.celsius) }] + : []), + ], + metadata: [ + meta.text("Model", g.name, { icon: Icons.monitor }), + meta.progress("Usage", usage), + ...(vram !== undefined + ? [meta.progress("Video memory", vram, `${bytes(g.memoryUsed ?? 0)} / ${bytes(g.memoryTotal ?? 0)}`)] + : []), + ], + }, + actions: [copy(`${g.name}: ${percent(usage)}`, "Copy Summary", { section: "Copy" })], + }; +} + +function batteryItem(b: Battery): Item { + const ratio = b.capacity / 100; + const charging = b.status === "Charging"; + const color: Color = charging ? "green" : ratio < 0.15 ? "red" : ratio < 0.3 ? "orange" : "green"; + return { + id: `battery-${b.name}`, + title: "Battery", + subtitle: b.status === "Not charging" ? "Plugged in, not charging" : b.status, + icon: { source: charging ? Icons.batteryCharging : Icons.battery, tint: color }, + section: "Overview", + keywords: ["battery", "batterie", "power", "charge"], + accessories: [{ text: `${b.capacity} %`, color }], + detail: { + stats: [ + { label: "Charge", value: `${b.capacity} %`, color }, + { label: "Status", value: b.status }, + ...(b.power ? [{ label: charging ? "Charging at" : "Drawing", value: `${b.power.toFixed(1)} W` }] : []), + ], + metadata: [meta.progress("Charge", ratio), meta.text("Device", b.name, { icon: Icons.battery })], + }, + }; +} + +function diskItem(d: Disk): Item { + const ratio = d.size ? d.used / d.size : 0; + const color = levelColor(ratio); + const name = d.mount === "/" ? "System" : d.mount.split("/").pop() || d.mount; + return { + id: `disk-${d.mount}`, + title: name, + subtitle: `${bytes(d.available)} free of ${bytes(d.size)}`, + icon: { source: Icons.drive, tint: color }, + section: "Disks", + keywords: ["disk", "disque", "storage", "stockage", "space", "espace", d.mount, d.device, d.fstype], + accessories: [{ tag: d.mount, color: "gray" }, { text: percent(ratio), color }], + detail: { + stats: [ + { label: "Used", value: bytes(d.used), color }, + { label: "Free", value: bytes(d.available) }, + { label: "Size", value: bytes(d.size) }, + ], + metadata: [ + meta.progress("Usage", ratio, `${percent(ratio)} used`), + meta.separator(), + meta.text("Mounted on", d.mount, { icon: Icons.folder }), + meta.text("Device", d.device, { icon: Icons.drive }), + ...(d.fstype ? [meta.tags("File system", [d.fstype])] : []), + ], + }, + actions: [ + open(d.mount, "Open in File Manager", { icon: Icons.folder }), + copy(d.mount, "Copy Mount Point", { section: "Copy" }), + copy(d.device, "Copy Device", { section: "Copy" }), + ], + }; +} + +interface Throughput { + name: string; + down: number; + up: number; + totals: NetCounters; + past: number[]; +} + +const NET_ICONS = { wifi: Icons.wifi, ethernet: Icons.plug, vpn: Icons.vpn, mobile: Icons.globe, other: Icons.globe }; +const NET_KINDS = { + wifi: "Wi-Fi", + ethernet: "Ethernet", + vpn: "VPN / tunnel", + mobile: "Mobile broadband", + other: "Network", +}; + +function netItem(n: Throughput): Item { + const kind = interfaceKind(n.name); + const busy = n.down + n.up > 64 * 1024; + return { + id: `net-${n.name}`, + title: n.name, + subtitle: `↓ ${rate(n.down)} ↑ ${rate(n.up)}`, + icon: { source: NET_ICONS[kind], tint: busy ? "blue" : "gray" }, + section: "Network", + keywords: ["network", "réseau", "bandwidth", "débit", "download", "upload", NET_KINDS[kind]], + accessories: [{ tag: NET_KINDS[kind], color: "gray" }], + detail: { + stats: [ + { label: "Download", value: rate(n.down), color: "blue" }, + { label: "Upload", value: rate(n.up), color: "purple" }, + ], + markdown: n.past.length > 1 ? `**Download, last minute** ${history(n.past, 0)}` : undefined, + metadata: [ + meta.text("Interface", n.name, { icon: NET_ICONS[kind] }), + meta.text("Type", NET_KINDS[kind]), + meta.separator(), + meta.text("Received since boot", bytes(n.totals.rx), { icon: Icons.arrowDown }), + meta.text("Sent since boot", bytes(n.totals.tx), { icon: Icons.arrowUp }), + ], + }, + actions: [copy(`${n.name}: ↓ ${rate(n.down)} ↑ ${rate(n.up)}`, "Copy Throughput", { section: "Copy" })], + }; +} + +function uptimeItem(seconds: number, host: string, os: string, kernel: string, load: LoadAvg): Item { + const booted = new Date(Date.now() - seconds * 1000); + const lines: Metadata[] = [ + meta.text("Booted", booted.toLocaleString(), { icon: Icons.power }), + ...(host ? [meta.text("Host name", host, { icon: Icons.home })] : []), + ...(os ? [meta.text("System", os, { icon: Icons.monitor })] : []), + ...(kernel ? [meta.text("Kernel", kernel, { icon: Icons.code })] : []), + meta.text("Processes", load.processes), + ]; + return { + id: "uptime", + title: "Uptime", + subtitle: duration(seconds), + icon: { source: Icons.clock, tint: "teal" }, + section: "Overview", + keywords: ["uptime", "boot", "démarrage", "kernel", "noyau", "host"], + accessories: [{ text: `since ${booted.toLocaleDateString([], { day: "numeric", month: "short" })}` }], + detail: { + stats: [{ label: "Up for", value: duration(seconds) }, { label: "Processes", value: load.processes }], + metadata: lines, + }, + actions: [ + copy( + [`Up ${duration(seconds)}`, os, kernel && `Linux ${kernel}`, host].filter(Boolean).join(" · "), + "Copy System Info", + { section: "Copy" }, + ), + ], + }; +} + +let status = "Measuring…"; + +export default list({ + placeholder: "Filter CPU, memory, disks, network…", + empty: { title: "Nothing to show", description: "/proc could not be read", icon: Icons.activity }, + actions: [topByCpu, topByMemory], + status: () => status, + async *items({ signal }) { + const os = await osName(); + let prev = await snapshot(); + let prevTime = performance.now(); + let cpuPast: number[] = []; + let memPast: number[] = []; + const netPast = new Map(); + let subtitle = ""; + // A short first sample so the numbers are real from the first frame. + await sleep(400, signal); + while (!signal.aborted) { + const now = await snapshot(); + const { cpu, memory: mem, load, net } = now; + const mounts = await disks(now.mounts); + const prevCpu = prev.cpu; + const prevNet = prev.net; + const seconds = Math.max(0.001, (performance.now() - prevTime) / 1000); + const usage = cpuUsage(prevCpu.total, cpu.total); + const cores = cpu.cores.map((c, i) => cpuUsage(prevCpu.cores[i] ?? c, c)); + const ram = mem.total ? mem.used / mem.total : 0; + cpuPast = push(cpuPast, usage); + memPast = push(memPast, ram); + + const throughput: Throughput[] = []; + for (const [name, c] of net) { + if (!isPhysicalInterface(name) || c.rx + c.tx === 0) continue; + const p = prevNet.get(name) ?? c; + const down = Math.max(0, (c.rx - p.rx) / seconds); + const past = push(netPast.get(name) ?? [], down); + netPast.set(name, past); + throughput.push({ name, down, up: Math.max(0, (c.tx - p.tx) / seconds), totals: c, past }); + } + + status = `CPU ${percent(usage)} · RAM ${percent(ram)} · ${bytes(mem.used)} used · updated every 2 s`; + const next = `CPU ${percent(usage)} · RAM ${percent(ram)}`; + if (next !== subtitle) { + subtitle = next; + updateCommandMetadata({ subtitle }).catch(() => {}); + } + + yield [ + cpuItem(usage, cores, now.info, load, cpuTemperature(now.sensors), cpuPast), + memoryItem(mem, memPast), + ...now.gpus.map(gpuItem), + ...now.batteries.map(batteryItem), + uptimeItem(now.uptime, now.host, os, now.kernel, load), + ...mounts.map(diskItem), + ...throughput.sort((a, b) => b.totals.rx - a.totals.rx).map(netItem), + ]; + + prev = now; + prevTime = performance.now(); + await sleep(INTERVAL, signal); + } + }, +}); diff --git a/extensions/system-monitor/commands/processes.ts b/extensions/system-monitor/commands/processes.ts new file mode 100644 index 0000000..d75014e --- /dev/null +++ b/extensions/system-monitor/commands/processes.ts @@ -0,0 +1,3 @@ +import { processView } from "../processes.ts"; + +export default processView(); diff --git a/extensions/system-monitor/lib.ts b/extensions/system-monitor/lib.ts new file mode 100644 index 0000000..e150015 --- /dev/null +++ b/extensions/system-monitor/lib.ts @@ -0,0 +1,433 @@ +// Parsing /proc, /sys and df output, and formatting numbers. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Files + +/** + * `grep -sHa "" `: the content of each file, the way the extension reads /proc and /sys + * (Deno only opens them with --allow-all). Paths given to it never contain a colon. + */ +export function parseGrep(text: string): Map { + const out = new Map(); + for (const line of text.split("\n")) { + const i = line.indexOf(":"); + if (i <= 0) continue; + const path = line.slice(0, i); + const lines = out.get(path) ?? []; + lines.push(line.slice(i + 1)); + out.set(path, lines); + } + return new Map([...out].map(([path, lines]) => [path, lines.join("\n")])); +} + +// --------------------------------------------------------------------------- +// CPU + +/** Jiffies of one line of /proc/stat. */ +export interface CpuTimes { + idle: number; + total: number; +} + +/** `/proc/stat`: the aggregate `cpu` line and one per core. */ +export function parseCpuStat(text: string): { total: CpuTimes; cores: CpuTimes[] } { + let total: CpuTimes = { idle: 0, total: 0 }; + const cores: CpuTimes[] = []; + for (const line of text.split("\n")) { + const m = line.match(/^cpu(\d*)\s+(.*)$/); + if (!m) continue; + // user nice system idle iowait irq softirq steal guest guest_nice + const n = m[2].trim().split(/\s+/).map(Number); + // guest time is already counted in user and nice. + const sum = n.slice(0, 8).reduce((a, b) => a + (b || 0), 0); + const times = { idle: (n[3] || 0) + (n[4] || 0), total: sum }; + if (m[1] === "") total = times; + else cores[Number(m[1])] = times; + } + return { total, cores: cores.filter(Boolean) }; +} + +/** Busy fraction between two samples, from 0 to 1. */ +export function cpuUsage(prev: CpuTimes, cur: CpuTimes): number { + const total = cur.total - prev.total; + if (total <= 0) return 0; + return clamp01(1 - (cur.idle - prev.idle) / total); +} + +export interface CpuInfo { + model: string; + threads: number; + /** Physical cores, when the kernel says. */ + cores?: number; + /** Average current frequency in MHz, from `cpu MHz` lines. */ + mhz?: number; +} + +/** `/proc/cpuinfo` on x86 and ARM. */ +export function parseCpuinfo(text: string): CpuInfo { + const blocks = text.split(/\n\s*\n/).filter((b) => b.trim()); + const field = (block: string, key: string) => block.match(new RegExp(`^${key}\\s*:\\s*(.*)$`, "m"))?.[1]?.trim(); + const processors = blocks.filter((b) => field(b, "processor") !== undefined); + const mhz = processors.map((b) => Number(field(b, "cpu MHz"))).filter((n) => n > 0); + const model = processors.map((b) => field(b, "model name") ?? field(b, "Processor")).find(Boolean) ?? + blocks.map((b) => field(b, "Model") ?? field(b, "Hardware")).find(Boolean) ?? "Unknown processor"; + const physical = new Set(processors.map((b) => `${field(b, "physical id")}:${field(b, "core id")}`)); + const hasCoreIds = processors.some((b) => field(b, "core id") !== undefined); + return { + model: model.replace(/\s+/g, " ").replace(/\((R|TM)\)/gi, "").replace(/ CPU /, " ").trim(), + threads: processors.length, + cores: hasCoreIds ? physical.size : undefined, + mhz: mhz.length ? mhz.reduce((a, b) => a + b, 0) / mhz.length : undefined, + }; +} + +export interface LoadAvg { + one: number; + five: number; + fifteen: number; + running: number; + processes: number; +} + +/** `/proc/loadavg`: `0.07 0.09 0.04 1/109 3352`. */ +export function parseLoadavg(text: string): LoadAvg { + const [one, five, fifteen, tasks] = text.trim().split(/\s+/); + const [running, processes] = (tasks ?? "0/0").split("/").map(Number); + return { one: Number(one), five: Number(five), fifteen: Number(fifteen), running, processes }; +} + +export interface Sensor { + /** hwmon `name` or thermal zone `type`. */ + chip: string; + label?: string; + /** °C */ + celsius: number; +} + +const CPU_CHIPS = ["coretemp", "k10temp", "zenpower", "cpu_thermal", "cpu-thermal", "x86_pkg_temp", "soc_thermal"]; + +/** The sensor that best describes the CPU: package temperature first, else the hottest CPU sensor. */ +export function cpuTemperature(sensors: Sensor[]): number | undefined { + const cpu = sensors.filter((s) => CPU_CHIPS.includes(s.chip) && s.celsius > 0 && s.celsius < 150); + const pkg = cpu.find((s) => /package|tctl|tdie/i.test(s.label ?? "")); + if (pkg) return pkg.celsius; + if (cpu.length) return Math.max(...cpu.map((s) => s.celsius)); + // Laptops without a CPU driver often expose only ACPI zones. + const acpi = sensors.filter((s) => /acpitz|thermal_zone/.test(s.chip) && s.celsius > 0 && s.celsius < 150); + return acpi.length ? Math.max(...acpi.map((s) => s.celsius)) : undefined; +} + +// --------------------------------------------------------------------------- +// Memory + +export interface Memory { + total: number; + used: number; + available: number; + cached: number; + buffers: number; + swapTotal: number; + swapUsed: number; +} + +/** `/proc/meminfo`, in bytes. Used = total − available, as `free` and GNOME System Monitor do. */ +export function parseMeminfo(text: string): Memory { + const kb: Record = {}; + for (const line of text.split("\n")) { + const m = line.match(/^([\w()]+):\s+(\d+)/); + if (m) kb[m[1]] = Number(m[2]); + } + const total = (kb.MemTotal ?? 0) * 1024; + const available = (kb.MemAvailable ?? (kb.MemFree ?? 0) + (kb.Cached ?? 0) + (kb.Buffers ?? 0)) * 1024; + const swapTotal = (kb.SwapTotal ?? 0) * 1024; + return { + total, + available, + used: Math.max(0, total - available), + cached: ((kb.Cached ?? 0) + (kb.SReclaimable ?? 0)) * 1024, + buffers: (kb.Buffers ?? 0) * 1024, + swapTotal, + swapUsed: Math.max(0, swapTotal - (kb.SwapFree ?? 0) * 1024), + }; +} + +// --------------------------------------------------------------------------- +// Disks + +export interface Disk { + device: string; + mount: string; + fstype: string; + size: number; + used: number; + available: number; +} + +/** `df -P -k`: 1024-byte blocks; the mount point may contain spaces. */ +export function parseDf(text: string): Omit[] { + return text.split("\n").slice(1).flatMap((line) => { + const m = line.match(/^(.+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+%\s+(\/.*)$/); + if (!m) return []; + return [{ + device: m[1], + size: Number(m[2]) * 1024, + used: Number(m[3]) * 1024, + available: Number(m[4]) * 1024, + mount: m[5], + }]; + }); +} + +/** `/proc/mounts`: mount point → filesystem type (octal escapes decoded). */ +export function parseMounts(text: string): Map { + const unescape = (s: string) => s.replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8))); + const out = new Map(); + for (const line of text.split("\n")) { + const [device, mount, fstype] = line.split(" "); + if (device && mount && fstype) out.set(unescape(mount), { device: unescape(device), fstype }); + } + return out; +} + +const PSEUDO_FS = new Set([ + "tmpfs", + "devtmpfs", + "overlay", + "squashfs", + "proc", + "sysfs", + "cgroup", + "cgroup2", + "devpts", + "efivarfs", + "fuse.portal", + "fuse.gvfsd-fuse", + "nsfs", + "ramfs", + "autofs", + "tracefs", + "debugfs", +]); + +/** Real storage only: no tmpfs, snaps, containers, /boot/efi duplicates of one device. */ +export function realDisks(df: Omit[], mounts: Map): Disk[] { + const seen = new Set(); + const disks: Disk[] = []; + for (const d of df) { + const fstype = mounts.get(d.mount)?.fstype ?? ""; + if (PSEUDO_FS.has(fstype) || d.size === 0) continue; + if (/^\/(proc|sys|dev|run|snap|var\/lib\/(docker|containers|snapd))(\/|$)/.test(d.mount)) { + // Removable drives live in /run/media; keep them. + if (!d.mount.startsWith("/run/media/")) continue; + } + if (!d.device.startsWith("/") && !["zfs", "btrfs", "nfs", "nfs4", "cifs", "fuse.sshfs"].includes(fstype)) continue; + // Btrfs subvolumes and bind mounts show the same device several times. + const key = `${d.device}:${d.size}`; + if (seen.has(key)) continue; + seen.add(key); + disks.push({ ...d, fstype }); + } + return disks.sort((a, b) => (a.mount === "/" ? -1 : b.mount === "/" ? 1 : a.mount.localeCompare(b.mount))); +} + +// --------------------------------------------------------------------------- +// Network + +export interface NetCounters { + rx: number; + tx: number; +} + +/** `/proc/net/dev`: received and sent bytes per interface. */ +export function parseNetDev(text: string): Map { + const out = new Map(); + for (const line of text.split("\n").slice(2)) { + const m = line.match(/^\s*([^:]+):\s*(.*)$/); + if (!m) continue; + const n = m[2].trim().split(/\s+/).map(Number); + out.set(m[1].trim(), { rx: n[0] || 0, tx: n[8] || 0 }); + } + return out; +} + +/** Interfaces worth showing: no loopback nor container plumbing. */ +export const isPhysicalInterface = (name: string) => !/^(lo|ifb\d|veth|docker|br-|virbr|vnet|tap|dummy)/.test(name); + +/** Friendly kind of an interface from its name. */ +export function interfaceKind(name: string): "wifi" | "ethernet" | "vpn" | "mobile" | "other" { + if (/^(wl|wlan|wifi)/.test(name)) return "wifi"; + if (/^(en|eth)/.test(name)) return "ethernet"; + if (/^(tun|wg|tailscale|ppp|proton|nordlynx)/.test(name)) return "vpn"; + if (/^(wwan|usb|rmnet)/.test(name)) return "mobile"; + return "other"; +} + +// --------------------------------------------------------------------------- +// GPU + +export interface Gpu { + name: string; + /** 0 to 1 */ + usage?: number; + memoryUsed?: number; + memoryTotal?: number; + celsius?: number; +} + +/** `nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader,nounits`. */ +export function parseNvidiaSmi(text: string): Gpu[] { + return text.split("\n").filter((l) => l.trim()).map((line) => { + const [name, util, used, total, temp] = line.split(",").map((s) => s.trim()); + const num = (s: string | undefined) => (s && /^\d+(\.\d+)?$/.test(s) ? Number(s) : undefined); + const mib = (s: string | undefined) => (num(s) === undefined ? undefined : num(s)! * 1024 * 1024); + return { + name, + usage: num(util) === undefined ? undefined : num(util)! / 100, + memoryUsed: mib(used), + memoryTotal: mib(total), + celsius: num(temp), + }; + }); +} + +/** PCI vendor id from `/sys/class/drm/cardN/device/vendor`. */ +export const gpuVendor = (id: string) => + ({ "0x1002": "AMD", "0x10de": "NVIDIA", "0x8086": "Intel" } as Record)[id.trim()] ?? "GPU"; + +// --------------------------------------------------------------------------- +// Processes + +export interface ProcStat { + pid: number; + name: string; + state: string; + ppid: number; + /** utime + stime, in clock ticks. */ + ticks: number; + /** Resident pages. */ + rssPages: number; + threads: number; + /** Clock ticks after boot when it started. */ + startTicks: number; +} + +/** `/proc//stat`; the name is in parentheses and may contain spaces and parentheses. */ +export function parseProcStat(text: string): ProcStat | undefined { + const open = text.indexOf("("); + const close = text.lastIndexOf(")"); + if (open < 0 || close < open) return undefined; + const rest = text.slice(close + 2).trim().split(/\s+/); + // rest[0] is field 3 (state); utime and stime are fields 14 and 15, rss is 24. + return { + pid: Number(text.slice(0, open).trim()), + name: text.slice(open + 1, close), + state: rest[0], + ppid: Number(rest[1]), + ticks: Number(rest[11]) + Number(rest[12]), + threads: Number(rest[17]), + startTicks: Number(rest[19]), + rssPages: Number(rest[21]), + }; +} + +export const PROCESS_STATES: Record = { + R: ["Running", "green"], + S: ["Sleeping", "gray"], + D: ["Waiting for I/O", "orange"], + Z: ["Zombie", "red"], + T: ["Stopped", "yellow"], + t: ["Traced", "yellow"], + I: ["Idle", "gray"], + X: ["Dead", "red"], +}; + +export interface PsRow { + pid: number; + uid: number; + /** Resident memory in bytes. */ + rss: number; + user: string; +} + +/** `ps -e -o pid=,uid=,rss=,user:32=`. */ +export function parsePs(text: string): PsRow[] { + return text.split("\n").flatMap((line) => { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/); + return m ? [{ pid: Number(m[1]), uid: Number(m[2]), rss: Number(m[3]) * 1024, user: m[4] }] : []; + }); +} + +/** `ps -o pid=,args= -p …`: pid → command line. */ +export function parsePsArgs(text: string): Map { + const out = new Map(); + for (const line of text.split("\n")) { + const m = line.trim().match(/^(\d+)\s+(.*)$/); + if (m) out.set(Number(m[1]), m[2]); + } + return out; +} + +/** + * CPU of a process between two samples, the way `top` shows it: 100 % is one + * core. `elapsedTotal` is the delta of the aggregate `cpu` line of /proc/stat. + */ +export function processCpu(prevTicks: number, curTicks: number, elapsedTotal: number, cpus: number): number { + if (elapsedTotal <= 0 || cpus <= 0) return 0; + return Math.max(0, (curTicks - prevTicks) / (elapsedTotal / cpus)); +} + +// --------------------------------------------------------------------------- +// Formatting + +export const clamp01 = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0); + +/** 1536 → "1.5 kB", in powers of 1024 like file managers on Linux label them "kB, MB, GB". */ +export function bytes(n: number, digits = 1): string { + if (!Number.isFinite(n) || n < 0) return "–"; + const units = ["B", "kB", "MB", "GB", "TB", "PB"]; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${i === 0 ? Math.round(n) : n.toFixed(n >= 100 ? 0 : digits)} ${units[i]}`; +} + +export const rate = (bytesPerSecond: number) => `${bytes(bytesPerSecond)}/s`; + +export const percent = (ratio: number) => `${Math.round(clamp01(ratio) * 100)} %`; + +/** 93784 → "1 d 2 h 3 min". */ +export function duration(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d) return `${d} d ${h} h ${m} min`; + if (h) return `${h} h ${m} min`; + if (m) return `${m} min`; + return `${Math.floor(seconds)} s`; +} + +/** Green, then yellow from 60 %, orange from 75 %, red from 90 %. */ +export const levelColor = (ratio: number): Color => + ratio >= 0.9 ? "red" : ratio >= 0.75 ? "orange" : ratio >= 0.6 ? "yellow" : "green"; + +export const temperatureColor = (celsius: number): Color => + celsius >= 90 ? "red" : celsius >= 80 ? "orange" : celsius >= 70 ? "yellow" : "green"; + +const BLOCKS = "▁▂▃▄▅▆▇█"; + +/** Values from 0 to `max` as a row of block characters. */ +export function sparkline(values: number[], max = 1): string { + if (!values.length) return ""; + const top = max > 0 ? max : Math.max(...values, 1); + return values.map((v) => BLOCKS[Math.round(clamp01(v / top) * (BLOCKS.length - 1))]).join(""); +} + +/** Keeps the last `size` values. */ +export function push(history: number[], value: number, size = 30): number[] { + const next = [...history, value]; + return next.length > size ? next.slice(next.length - size) : next; +} diff --git a/extensions/system-monitor/lib_test.ts b/extensions/system-monitor/lib_test.ts new file mode 100644 index 0000000..527c613 --- /dev/null +++ b/extensions/system-monitor/lib_test.ts @@ -0,0 +1,242 @@ +import { assertAlmostEquals, assertEquals } from "@std/assert"; +import { + bytes, + cpuTemperature, + cpuUsage, + duration, + interfaceKind, + isPhysicalInterface, + levelColor, + parseCpuinfo, + parseCpuStat, + parseDf, + parseGrep, + parseLoadavg, + parseMeminfo, + parseMounts, + parseNetDev, + parseNvidiaSmi, + parseProcStat, + parsePs, + parsePsArgs, + processCpu, + push, + realDisks, + sparkline, +} from "./lib.ts"; + +Deno.test("grep -H output is split back into files", () => { + const files = parseGrep( + "/proc/uptime:2112.17 8289.42\n/proc/loadavg:0.44 0.23 0.11 1/105 5099\n" + + "/proc/meminfo:MemTotal: 16477352 kB\n/proc/meminfo:MemFree: 15780280 kB\n", + ); + assertEquals(files.get("/proc/uptime"), "2112.17 8289.42"); + assertEquals(files.get("/proc/meminfo"), "MemTotal: 16477352 kB\nMemFree: 15780280 kB"); + assertEquals(files.has("/sys/class/hwmon/hwmon0/name"), false); +}); + +const STAT_A = `cpu 4643 0 2717 659833 2391 0 219 161 0 0 +cpu0 1045 0 683 165211 409 0 85 39 0 0 +cpu1 1164 0 733 164836 661 0 57 38 0 0 +intr 454653 0 0 0 +ctxt 1234`; +const STAT_B = `cpu 4743 0 2767 659983 2391 0 219 161 0 0 +cpu0 1145 0 683 165211 409 0 85 39 0 0 +cpu1 1164 0 783 164986 661 0 57 38 0 0`; + +Deno.test("cpu usage from two /proc/stat samples", () => { + const a = parseCpuStat(STAT_A); + const b = parseCpuStat(STAT_B); + assertEquals(a.cores.length, 2); + assertEquals(a.total.idle, 659833 + 2391); + // 150 busy jiffies out of 300. + assertAlmostEquals(cpuUsage(a.total, b.total), 0.5); + assertEquals(cpuUsage(a.cores[0], b.cores[0]), 1); + assertAlmostEquals(cpuUsage(a.cores[1], b.cores[1]), 0.25); + assertEquals(cpuUsage(a.total, a.total), 0); +}); + +Deno.test("cpuinfo on x86 and ARM", () => { + const x86 = parseCpuinfo(`processor : 0 +vendor_id : GenuineIntel +model name : 13th Gen Intel(R) Core(TM) i7-1360P +cpu MHz : 1200.000 +physical id : 0 +core id : 0 + +processor : 1 +model name : 13th Gen Intel(R) Core(TM) i7-1360P +cpu MHz : 2400.000 +physical id : 0 +core id : 0 + +processor : 2 +model name : 13th Gen Intel(R) Core(TM) i7-1360P +cpu MHz : 1800.000 +physical id : 0 +core id : 4 +`); + assertEquals(x86, { model: "13th Gen Intel Core i7-1360P", threads: 3, cores: 2, mhz: 1800 }); + const arm = parseCpuinfo(`processor : 0 +BogoMIPS : 108.00 +CPU implementer : 0x41 + +processor : 1 +BogoMIPS : 108.00 + +Hardware : BCM2835 +Model : Raspberry Pi 4 Model B Rev 1.4 +`); + assertEquals(arm.model, "Raspberry Pi 4 Model B Rev 1.4"); + assertEquals(arm.threads, 2); + assertEquals(arm.mhz, undefined); +}); + +Deno.test("loadavg", () => { + assertEquals(parseLoadavg("0.07 0.09 0.04 1/109 3352\n"), { + one: 0.07, + five: 0.09, + fifteen: 0.04, + running: 1, + processes: 109, + }); +}); + +Deno.test("temperature: package sensor first, then CPU sensors, then ACPI", () => { + assertEquals( + cpuTemperature([ + { chip: "nvme", label: "Composite", celsius: 41 }, + { chip: "coretemp", label: "Core 0", celsius: 55 }, + { chip: "coretemp", label: "Package id 0", celsius: 58 }, + ]), + 58, + ); + assertEquals( + cpuTemperature([{ chip: "k10temp", label: "Tccd1", celsius: 62 }, { chip: "k10temp", celsius: 66 }]), + 66, + ); + assertEquals(cpuTemperature([{ chip: "acpitz", celsius: 47 }, { chip: "amdgpu", label: "edge", celsius: 50 }]), 47); + assertEquals(cpuTemperature([{ chip: "nvme", celsius: 40 }]), undefined); +}); + +Deno.test("meminfo: used is total minus available", () => { + const m = parseMeminfo(`MemTotal: 16477352 kB +MemFree: 1780280 kB +MemAvailable: 9848740 kB +Buffers: 13916 kB +Cached: 6273544 kB +SwapCached: 0 kB +SwapTotal: 8388604 kB +SwapFree: 8126460 kB +SReclaimable: 312000 kB +`); + assertEquals(m.total, 16477352 * 1024); + assertEquals(m.used, (16477352 - 9848740) * 1024); + assertEquals(m.swapUsed, (8388604 - 8126460) * 1024); + assertEquals(m.cached, (6273544 + 312000) * 1024); +}); + +const DF = `Filesystem 1024-blocks Used Available Capacity Mounted on +/dev/nvme0n1p2 490617784 211430520 254191496 46% / +tmpfs 8133576 91124 8042452 2% /dev/shm +/dev/nvme0n1p1 1046508 71960 974548 7% /boot/efi +/dev/nvme0n1p2 490617784 211430520 254191496 46% /home +/dev/sda1 60014592 12345678 47668914 21% /run/media/ada/USB STICK +/dev/loop3 75776 75776 0 100% /snap/core22/1612 +portal 0 0 0 0% /run/user/1000/doc`; + +const MOUNTS = `/dev/nvme0n1p2 / btrfs rw,relatime 0 0 +tmpfs /dev/shm tmpfs rw 0 0 +/dev/nvme0n1p1 /boot/efi vfat rw 0 0 +/dev/nvme0n1p2 /home btrfs rw,relatime,subvol=/home 0 0 +/dev/sda1 /run/media/ada/USB\\040STICK exfat rw 0 0 +/dev/loop3 /snap/core22/1612 squashfs ro 0 0 +portal /run/user/1000/doc fuse.portal rw 0 0`; + +Deno.test("df and mounts: real disks only, subvolumes once", () => { + const df = parseDf(DF); + assertEquals(df.length, 7); + assertEquals(df[4].mount, "/run/media/ada/USB STICK"); + assertEquals(df[0].size, 490617784 * 1024); + const mounts = parseMounts(MOUNTS); + assertEquals(mounts.get("/run/media/ada/USB STICK"), { device: "/dev/sda1", fstype: "exfat" }); + const disks = realDisks(df, mounts); + assertEquals(disks.map((d) => [d.mount, d.fstype]), [ + ["/", "btrfs"], + ["/boot/efi", "vfat"], + ["/run/media/ada/USB STICK", "exfat"], + ]); +}); + +Deno.test("network counters and interface kinds", () => { + const net = parseNetDev(`Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 23199805 5880 0 0 0 0 0 0 23199805 5880 0 0 0 0 0 0 + eth0: 26803741 25442 0 6 0 0 0 0 163159080 25101 0 0 0 0 0 0 +wlp2s0:123 4 0 0 0 0 0 0 456 7 0 0 0 0 0 0`); + assertEquals(net.get("eth0"), { rx: 26803741, tx: 163159080 }); + assertEquals(net.get("wlp2s0"), { rx: 123, tx: 456 }); + assertEquals([...net.keys()].filter(isPhysicalInterface), ["eth0", "wlp2s0"]); + assertEquals(["wlp2s0", "enp3s0", "wg0", "tailscale0", "wwan0", "zt0"].map(interfaceKind), [ + "wifi", + "ethernet", + "vpn", + "vpn", + "mobile", + "other", + ]); +}); + +Deno.test("nvidia-smi csv", () => { + const [gpu] = parseNvidiaSmi("NVIDIA GeForce RTX 3070, 37, 1520, 8192, 54\n"); + assertEquals(gpu.name, "NVIDIA GeForce RTX 3070"); + assertEquals(gpu.usage, 0.37); + assertEquals(gpu.memoryTotal, 8192 * 1024 * 1024); + assertEquals(gpu.celsius, 54); + assertEquals(parseNvidiaSmi("Tesla T4, [N/A], 0, 15360, [N/A]")[0].usage, undefined); +}); + +Deno.test("process stat with spaces and parentheses in the name", () => { + const p = parseProcStat( + "4242 (Web Content (1)) S 4100 4100 4100 0 -1 4194560 2285 37584 0 784 1500 250 19 86 20 0 31 0 28123 26398720 1581 18446744073709551615", + ); + assertEquals(p, { + pid: 4242, + name: "Web Content (1)", + state: "S", + ppid: 4100, + ticks: 1750, + threads: 31, + startTicks: 28123, + rssPages: 1581, + }); + assertEquals(parseProcStat("garbage"), undefined); + // 50 ticks over 2 s on 4 cores (800 total jiffies → 200 per core): 25 % of a core. + assertEquals(processCpu(1000, 1050, 800, 4), 0.25 * 100 / 100); +}); + +Deno.test("ps output", () => { + assertEquals(parsePs(" 1 0 6572 root\n 4242 1000 204800 ada\n"), [ + { pid: 1, uid: 0, rss: 6572 * 1024, user: "root" }, + { pid: 4242, uid: 1000, rss: 204800 * 1024, user: "ada" }, + ]); + const args = parsePsArgs(" 1 /sbin/init splash\n 2 [kthreadd]\n"); + assertEquals(args.get(1), "/sbin/init splash"); + assertEquals(args.get(2), "[kthreadd]"); +}); + +Deno.test("formatting", () => { + assertEquals([bytes(512), bytes(1536), bytes(16477352 * 1024), bytes(5 * 1024 ** 4)], [ + "512 B", + "1.5 kB", + "15.7 GB", + "5.0 TB", + ]); + assertEquals(duration(93784), "1 d 2 h 3 min"); + assertEquals(duration(3700), "1 h 1 min"); + assertEquals(duration(42), "42 s"); + assertEquals([0.2, 0.65, 0.8, 0.95].map(levelColor), ["green", "yellow", "orange", "red"]); + assertEquals(sparkline([0, 0.5, 1]), "▁▅█"); + assertEquals(sparkline([10, 20], 0), "▅█"); + assertEquals(push([1, 2, 3], 4, 3), [2, 3, 4]); +}); diff --git a/extensions/system-monitor/processes.ts b/extensions/system-monitor/processes.ts new file mode 100644 index 0000000..d887116 --- /dev/null +++ b/extensions/system-monitor/processes.ts @@ -0,0 +1,194 @@ +// The live list of top processes, shared by the "Top Processes" command and the monitor's actions. + +import { action, copy, exec, Icons, type Item, list, type ListSpec, meta, refresh, toast } from "@backslash/api"; +import { + bytes, + levelColor, + parseCpuStat, + parseMeminfo, + parseProcStat, + parsePs, + parsePsArgs, + PROCESS_STATES, + processCpu, + type ProcStat, + type PsRow, +} from "./lib.ts"; +import { capture, readFiles, sleep } from "./system.ts"; + +export type Sort = "cpu" | "memory"; + +interface Proc extends ProcStat { + cpu: number; + rss: number; + command: string; + user: string; + own: boolean; +} + +const TICKS_PER_SECOND = 100; // USER_HZ, 100 on every mainstream Linux build +const SHOWN = 40; + +interface Sample { + ps: Map; + stats: Map; + cpu: ReturnType; + memTotal: number; + memUsed: number; + uptime: number; +} + +/** Owners and memory from ps, CPU ticks from /proc//stat (finer than ps' seconds). */ +async function sample(): Promise { + const ps = new Map(parsePs(await capture("ps", ["-e", "-o", "pid=,uid=,rss=,user:32="])).map((r) => [r.pid, r])); + const files = await readFiles([ + "/proc/stat", + "/proc/meminfo", + "/proc/uptime", + ...[...ps.keys()].map((pid) => `/proc/${pid}/stat`), + ]); + const stats = new Map(); + for (const pid of ps.keys()) { + const s = parseProcStat(files.get(`/proc/${pid}/stat`) ?? ""); + if (s) stats.set(pid, s); + } + const mem = parseMeminfo(files.get("/proc/meminfo") ?? ""); + return { + ps, + stats, + cpu: parseCpuStat(files.get("/proc/stat") ?? ""), + memTotal: mem.total, + memUsed: mem.used, + uptime: Number((files.get("/proc/uptime") ?? "").split(" ")[0]) || 0, + }; +} + +/** Command lines, cached: they don't change during a process' life (exec aside). */ +const commands = new Map(); + +async function commandLines(pids: number[]): Promise { + const missing = pids.filter((pid) => !commands.has(pid)); + if (!missing.length) return; + const args = parsePsArgs(await capture("ps", ["-o", "pid=,args=", "-p", missing.join(",")])); + for (const pid of missing) commands.set(pid, args.get(pid) ?? ""); +} + +function signal(p: Proc, sig: "TERM" | "KILL") { + return async () => { + await exec("kill", [`-${sig}`, String(p.pid)]); + await toast(sig === "TERM" ? `Asked ${p.name} to quit` : `Killed ${p.name}`, { style: "success" }); + await sleep(400); + return refresh(); + }; +} + +function item(p: Proc, sort: Sort, memTotal: number, boot: number): Item { + const memRatio = memTotal ? p.rss / memTotal : 0; + const [state, stateColor] = PROCESS_STATES[p.state] ?? [p.state, "gray"]; + const started = new Date((boot + p.startTicks / TICKS_PER_SECOND) * 1000); + const cpuText = `${p.cpu.toFixed(p.cpu >= 10 ? 0 : 1)} %`; + const command = p.command.length > 400 ? `${p.command.slice(0, 400)}…` : p.command; + return { + id: String(p.pid), + title: p.name, + subtitle: /^\[.*\]$/.test(p.command) ? "Kernel thread" : command.slice(0, 80), + icon: sort === "cpu" + ? { source: Icons.cpu, tint: levelColor(p.cpu / 100) } + : { source: Icons.layers, tint: levelColor(memRatio * 4) }, + keywords: [String(p.pid), p.user, p.command], + accessories: [ + ...(p.own ? [] : [{ tag: p.user, color: "gray" as const }]), + { text: cpuText, icon: Icons.cpu, tooltip: "CPU (100 % = one core)" }, + { text: bytes(p.rss), icon: Icons.layers, tooltip: "Memory (resident)" }, + ], + detail: { + stats: [ + { label: "CPU", value: cpuText, color: levelColor(p.cpu / 100) }, + { label: "Memory", value: bytes(p.rss) }, + { label: "Threads", value: p.threads }, + ], + markdown: `\`${command.replaceAll("`", "'")}\``, + metadata: [ + meta.progress("Share of RAM", memRatio, `${(memRatio * 100).toFixed(1)} %`), + meta.text("State", state, { icon: Icons.dottedCircle, color: stateColor }), + meta.separator(), + meta.text("PID", p.pid, { icon: Icons.hash }), + meta.text("Parent PID", p.ppid), + meta.person("User", p.user), + meta.text("Started", started.toLocaleString(), { icon: Icons.clock }), + ], + }, + actions: [ + action("Quit Process", signal(p, "TERM"), { + icon: Icons.stop, + destructive: true, + confirm: `Ask ${p.name} (pid ${p.pid}) to quit?`, + shortcut: "k", + section: "Process", + }), + action("Force Kill", signal(p, "KILL"), { + icon: Icons.cross, + destructive: true, + confirm: `Kill ${p.name} right away? Unsaved work is lost.`, + shortcut: "k", + section: "Process", + }), + copy(String(p.pid), "Copy PID", { section: "Copy", shortcut: "c" }), + copy(p.command, "Copy Command Line", { section: "Copy" }), + copy(p.name, "Copy Name", { section: "Copy" }), + ], + }; +} + +/** A live list of the busiest processes. `title` keeps the remembered sort separate per entry point. */ +export function processView(title = "Top Processes", defaultSort: Sort = "cpu"): ListSpec { + let summary = ""; + return list({ + title, + placeholder: "Filter processes by name, command, user or PID…", + filter: { + options: [{ value: "cpu", title: "By CPU" }, { value: "memory", title: "By Memory" }], + default: defaultSort, + }, + empty: { title: "No processes", description: "/proc could not be read", icon: Icons.activity }, + status: () => summary, + async *items({ filter, signal }) { + const sort: Sort = filter === "memory" ? "memory" : "cpu"; + let prev = await sample(); + await sleep(500, signal); + while (!signal.aborted) { + const cur = await sample(); + // ps lists itself and grep; the deno process tells which uid is ours. + const me = cur.ps.get(Deno.pid)?.uid; + const elapsed = cur.cpu.total.total - prev.cpu.total.total; + const cpus = cur.cpu.cores.length || 1; + const procs = [...cur.stats.values()].filter((p) => p.pid !== Deno.pid && p.ppid !== Deno.pid).map((p) => ({ + ...p, + cpu: processCpu(prev.stats.get(p.pid)?.ticks ?? p.ticks, p.ticks, elapsed, cpus), + rss: cur.ps.get(p.pid)?.rss ?? 0, + })); + procs.sort((a, b) => sort === "cpu" ? b.cpu - a.cpu || b.rss - a.rss : b.rss - a.rss || b.cpu - a.cpu); + const shown = procs.slice(0, SHOWN); + await commandLines(shown.map((p) => p.pid)); + for (const pid of commands.keys()) if (!cur.ps.has(pid)) commands.delete(pid); + const top: Proc[] = shown.map((p) => { + const row = cur.ps.get(p.pid); + return { + ...p, + command: commands.get(p.pid) || `[${p.name}]`, + user: row?.user ?? "?", + own: me !== undefined && row?.uid === me, + }; + }); + const boot = Date.now() / 1000 - cur.uptime; + const totalCpu = procs.reduce((a, p) => a + p.cpu, 0) / cpus; + summary = `${procs.length} processes · CPU ${Math.round(Math.min(100, totalCpu))} % · RAM ${ + Math.round((cur.memUsed / (cur.memTotal || 1)) * 100) + } % · top ${top.length} by ${sort === "cpu" ? "CPU" : "memory"}`; + yield top.map((p) => item(p, sort, cur.memTotal, boot)); + prev = cur; + await sleep(2000, signal); + } + }, + }); +} diff --git a/extensions/system-monitor/system.ts b/extensions/system-monitor/system.ts new file mode 100644 index 0000000..eb8f578 --- /dev/null +++ b/extensions/system-monitor/system.ts @@ -0,0 +1,222 @@ +// Reads /proc and /sys. Deno refuses to open them without --allow-all, so they are read in one batch +// by `grep -sH`, which prefixes each line with its file. Missing files are simply absent: every reader +// returns what it could and never throws. + +import { + type CpuInfo, + type CpuTimes, + type Disk, + type Gpu, + gpuVendor, + type LoadAvg, + type Memory, + type NetCounters, + parseCpuinfo, + parseCpuStat, + parseDf, + parseGrep, + parseLoadavg, + parseMeminfo, + parseMounts, + parseNetDev, + parseNvidiaSmi, + realDisks, + type Sensor, +} from "./lib.ts"; + +/** Runs a program and returns its stdout whatever the exit code; "" when it is missing. */ +export async function capture(program: string, args: string[]): Promise { + try { + const { stdout } = await new Deno.Command(program, { args, stdin: "null", stdout: "piped", stderr: "null" }) + .output(); + return new TextDecoder().decode(stdout); + } catch { + return ""; + } +} + +/** Reads many small text files in one spawn. */ +export async function readFiles(paths: string[]): Promise> { + if (!paths.length) return new Map(); + return parseGrep(await capture("grep", ["-sHa", "", "--", ...paths])); +} + +/** Sleeps, waking up early when the view goes away. */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); +} + +const range = (n: number) => [...Array(n).keys()]; + +// Candidate paths: sysfs directories can't be listed, so the usual names are tried. +const HWMON = range(16).flatMap((i) => [ + `/sys/class/hwmon/hwmon${i}/name`, + ...range(10).flatMap(( + t, + ) => [`/sys/class/hwmon/hwmon${i}/temp${t}_input`, `/sys/class/hwmon/hwmon${i}/temp${t}_label`]), +]); +const THERMAL = range(16).flatMap((i) => [ + `/sys/class/thermal/thermal_zone${i}/type`, + `/sys/class/thermal/thermal_zone${i}/temp`, +]); +const BATTERIES = ["BAT0", "BAT1", "BAT2", "BAT", "CMB0", "CMB1", "battery", "macsmc-battery"]; +const POWER = BATTERIES.flatMap((b) => + ["type", "capacity", "status", "power_now"].map((f) => `/sys/class/power_supply/${b}/${f}`) +); +const DRM = range(4).flatMap((i) => + ["gpu_busy_percent", "mem_info_vram_used", "mem_info_vram_total", "vendor"].map((f) => + `/sys/class/drm/card${i}/device/${f}` + ) +); + +export interface Battery { + name: string; + capacity: number; + status: string; + /** Watts, when reported. */ + power?: number; +} + +export interface Snapshot { + cpu: { total: CpuTimes; cores: CpuTimes[] }; + memory: Memory; + load: LoadAvg; + net: Map; + uptime: number; + info: CpuInfo & { maxMhz?: number }; + sensors: Sensor[]; + batteries: Battery[]; + gpus: Gpu[]; + mounts: string; + host: string; + kernel: string; +} + +let threads = 0; + +/** Everything the monitor shows, from one grep. */ +export async function snapshot(): Promise { + const freq = range(threads).map((i) => `/sys/devices/system/cpu/cpu${i}/cpufreq/scaling_cur_freq`); + const files = await readFiles([ + "/proc/stat", + "/proc/meminfo", + "/proc/loadavg", + "/proc/net/dev", + "/proc/uptime", + "/proc/cpuinfo", + "/proc/mounts", + "/proc/sys/kernel/hostname", + "/proc/sys/kernel/osrelease", + "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", + ...freq, + ...HWMON, + ...THERMAL, + ...POWER, + ...DRM, + ]); + const get = (path: string) => files.get(path) ?? ""; + const cpu = parseCpuStat(get("/proc/stat")); + threads = cpu.cores.length; + + const info = parseCpuinfo(get("/proc/cpuinfo")); + const khz = freq.map((p) => Number(get(p))).filter((f) => f > 0); + const max = Number(get("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")); + + const sensors: Sensor[] = []; + for (let i = 0; i < 16; i++) { + const dir = `/sys/class/hwmon/hwmon${i}`; + const chip = get(`${dir}/name`).trim(); + if (!chip) continue; + for (let t = 0; t < 10; t++) { + const milli = Number(get(`${dir}/temp${t}_input`)); + if (milli) sensors.push({ chip, label: get(`${dir}/temp${t}_label`).trim() || undefined, celsius: milli / 1000 }); + } + } + for (let i = 0; i < 16; i++) { + const milli = Number(get(`/sys/class/thermal/thermal_zone${i}/temp`)); + if (milli) sensors.push({ chip: get(`/sys/class/thermal/thermal_zone${i}/type`).trim(), celsius: milli / 1000 }); + } + + const batteries: Battery[] = BATTERIES.flatMap((name) => { + const dir = `/sys/class/power_supply/${name}`; + const capacity = get(`${dir}/capacity`).trim(); + if (get(`${dir}/type`).trim() !== "Battery" || !capacity) return []; + const microwatts = Number(get(`${dir}/power_now`)); + return [{ + name, + capacity: Number(capacity), + status: get(`${dir}/status`).trim() || "Unknown", + power: microwatts > 0 ? microwatts / 1e6 : undefined, + }]; + }); + + // AMD cards report their load in sysfs; their temperature is the amdgpu hwmon sensor. + const amdTemp = sensors.find((s) => s.chip === "amdgpu")?.celsius; + const gpus: Gpu[] = range(4).flatMap((i) => { + const dev = `/sys/class/drm/card${i}/device`; + const busy = get(`${dev}/gpu_busy_percent`).trim(); + if (!busy) return []; + return [{ + name: `${gpuVendor(get(`${dev}/vendor`))} graphics (card${i})`, + usage: Number(busy) / 100, + memoryUsed: Number(get(`${dev}/mem_info_vram_used`)) || undefined, + memoryTotal: Number(get(`${dev}/mem_info_vram_total`)) || undefined, + celsius: amdTemp, + }]; + }); + + return { + cpu, + memory: parseMeminfo(get("/proc/meminfo")), + load: parseLoadavg(get("/proc/loadavg")), + net: parseNetDev(get("/proc/net/dev")), + uptime: Number(get("/proc/uptime").split(" ")[0]) || 0, + info: { + ...info, + threads: info.threads || cpu.cores.length, + mhz: khz.length ? khz.reduce((a, b) => a + b, 0) / khz.length / 1000 : info.mhz, + maxMhz: max > 0 ? max / 1000 : undefined, + }, + sensors, + batteries, + gpus: [...gpus, ...await nvidia()], + mounts: get("/proc/mounts"), + host: get("/proc/sys/kernel/hostname").trim(), + kernel: get("/proc/sys/kernel/osrelease").trim(), + }; +} + +/** CPU times alone, for the processes view. */ +export async function cpuTimes() { + return parseCpuStat((await readFiles(["/proc/stat"])).get("/proc/stat") ?? ""); +} + +/** Mounted filesystems with their usage. */ +export async function disks(mounts: string): Promise { + return realDisks(parseDf(await capture("df", ["-P", "-k"])), parseMounts(mounts)); +} + +let nvidiaMissing = false; + +async function nvidia(): Promise { + if (nvidiaMissing) return []; + const out = await capture("nvidia-smi", [ + "--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu", + "--format=csv,noheader,nounits", + ]); + const gpus = parseNvidiaSmi(out).filter((g) => g.usage !== undefined); + // Not installed, or no NVIDIA card: don't try again every 2 s. + if (!gpus.length) nvidiaMissing = true; + return gpus; +} + +export async function osName(): Promise { + const text = await Deno.readTextFile("/etc/os-release").catch(() => ""); + return text.match(/^PRETTY_NAME="?([^"\n]*)"?/m)?.[1] ?? ""; +} diff --git a/extensions/updates/backslash.toml b/extensions/updates/backslash.toml new file mode 100644 index 0000000..df36d21 --- /dev/null +++ b/extensions/updates/backslash.toml @@ -0,0 +1,68 @@ +[extension] +id = "updates" +title = "System Updates" +description = "Available updates from APT, DNF, Pacman, Zypper, Flatpak, Snap and firmware, applied in a terminal" +version = "1.0.0" +author = "Backslash" +icon = "bs-download" +license = "MIT" + +# The package managers only list updates here (no root needed); installing them runs in a +# terminal you can watch, where sudo asks for your password. rpm gives the installed versions +# on DNF systems, /etc/os-release tells openSUSE Tumbleweed apart (it updates with dist-upgrade). +[permissions] +env = ["XDG_CURRENT_DESKTOP"] +read = ["/etc/os-release"] +run = [ + "apt", + "dnf", + "rpm", + "pacman", + "checkupdates", + "zypper", + "flatpak", + "snap", + "fwupdmgr", + "x-terminal-emulator", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "foot", + "kitty", + "alacritty", + "wezterm", + "ghostty", + "xterm", +] + +[[commands]] +name = "updates" +title = "System Updates" +description = "What can be updated, by source, with versions; update everything in a terminal" +icon = "bs-download" +keywords = [ + "updates", + "update", + "upgrade", + "mises à jour", + "mise à jour", + "apt", + "dnf", + "pacman", + "zypper", + "flatpak", + "snap", + "firmware", + "fwupd", + "packages", + "paquets", + "security", + "sécurité", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use your desktop's terminal, else the first one installed (Konsole, GNOME Console, kitty, foot…)" diff --git a/extensions/updates/commands/updates.ts b/extensions/updates/commands/updates.ts new file mode 100644 index 0000000..a8ea5b3 --- /dev/null +++ b/extensions/updates/commands/updates.ts @@ -0,0 +1,380 @@ +import { + type Action, + action, + close, + type Color, + type Context, + copy, + Icons, + type Item, + list, + meta, + refresh, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { + FLATPAK_COLUMNS, + type Manager, + MANAGERS, + ORDER, + parseApt, + parseCheckupdates, + parseDnf, + parseDnfSecurity, + parseFlatpak, + parseFwupd, + parseRpmVersions, + parseSnap, + parseZypper, + plural, + size, + TERMINAL_ARGS, + terminalOrder, + terminalScript, + type Update, + updateAllCommand, + updateOneCommand, +} from "../lib.ts"; + +interface Prefs { + terminal?: string; +} + +interface Output { + code: number; + stdout: string; + stderr: string; +} + +/** Runs a program, whatever its exit code (dnf says 100 and checkupdates 2 for "updates" and "none"). */ +async function run(program: string, args: string[], signal: AbortSignal, timeout = 180_000): Promise { + const { code, stdout, stderr } = await new Deno.Command(program, { + args, + stdin: "null", + stdout: "piped", + stderr: "piped", + env: { LC_ALL: "C.UTF-8" }, + signal: AbortSignal.any([signal, AbortSignal.timeout(timeout)]), + }).output(); + const text = (b: Uint8Array) => new TextDecoder().decode(b); + return { code, stdout: text(stdout), stderr: text(stderr).trim() }; +} + +class CheckError extends Error { + constructor(message: string, readonly hint?: string) { + super(message); + } +} + +function fail(o: Output, program: string): never { + throw new CheckError( + o.stderr.split("\n").filter((l) => !/^WARNING: apt does not/.test(l)).join("\n") || + `${program} exited with ${o.code}`, + ); +} + +const CHECKS: Record Promise> = { + async apt(signal) { + const o = await run("apt", ["list", "--upgradable"], signal); + if (o.code !== 0) fail(o, "apt"); + return parseApt(o.stdout); + }, + async dnf(signal) { + const o = await run("dnf", ["check-update", "-q"], signal); + if (o.code !== 0 && o.code !== 100) fail(o, "dnf"); + const updates = parseDnf(o.stdout); + if (!updates.length) return updates; + const [security, installed] = await Promise.all([ + run("dnf", ["updateinfo", "list", "--security", "-q"], signal).then((s) => parseDnfSecurity(s.stdout)) + .catch(() => new Set()), + run( + "rpm", + ["-q", "--qf", "%{NAME}.%{ARCH} %{VERSION}-%{RELEASE}\\n", ...new Set(updates.map((u) => u.name))], + signal, + ) + .then((r) => parseRpmVersions(r.stdout)).catch(() => new Map()), + ]); + return updates.map((u) => ({ + ...u, + security: security.has(u.name), + from: installed.get(`${u.name}.${u.arch}`) ?? installed.get(`${u.name}.noarch`), + })); + }, + async pacman(signal) { + let o: Output; + try { + o = await run("checkupdates", [], signal); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + throw new CheckError( + "checkupdates is not installed", + "Install **pacman-contrib** (`sudo pacman -S pacman-contrib`): it checks for updates without touching the system's package database.", + ); + } + throw err; + } + if (o.code === 2) return []; + if (o.code !== 0) fail(o, "checkupdates"); + return parseCheckupdates(o.stdout); + }, + async zypper(signal) { + const o = await run("zypper", ["-n", "-q", "list-updates"], signal); + if (o.code !== 0) fail(o, "zypper"); + return parseZypper(o.stdout); + }, + async flatpak(signal) { + const [updates, installed] = await Promise.all([ + run("flatpak", ["remote-ls", "--updates", `--columns=${FLATPAK_COLUMNS}`], signal), + run("flatpak", ["list", "--columns=application,version,branch"], signal), + ]); + if (updates.code !== 0) fail(updates, "flatpak"); + return parseFlatpak(updates.stdout, installed.stdout); + }, + async snap(signal) { + const [updates, installed] = await Promise.all([ + run("snap", ["refresh", "--list"], signal), + run("snap", ["list"], signal), + ]); + if (updates.code !== 0) fail(updates, "snap"); + return parseSnap(updates.stdout, installed.stdout); + }, + async fwupd(signal) { + const o = await run("fwupdmgr", ["get-updates", "--json"], signal, 60_000); + // 2: nothing to update, or no device fwupd can update. + if (o.code === 2 || /No updat|no updatable/i.test(o.stderr)) return []; + if (o.code !== 0) fail(o, "fwupdmgr"); + return parseFwupd(o.stdout); + }, +}; + +/** Managers installed here: their program starts. */ +async function installed(): Promise { + const found = await Promise.all(ORDER.map(async (m) => { + try { + await run(MANAGERS[m].program, ["--version"], AbortSignal.timeout(10_000), 10_000); + return true; + } catch { + return false; + } + })); + return ORDER.filter((_, i) => found[i]); +} + +/** Starts the commands in a terminal window that outlives Backslash. */ +function openTerminal(script: string, preferred?: string) { + const desktop = Deno.env.get("XDG_CURRENT_DESKTOP") ?? ""; + const argv = ["sh", "-c", script]; + for (const term of preferred ? [preferred, ...terminalOrder(desktop)] : terminalOrder(desktop)) { + try { + const args = (TERMINAL_ARGS[term] ?? ((a: string[]) => ["-e", ...a]))(argv); + new Deno.Command(term, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable)) throw err; + } + } + throw new Error("No terminal found: install one (GNOME Console, Konsole, kitty…) or set it in the preferences"); +} + +async function isRolling(): Promise { + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + return /^ID="?opensuse-tumbleweed"?$/m.test(os) || /^ID="?opensuse-slowroll"?$/m.test(os); +} + +function inTerminal(title: string, steps: { title: string; command: string }[], prefs: Prefs, options = {}): Action { + return action(title, () => { + openTerminal(terminalScript(steps), prefs.terminal || undefined); + return close(steps.length === 1 ? `Running ${steps[0].command}` : `Running ${steps.length} updates`); + }, { icon: Icons.terminal, ...options }); +} + +const ICONS: Record = { + system: [Icons.package, "blue"], + apps: [Icons.box, "purple"], + firmware: [Icons.cpu, "orange"], +}; + +function updateItem(u: Update, updateAll: Action, prefs: Prefs): Item { + const info = MANAGERS[u.manager]; + const [glyph, tint] = ICONS[info.kind]; + const one = updateOneCommand(u); + const title = u.label ?? u.name; + return { + id: `${u.manager}:${u.name}:${u.arch ?? ""}`, + title, + subtitle: u.from ? `${u.from} → ${u.to}` : `→ ${u.to}`, + icon: u.security ? { source: Icons.shield, tint: "red" } : { source: glyph, tint }, + section: info.title, + keywords: [u.name, u.manager, info.title, u.source ?? "", ...(u.security ? ["security", "sécurité", "cve"] : [])], + accessories: [ + ...(u.security ? [{ tag: "Security", color: "red" as const }] : []), + ...(u.size ? [{ text: size(u.size), tooltip: "Download size" }] : []), + ...(u.source ? [{ text: u.source, tooltip: "Source" }] : []), + ], + detail: { + stats: [ + { label: "Installed", value: u.from ?? "–" }, + { label: "Available", value: u.to, color: u.security ? "red" : "green" }, + ], + markdown: u.summary, + metadata: [ + meta.text(u.manager === "fwupd" ? "Device" : "Package", u.name, { icon: glyph }), + ...(u.label ? [meta.text("Name", u.label)] : []), + meta.tags("Kind", [ + { text: info.title, color: tint }, + ...(u.security ? [{ text: "Security fix", color: "red" as const }] : []), + ]), + ...(u.source + ? [meta.text(u.manager === "fwupd" ? "Vendor" : u.manager === "snap" ? "Publisher" : "Source", u.source, { + icon: Icons.globe, + })] + : []), + ...(u.arch ? [meta.text(u.manager === "flatpak" ? "Branch" : "Architecture", u.arch)] : []), + ...(u.size ? [meta.text("Download", size(u.size), { icon: Icons.download })] : []), + meta.separator(), + meta.text("Command", one ?? updateAllCommand(u.manager), { icon: Icons.terminal }), + ], + }, + actions: [ + updateAll, + ...(one ? [inTerminal(`Update ${title} Only`, [{ title, command: one }], prefs, { section: "Update" })] : []), + copy(u.name, u.manager === "fwupd" ? "Copy Device Name" : "Copy Package Name", { section: "Copy" }), + copy(one ?? updateAllCommand(u.manager), "Copy Update Command", { section: "Copy" }), + action("Check Again", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ], + }; +} + +function errorItem(m: Manager, err: unknown): Item { + const info = MANAGERS[m]; + const message = err instanceof Error ? err.message : String(err); + const hint = err instanceof CheckError ? err.hint : undefined; + return { + id: `${m}:error`, + title: `${info.title}: could not check`, + subtitle: message.split("\n")[0].slice(0, 120), + icon: { source: Icons.alert, tint: "orange" }, + section: info.title, + detail: { + markdown: [hint, `\`\`\`\n${message.slice(0, 2000)}\n\`\`\``].filter(Boolean).join("\n\n"), + metadata: [meta.text("Manager", info.title, { icon: Icons.package })], + }, + actions: [ + action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + copy(message, "Copy Error", { section: "Copy" }), + ], + }; +} + +function upToDateItem(m: Manager): Item { + const info = MANAGERS[m]; + return { + id: `${m}:ok`, + title: info.title, + subtitle: "Up to date", + icon: { source: Icons.done, tint: "green" }, + section: "Up to Date", + keywords: [m, "up to date", "à jour"], + detail: { + metadata: [ + meta.text("Manager", info.title, { icon: Icons.package }), + meta.text("Status", "Nothing to update", { icon: Icons.check, color: "green" }), + meta.text("Checked", new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), { + icon: Icons.clock, + }), + ], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + }; +} + +export default async (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + const managers = await installed(); + let status = ""; + + if (!managers.length) { + return list({ + empty: { + title: "No package manager found", + description: "Backslash knows APT, DNF, Pacman, Zypper, Flatpak, Snap and fwupd", + icon: Icons.package, + }, + items: [], + }); + } + + return list({ + placeholder: "Filter updates…", + filter: { + options: [ + { value: "all", title: "All" }, + ...managers.map((m) => ({ value: m, title: MANAGERS[m].title })), + ], + }, + empty: { title: "Everything is up to date", icon: Icons.done }, + status: () => status, + async *items({ filter, signal }) { + const wanted = filter && filter !== "all" ? managers.filter((m) => m === filter) : managers; + const rolling = await isRolling(); + const results = new Map(); + const pending = new Map( + wanted.map((m) => [ + m, + CHECKS[m](signal).then( + (u) => [m, u] as const, + (e) => [m, e instanceof Error ? e : new Error(String(e))] as const, + ), + ]), + ); + while (pending.size && !signal.aborted) { + const [m, result] = await Promise.race(pending.values()); + pending.delete(m); + results.set(m, result); + + const found = [...results].flatMap(([, r]) => (Array.isArray(r) ? r : [])); + const security = found.filter((u) => u.security).length; + const toUpdate = wanted.filter((x) => Array.isArray(results.get(x)) && (results.get(x) as Update[]).length); + const steps = toUpdate.map((x) => ({ title: MANAGERS[x].title, command: updateAllCommand(x, rolling) })); + const updateAll = inTerminal("Update All in Terminal", steps, prefs, { shortcut: "u" }); + const perManager = submenu( + "Update Only…", + toUpdate.map((x) => + inTerminal(MANAGERS[x].title, [{ title: MANAGERS[x].title, command: updateAllCommand(x, rolling) }], prefs) + ), + { icon: Icons.filter, section: "Update" }, + ); + + const waiting = [...pending.keys()].map((x) => MANAGERS[x].title); + status = [ + found.length ? plural(found.length) : waiting.length ? "" : "Everything is up to date", + security ? `${security} security` : "", + waiting.length ? `checking ${waiting.join(", ")}…` : "", + ].filter(Boolean).join(" · "); + if (!pending.size && wanted.length === managers.length) { + updateCommandMetadata({ subtitle: found.length ? plural(found.length) : "Up to date" }).catch(() => {}); + } + + const order = (u: Update) => ORDER.indexOf(u.manager) * 2 + (u.security ? 0 : 1); + const items: Item[] = []; + for (const x of wanted) { + const r = results.get(x); + if (r instanceof Error) items.push(errorItem(x, r)); + } + const withActions = (u: Update) => { + const item = updateItem(u, updateAll, prefs); + if (toUpdate.length > 1) item.actions = [item.actions![0], perManager, ...item.actions!.slice(1)]; + return item; + }; + items.push( + ...found.sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name)).map(withActions), + ...wanted.filter((x) => Array.isArray(results.get(x)) && !(results.get(x) as Update[]).length).map( + upToDateItem, + ), + ); + yield items; + } + }, + }); +}; diff --git a/extensions/updates/lib.ts b/extensions/updates/lib.ts new file mode 100644 index 0000000..6bd63f1 --- /dev/null +++ b/extensions/updates/lib.ts @@ -0,0 +1,369 @@ +// Parsing the output of package managers, and building the commands that apply updates. +// Pure, tested in lib_test.ts. + +export type Manager = "apt" | "dnf" | "pacman" | "zypper" | "flatpak" | "snap" | "fwupd"; + +export interface Update { + manager: Manager; + name: string; + /** Installed version, when the manager tells. */ + from?: string; + to: string; + /** Repository, remote, channel or vendor. */ + source?: string; + arch?: string; + security: boolean; + /** Flatpak application id, fwupd device id. */ + id?: string; + /** Display name when it differs from the package name (Flatpak apps, firmware). */ + label?: string; + summary?: string; + /** Download size in bytes, when known. */ + size?: number; +} + +export interface ManagerInfo { + title: string; + /** Programs probed to know whether the manager is installed. */ + program: string; + /** Asks for administrator rights. */ + sudo: boolean; + kind: "system" | "apps" | "firmware"; +} + +export const MANAGERS: Record = { + apt: { title: "APT", program: "apt", sudo: true, kind: "system" }, + dnf: { title: "DNF", program: "dnf", sudo: true, kind: "system" }, + pacman: { title: "Pacman", program: "pacman", sudo: true, kind: "system" }, + zypper: { title: "Zypper", program: "zypper", sudo: true, kind: "system" }, + flatpak: { title: "Flatpak", program: "flatpak", sudo: false, kind: "apps" }, + snap: { title: "Snap", program: "snap", sudo: true, kind: "apps" }, + fwupd: { title: "Firmware", program: "fwupdmgr", sudo: false, kind: "firmware" }, +}; + +export const ORDER: Manager[] = ["apt", "dnf", "pacman", "zypper", "flatpak", "snap", "fwupd"]; + +const lines = (out: string) => out.split("\n").map((l) => l.trimEnd()).filter((l) => l.trim()); + +/** + * `apt list --upgradable`: + * `openssl/jammy-updates,jammy-security 3.0.2-0ubuntu1.15 amd64 [upgradable from: 3.0.2-0ubuntu1.14]`. + */ +export function parseApt(out: string): Update[] { + return lines(out).flatMap((line) => { + const m = line.match(/^([^/\s]+)\/(\S+)\s+(\S+)\s+(\S+)(?:\s+\[upgradable from:\s*([^\]]+)\])?/); + if (!m) return []; + const suites = m[2].split(","); + return [{ + manager: "apt" as const, + name: m[1], + to: m[3], + arch: m[4], + from: m[5]?.trim(), + source: suites[0], + security: suites.some((s) => /-security$|^security$/.test(s)), + }]; + }); +} + +/** + * `dnf check-update` (dnf 4 and 5): `name.arch version repo`, sometimes wrapped after a long name. + * Stops at "Obsoleting Packages". + */ +export function parseDnf(out: string): Update[] { + const updates: Update[] = []; + let carry = ""; + for (const raw of out.split("\n")) { + if (/^Obsoleting/i.test(raw.trim())) break; + const line = (carry ? `${carry} ${raw.trim()}` : raw.trim()).trim(); + carry = ""; + if (!line || /^(Last metadata|Available Upgrades|Upgrades|Updating|Security:|Repositories loaded)/i.test(line)) { + continue; + } + const parts = line.split(/\s+/); + if (parts.length === 1 && /\.\w+$/.test(parts[0])) { + carry = parts[0]; // the version and repo are on the next line + continue; + } + if (parts.length < 3) continue; + const m = parts[0].match(/^(.+)\.([^.]+)$/); + if (!m || !/\d/.test(parts[1])) continue; + updates.push({ manager: "dnf", name: m[1], arch: m[2], to: parts[1], source: parts[2], security: false }); + } + return updates; +} + +/** Name of an RPM from its NEVRA, `kernel-core-6.8.5-301.fc40.x86_64` → `kernel-core`. */ +export function rpmName(nevra: string): string | undefined { + return nevra.match(/^(.+)-[^-]+-[^-]+\.[^.]+$/)?.[1]; +} + +/** + * `dnf updateinfo list --security`: the packages fixed by a security advisory. + * dnf 4: `FEDORA-2024-1a2b3c Important/Sec. openssl-1:3.2.1-2.fc40.x86_64`; + * dnf 5: `FEDORA-2024-1a2b3c security Important openssl-1:3.2.1-2.fc40.x86_64 2024-04-09 01:23:45`. + */ +export function parseDnfSecurity(out: string): Set { + const names = new Set(); + for (const line of lines(out)) { + const nevra = line.trim().split(/\s+/).find((t, i) => + i > 0 && /-\d.*\.\w+$/.test(t) && !/^\d{4}-\d\d-\d\d$/.test(t) + ); + const name = nevra && rpmName(nevra); + if (name) names.add(name); + } + return names; +} + +/** `rpm -q --qf '%{NAME}.%{ARCH} %{VERSION}-%{RELEASE}\n' …`: the newest installed version wins. */ +export function parseRpmVersions(out: string): Map { + const versions = new Map(); + for (const line of lines(out)) { + const [key, version] = line.trim().split(/\s+/); + if (key && version && !/is not installed/.test(line)) versions.set(key, version); + } + return versions; +} + +/** `checkupdates` (pacman-contrib): `linux 6.8.4.arch1-1 -> 6.8.5.arch1-1`. */ +export function parseCheckupdates(out: string): Update[] { + return lines(out).flatMap((line) => { + const m = line.trim().match(/^(\S+)\s+(\S+)\s+->\s+(\S+)/); + return m ? [{ manager: "pacman" as const, name: m[1], from: m[2], to: m[3], security: false }] : []; + }); +} + +/** `zypper -q list-updates`: a table whose header names the columns. */ +export function parseZypper(out: string): Update[] { + const rows = lines(out).filter((l) => l.includes("|")); + const header = rows.findIndex((r) => /\bName\b/.test(r) && /Version/.test(r)); + if (header < 0) return []; + const cols = rows[header].split("|").map((c) => c.trim()); + const at = (name: RegExp) => cols.findIndex((c) => name.test(c)); + const [repo, name, current, available, arch] = [ + at(/^Repository$/), + at(/^Name$/), + at(/^Current Version$/), + at(/^Available Version$/), + at(/^Arch$/), + ]; + return rows.slice(header + 1).flatMap((row) => { + if (/^[-+|\s]+$/.test(row)) return []; + const cells = row.split("|").map((c) => c.trim()); + if (!cells[name]) return []; + return [{ + manager: "zypper" as const, + name: cells[name], + from: current >= 0 ? cells[current] || undefined : undefined, + to: cells[available] ?? "", + source: repo >= 0 ? cells[repo] : undefined, + arch: arch >= 0 ? cells[arch] : undefined, + security: false, + }]; + }); +} + +export const FLATPAK_COLUMNS = "application,name,version,branch,origin"; + +/** + * `flatpak remote-ls --updates --columns=application,name,version,branch,origin` (tab separated), + * with the installed versions from `flatpak list --columns=application,version,branch`. + */ +export function parseFlatpak(out: string, installed = ""): Update[] { + const current = new Map(); + for (const line of lines(installed)) { + const [app, version, branch] = line.split("\t"); + if (app) current.set(`${app}/${branch ?? ""}`, version || branch || ""); + } + return lines(out).flatMap((line) => { + const [app, name, version, branch, origin] = line.split("\t").map((c) => c?.trim()); + if (!app || app === "Application ID") return []; + const runtime = /\.(Platform|Sdk|GL|Locale|Debug|Codecs|ffmpeg|openh264)\b/.test(app) || + /^org\.freedesktop\./.test(app); + return [{ + manager: "flatpak" as const, + name: app, + label: name && name !== app ? name : undefined, + id: app, + from: current.get(`${app}/${branch ?? ""}`) || undefined, + to: version || branch || "latest", + source: origin, + arch: branch, + summary: runtime ? "Runtime shared by Flatpak apps" : undefined, + security: false, + }]; + }); +} + +const SIZE_UNITS: Record = { B: 1, kB: 1e3, KB: 1e3, MB: 1e6, GB: 1e9 }; + +/** `snap refresh --list`: `Name Version Rev Size Publisher Notes`; `snap list` gives the installed versions. */ +export function parseSnap(out: string, installed = ""): Update[] { + const current = new Map(); + for (const line of lines(installed).slice(1)) { + const [name, version] = line.trim().split(/\s+/); + if (name) current.set(name, version); + } + const rows = lines(out); + if (!rows.length || !/^Name\s+Version/.test(rows[0])) return []; + return rows.slice(1).flatMap((line) => { + const [name, version, _rev, size, publisher] = line.trim().split(/\s+/); + if (!name || !version) return []; + const m = size?.match(/^([\d.]+)(B|kB|KB|MB|GB)$/); + return [{ + manager: "snap" as const, + name, + from: current.get(name), + to: version, + source: publisher?.replace(/[✓✪*]$/, ""), + size: m ? Number(m[1]) * SIZE_UNITS[m[2]] : undefined, + security: false, + }]; + }); +} + +const stripHtml = (s: string) => + s.replace(/
  • /g, "\n- ").replace(/<\/p>\s*

    /g, "\n\n").replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").trim(); + +/** `fwupdmgr get-updates --json`. */ +export function parseFwupd(json: string): Update[] { + if (!json.trim().startsWith("{")) return []; + const data = JSON.parse(json) as { + Devices?: { + Name?: string; + DeviceId?: string; + Version?: string; + Vendor?: string; + Releases?: { Version?: string; Summary?: string; Description?: string; Urgency?: string; Size?: number }[]; + }[]; + }; + return (data.Devices ?? []).flatMap((d) => { + const r = d.Releases?.[0]; + if (!r?.Version) return []; + return [{ + manager: "fwupd" as const, + name: d.Name ?? d.DeviceId ?? "Device", + id: d.DeviceId, + from: d.Version, + to: r.Version, + source: d.Vendor, + summary: [r.Summary, r.Description && stripHtml(r.Description)].filter(Boolean).join("\n\n") || undefined, + size: r.Size, + security: r.Urgency === "high" || r.Urgency === "critical", + }]; + }); +} + +/** Single-quotes a word for sh. */ +export const shellQuote = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`); + +/** The command applying every update of one manager. `rolling` is openSUSE Tumbleweed. */ +export function updateAllCommand(m: Manager, rolling = false): string { + switch (m) { + case "apt": + return "sudo apt update && sudo apt upgrade"; + case "dnf": + return "sudo dnf upgrade --refresh"; + case "pacman": + return "sudo pacman -Syu"; + case "zypper": + return rolling ? "sudo zypper dist-upgrade" : "sudo zypper update"; + case "flatpak": + return "flatpak update"; + case "snap": + return "sudo snap refresh"; + case "fwupd": + return "fwupdmgr update"; + } +} + +/** The command updating one package, or undefined where partial upgrades are unsupported (Arch). */ +export function updateOneCommand(u: Update): string | undefined { + const name = shellQuote(u.name); + switch (u.manager) { + case "apt": + return `sudo apt install --only-upgrade ${name}`; + case "dnf": + return `sudo dnf upgrade ${name}`; + case "pacman": + return undefined; + case "zypper": + return `sudo zypper update ${name}`; + case "flatpak": + return `flatpak update ${shellQuote(u.id ?? u.name)}`; + case "snap": + return `sudo snap refresh ${name}`; + case "fwupd": + return u.id ? `fwupdmgr update ${shellQuote(u.id)}` : "fwupdmgr update"; + } +} + +/** A shell script running the commands one after the other, then waiting for Enter. */ +export function terminalScript(commands: { title: string; command: string }[]): string { + const steps = commands.map(({ title, command }) => + `printf '\\n\\033[1m==> %s\\033[0m\\n' ${shellQuote(title)}; ${command} || failed=1` + ); + return [ + "failed=0", + ...steps, + `if [ "$failed" = 0 ]; then printf '\\n\\033[32mAll done.\\033[0m '; else printf '\\n\\033[31mSome updates failed.\\033[0m '; fi`, + "printf 'Press Enter to close…'; read -r _", + ].join("\n"); +} + +/** How each terminal runs a command. */ +export const TERMINAL_ARGS: Record string[]> = { + "x-terminal-emulator": (argv) => ["-e", ...argv], + ptyxis: (argv) => ["--", ...argv], + kgx: (argv) => ["--", ...argv], + "gnome-terminal": (argv) => ["--", ...argv], + konsole: (argv) => ["-e", ...argv], + "xfce4-terminal": (argv) => ["-x", ...argv], + foot: (argv) => argv, + kitty: (argv) => argv, + alacritty: (argv) => ["-e", ...argv], + wezterm: (argv) => ["start", "--", ...argv], + ghostty: (argv) => ["-e", ...argv], + xterm: (argv) => ["-e", ...argv], +}; + +/** Terminals to try, the desktop's own first. */ +export function terminalOrder(desktop = ""): string[] { + const d = desktop.toLowerCase(); + const first = d.includes("kde") + ? ["konsole"] + : d.includes("gnome") || d.includes("unity") || d.includes("budgie") + ? ["ptyxis", "kgx", "gnome-terminal"] + : d.includes("xfce") + ? ["xfce4-terminal"] + : []; + const rest = [ + "x-terminal-emulator", + "kgx", + "gnome-terminal", + "ptyxis", + "konsole", + "foot", + "kitty", + "alacritty", + "wezterm", + "ghostty", + "xfce4-terminal", + "xterm", + ]; + return [...new Set([...first, ...rest])]; +} + +/** "3 updates", "1 update". */ +export const plural = (n: number, word = "update") => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** 12_345_678 → "12.3 MB". */ +export function size(n: number): string { + const units = ["B", "kB", "MB", "GB"]; + let i = 0; + while (n >= 1000 && i < units.length - 1) { + n /= 1000; + i++; + } + return `${i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} diff --git a/extensions/updates/lib_test.ts b/extensions/updates/lib_test.ts new file mode 100644 index 0000000..7c47501 --- /dev/null +++ b/extensions/updates/lib_test.ts @@ -0,0 +1,190 @@ +import { assert, assertEquals } from "@std/assert"; +import { + parseApt, + parseCheckupdates, + parseDnf, + parseDnfSecurity, + parseFlatpak, + parseFwupd, + parseRpmVersions, + parseSnap, + parseZypper, + plural, + rpmName, + shellQuote, + TERMINAL_ARGS, + terminalOrder, + terminalScript, + updateAllCommand, + updateOneCommand, +} from "./lib.ts"; + +Deno.test("apt list --upgradable, security from the suite", () => { + const updates = parseApt(`Listing... +libbrotli1/noble-updates,noble-security 1.1.0-2ubuntu0.1 amd64 [upgradable from: 1.1.0-2build2] +firefox-esr/stable-security 128.4.0esr-1~deb12u1 amd64 [upgradable from: 128.3.1esr-1~deb12u1] +code/stable 1.95.0-1730153583 amd64 [upgradable from: 1.94.2-1728494015] +`); + assertEquals(updates.length, 3); + assertEquals(updates[0], { + manager: "apt", + name: "libbrotli1", + to: "1.1.0-2ubuntu0.1", + arch: "amd64", + from: "1.1.0-2build2", + source: "noble-updates", + security: true, + }); + assertEquals(updates[1].security, true); + assertEquals(updates[2].security, false); + assertEquals(parseApt("Listing... Done\n"), []); +}); + +Deno.test("dnf check-update with a wrapped line and obsoletes", () => { + const updates = parseDnf(` +Last metadata expiration check: 0:12:01 ago on Mon 28 Oct 2026 09:00:00. + +firefox.x86_64 132.0-1.fc41 updates +kernel-core.x86_64 6.11.5-300.fc41 updates +texlive-collection-latexrecommended.noarch + 11:svn65512-71.fc41 updates +Obsoleting Packages +grub2-tools-efi.x86_64 1:2.12-10.fc41 updates +`); + assertEquals(updates.map((u) => [u.name, u.arch, u.to]), [ + ["firefox", "x86_64", "132.0-1.fc41"], + ["kernel-core", "x86_64", "6.11.5-300.fc41"], + ["texlive-collection-latexrecommended", "noarch", "11:svn65512-71.fc41"], + ]); +}); + +Deno.test("dnf security advisories (dnf 4 and 5) and rpm versions", () => { + assertEquals(rpmName("openssl-libs-1:3.2.2-3.fc41.x86_64"), "openssl-libs"); + const names = parseDnfSecurity(`FEDORA-2026-1a2b3c Important/Sec. openssl-libs-1:3.2.2-3.fc41.x86_64 +FEDORA-2026-4d5e6f security Moderate firefox-132.0-1.fc41.x86_64 2026-10-27 12:00:00 +`); + assertEquals([...names], ["openssl-libs", "firefox"]); + const versions = parseRpmVersions(`kernel-core.x86_64 6.11.3-300.fc41 +kernel-core.x86_64 6.11.4-301.fc41 +firefox.x86_64 131.0.3-1.fc41 +package texlive is not installed +`); + assertEquals(versions.get("kernel-core.x86_64"), "6.11.4-301.fc41"); + assertEquals(versions.get("firefox.x86_64"), "131.0.3-1.fc41"); + assertEquals(versions.size, 2); +}); + +Deno.test("checkupdates", () => { + assertEquals(parseCheckupdates("linux 6.11.4.arch1-1 -> 6.11.5.arch1-1\nmesa 1:24.2.4-1 -> 1:24.2.5-1\n"), [ + { manager: "pacman", name: "linux", from: "6.11.4.arch1-1", to: "6.11.5.arch1-1", security: false }, + { manager: "pacman", name: "mesa", from: "1:24.2.4-1", to: "1:24.2.5-1", security: false }, + ]); +}); + +Deno.test("zypper list-updates table", () => { + const updates = parseZypper( + `S | Repository | Name | Current Version | Available Version | Arch +--+---------------------------+---------------+-----------------+-------------------+------- +v | Main Update Repository | MozillaFirefox | 131.0-1.1 | 132.0-1.1 | x86_64 +v | repo-oss | vim | 9.1.0750-1.1 | 9.1.0790-1.1 | x86_64 +`, + ); + assertEquals(updates.length, 2); + assertEquals(updates[0], { + manager: "zypper", + name: "MozillaFirefox", + from: "131.0-1.1", + to: "132.0-1.1", + source: "Main Update Repository", + arch: "x86_64", + security: false, + }); +}); + +Deno.test("flatpak updates with installed versions", () => { + const updates = parseFlatpak( + "org.mozilla.firefox\tFirefox\t132.0\tstable\tflathub\norg.gnome.Platform\tGNOME Application Platform version 47\t\t47\tflathub\n", + "org.mozilla.firefox\t131.0.3\tstable\norg.gnome.Platform\t\t47\n", + ); + assertEquals(updates[0].label, "Firefox"); + assertEquals(updates[0].from, "131.0.3"); + assertEquals(updates[0].to, "132.0"); + assertEquals(updates[0].source, "flathub"); + assertEquals(updates[1].to, "47"); + assertEquals(updates[1].summary, "Runtime shared by Flatpak apps"); +}); + +Deno.test("snap refresh --list", () => { + const updates = parseSnap( + `Name Version Rev Size Publisher Notes +firefox 132.0-1 5187 280MB mozilla✓ - +core22 20241001 1663 77MB canonical✓ base +`, + `Name Version Rev Tracking Publisher Notes +core22 20240823 1621 latest/stable canonical✓ base +firefox 131.0.3-1 5134 latest/stable/… mozilla✓ - +`, + ); + assertEquals(updates.map((u) => [u.name, u.from, u.to, u.source, u.size]), [ + ["firefox", "131.0.3-1", "132.0-1", "mozilla", 280e6], + ["core22", "20240823", "20241001", "canonical", 77e6], + ]); + assertEquals(parseSnap("All snaps up to date.\n"), []); +}); + +Deno.test("fwupd get-updates --json", () => { + const updates = parseFwupd(JSON.stringify({ + Devices: [{ + Name: "System Firmware", + DeviceId: "a45df35ac0e948ee180fe216a5f703f32dda163f", + Version: "0.1.25", + Vendor: "LENOVO", + Releases: [{ + Version: "0.1.27", + Summary: "Lenovo ThinkPad X1 Carbon Gen 9 System Firmware", + Description: "

    Fixes:

    • Security fix for CVE-2026-1234
    ", + Urgency: "high", + Size: 19345678, + }], + }], + })); + assertEquals(updates[0].from, "0.1.25"); + assertEquals(updates[0].to, "0.1.27"); + assertEquals(updates[0].security, true); + assert(updates[0].summary?.includes("- Security fix for CVE-2026-1234")); + assertEquals(parseFwupd(""), []); +}); + +Deno.test("update commands", () => { + assertEquals(updateAllCommand("apt"), "sudo apt update && sudo apt upgrade"); + assertEquals(updateAllCommand("zypper", true), "sudo zypper dist-upgrade"); + assertEquals(updateOneCommand({ manager: "pacman", name: "linux", to: "1", security: false }), undefined); + assertEquals( + updateOneCommand({ + manager: "flatpak", + name: "org.mozilla.firefox", + id: "org.mozilla.firefox", + to: "1", + security: false, + }), + "flatpak update org.mozilla.firefox", + ); + assertEquals(shellQuote("it's"), `'it'\\''s'`); + assertEquals(plural(1), "1 update"); + assertEquals(plural(12), "12 updates"); +}); + +Deno.test("terminal script is valid sh and terminals are ordered by desktop", async () => { + const script = terminalScript([ + { title: "APT", command: "sudo apt update && sudo apt upgrade" }, + { title: "Flatpak", command: "flatpak update" }, + ]); + const check = await new Deno.Command("sh", { args: ["-n", "-c", script] }).output(); + assertEquals(check.code, 0); + assertEquals(terminalOrder("KDE")[0], "konsole"); + assertEquals(terminalOrder("ubuntu:GNOME").slice(0, 3), ["ptyxis", "kgx", "gnome-terminal"]); + assertEquals(terminalOrder("sway")[0], "x-terminal-emulator"); + assertEquals(new Set(terminalOrder("")).size, terminalOrder("").length); + for (const t of terminalOrder("")) assert(TERMINAL_ARGS[t], t); + assertEquals(TERMINAL_ARGS["gnome-terminal"](["sh", "-c", "x"]), ["--", "sh", "-c", "x"]); +}); From e9f9dc07b80d04ec0b836b6bb04ce1db271a7eb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:57:03 +0000 Subject: [PATCH 02/10] Dev Utils, SSH Hosts, Terminal Sessions, Containers & VMs, Kubernetes and Docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/dev-utils/backslash.toml | 64 ++ .../dev-utils/commands/copy-password.ts | 8 + extensions/dev-utils/commands/copy-uuid.ts | 8 + extensions/dev-utils/commands/dev-utils.ts | 285 +++++++ extensions/dev-utils/lib.ts | 770 ++++++++++++++++++ extensions/dev-utils/lib_test.ts | 278 +++++++ extensions/distrobox/backslash.toml | 57 ++ extensions/distrobox/commands/machines.ts | 323 ++++++++ extensions/distrobox/lib.ts | 143 ++++ extensions/distrobox/lib_test.ts | 108 +++ extensions/distrobox/terminal.ts | 77 ++ extensions/docs/backslash.toml | 78 ++ extensions/docs/commands/arch-wiki.ts | 124 +++ extensions/docs/commands/choose-docs.ts | 80 ++ extensions/docs/commands/devdocs.ts | 116 +++ extensions/docs/commands/man.ts | 135 +++ extensions/docs/commands/tldr.ts | 198 +++++ extensions/docs/devdocs.ts | 73 ++ extensions/docs/lib.ts | 515 ++++++++++++ extensions/docs/lib_test.ts | 315 +++++++ extensions/docs/terminal.ts | 77 ++ extensions/kubernetes/backslash.toml | 51 ++ extensions/kubernetes/commands/contexts.ts | 60 ++ extensions/kubernetes/commands/namespaces.ts | 83 ++ extensions/kubernetes/commands/pods.ts | 290 +++++++ extensions/kubernetes/kube.ts | 123 +++ extensions/kubernetes/lib.ts | 297 +++++++ extensions/kubernetes/lib_test.ts | 286 +++++++ extensions/kubernetes/terminal.ts | 77 ++ extensions/ssh/backslash.toml | 39 + extensions/ssh/commands/hosts.ts | 163 ++++ extensions/ssh/hosts.ts | 56 ++ extensions/ssh/lib.ts | 256 ++++++ extensions/ssh/lib_test.ts | 158 ++++ extensions/ssh/terminal.ts | 77 ++ extensions/terminal-sessions/backslash.toml | 39 + .../terminal-sessions/commands/sessions.ts | 325 ++++++++ extensions/terminal-sessions/lib.ts | 183 +++++ extensions/terminal-sessions/lib_test.ts | 100 +++ extensions/terminal-sessions/terminal.ts | 78 ++ 40 files changed, 6573 insertions(+) create mode 100644 extensions/dev-utils/backslash.toml create mode 100644 extensions/dev-utils/commands/copy-password.ts create mode 100644 extensions/dev-utils/commands/copy-uuid.ts create mode 100644 extensions/dev-utils/commands/dev-utils.ts create mode 100644 extensions/dev-utils/lib.ts create mode 100644 extensions/dev-utils/lib_test.ts create mode 100644 extensions/distrobox/backslash.toml create mode 100644 extensions/distrobox/commands/machines.ts create mode 100644 extensions/distrobox/lib.ts create mode 100644 extensions/distrobox/lib_test.ts create mode 100644 extensions/distrobox/terminal.ts create mode 100644 extensions/docs/backslash.toml create mode 100644 extensions/docs/commands/arch-wiki.ts create mode 100644 extensions/docs/commands/choose-docs.ts create mode 100644 extensions/docs/commands/devdocs.ts create mode 100644 extensions/docs/commands/man.ts create mode 100644 extensions/docs/commands/tldr.ts create mode 100644 extensions/docs/devdocs.ts create mode 100644 extensions/docs/lib.ts create mode 100644 extensions/docs/lib_test.ts create mode 100644 extensions/docs/terminal.ts create mode 100644 extensions/kubernetes/backslash.toml create mode 100644 extensions/kubernetes/commands/contexts.ts create mode 100644 extensions/kubernetes/commands/namespaces.ts create mode 100644 extensions/kubernetes/commands/pods.ts create mode 100644 extensions/kubernetes/kube.ts create mode 100644 extensions/kubernetes/lib.ts create mode 100644 extensions/kubernetes/lib_test.ts create mode 100644 extensions/kubernetes/terminal.ts create mode 100644 extensions/ssh/backslash.toml create mode 100644 extensions/ssh/commands/hosts.ts create mode 100644 extensions/ssh/hosts.ts create mode 100644 extensions/ssh/lib.ts create mode 100644 extensions/ssh/lib_test.ts create mode 100644 extensions/ssh/terminal.ts create mode 100644 extensions/terminal-sessions/backslash.toml create mode 100644 extensions/terminal-sessions/commands/sessions.ts create mode 100644 extensions/terminal-sessions/lib.ts create mode 100644 extensions/terminal-sessions/lib_test.ts create mode 100644 extensions/terminal-sessions/terminal.ts diff --git a/extensions/dev-utils/backslash.toml b/extensions/dev-utils/backslash.toml new file mode 100644 index 0000000..ea68c96 --- /dev/null +++ b/extensions/dev-utils/backslash.toml @@ -0,0 +1,64 @@ +[extension] +id = "dev-utils" +title = "Dev Utils" +description = "Encode, decode, hash, convert and generate developer values right from the search bar." +version = "1.0.0" +author = "Backslash" +icon = "bs-braces" +license = "MIT" + +# No permission: everything runs locally, offline. + +[[commands]] +name = "dev-utils" +title = "Dev Utils" +description = "Paste a value: base64, URL, hashes, JWT, timestamps, JSON, number bases, case, counts" +icon = "bs-braces" +keywords = [ + "base64", + "url", + "encode", + "decode", + "hash", + "md5", + "sha256", + "jwt", + "timestamp", + "epoch", + "json", + "hex", + "binary", + "case", + "camel", + "snake", + "count", + "uuid", + "password", + "lorem", + "encoder", + "décoder", + "convertir", + "mot de passe", +] + +[[commands]] +name = "copy-uuid" +title = "Copy New UUID" +description = "Copy a random UUID v4 to the clipboard" +icon = "bs-tag" +mode = "no-view" +keywords = ["uuid", "guid", "generate", "générer", "identifiant"] + +[[commands]] +name = "copy-password" +title = "Copy New Password" +description = "Copy a strong random password to the clipboard" +icon = "bs-lock" +mode = "no-view" +keywords = ["password", "generate", "random", "mot de passe", "générer"] +[[commands.arguments]] +name = "length" +placeholder = "Length (20)" +type = "number" +min = 8 +max = 256 diff --git a/extensions/dev-utils/commands/copy-password.ts b/extensions/dev-utils/commands/copy-password.ts new file mode 100644 index 0000000..37301ad --- /dev/null +++ b/extensions/dev-utils/commands/copy-password.ts @@ -0,0 +1,8 @@ +import { clipboard, run } from "@backslash/api"; +import { password } from "../lib.ts"; + +export default run<{ length?: number }>(async ({ args }) => { + const length = Math.min(256, Math.max(8, Number(args.length) || 20)); + await clipboard.write(password(length, true)); + return `Copied a ${length}-character password`; +}); diff --git a/extensions/dev-utils/commands/copy-uuid.ts b/extensions/dev-utils/commands/copy-uuid.ts new file mode 100644 index 0000000..e84ef0b --- /dev/null +++ b/extensions/dev-utils/commands/copy-uuid.ts @@ -0,0 +1,8 @@ +import { clipboard, run } from "@backslash/api"; +import { uuidV4 } from "../lib.ts"; + +export default run(async () => { + const uuid = uuidV4(); + await clipboard.write(uuid); + return `Copied ${uuid}`; +}); diff --git a/extensions/dev-utils/commands/dev-utils.ts b/extensions/dev-utils/commands/dev-utils.ts new file mode 100644 index 0000000..e536185 --- /dev/null +++ b/extensions/dev-utils/commands/dev-utils.ts @@ -0,0 +1,285 @@ +import { + type Action, + action, + copy, + detail, + Icons, + type Item, + list, + meta, + paste, + refresh, + submenu, +} from "@backslash/api"; +import { + entropy, + generatorQuery, + loremParagraph, + loremParagraphs, + loremSentence, + password, + relative, + type Result, + strength, + transforms, + uuidV4, + uuidV7, +} from "../lib.ts"; + +const preview = (text: string, max = 140) => { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > max ? `${line.slice(0, max)}…` : line; +}; + +/** A fenced block that can't be closed early by the text itself. */ +const fence = (text: string, lang = "") => { + const ticks = "`".repeat(Math.max(3, ...[...text.matchAll(/`+/g)].map((m) => m[0].length + 1))); + const shown = text.length > 6000 ? `${text.slice(0, 6000)}\n…` : text; + return `${ticks}${lang}\n${shown}\n${ticks}`; +}; + +const valueActions = (value: string): Action[] => [ + copy(value, "Copy to Clipboard"), + paste(value, "Paste in Active App"), +]; + +function resultItem(r: Result, depth: number): Item { + const isJson = r.section === "JSON" || r.id === "jwt"; + const markdown = r.markdown ?? fence(r.value, isJson ? "json" : ""); + const metadata = [ + meta.text("Transformation", r.label.split(" · ")[0], { icon: r.icon }), + ...(r.metadata ?? []), + ...(r.id === "counts" ? [] : [meta.separator(), meta.text("Output length", `${[...r.value].length} characters`)]), + ]; + return { + id: r.id, + title: r.title ?? (preview(r.value) || "(empty)"), + subtitle: r.subtitle ?? r.label, + icon: { source: r.icon, tint: r.tint }, + section: r.section, + keywords: [r.label], + accessories: r.tags, + detail: { stats: r.stats, markdown: markdown || undefined, metadata }, + actions: [ + ...valueActions(r.value), + action("Show Full Output", () => + detail({ + title: r.label, + markdown, + stats: r.stats, + metadata, + status: r.label, + actions: [...valueActions(r.value), ...(r.extra ?? []).map((e) => copy(e.value, e.title))], + }), { icon: Icons.eye, shortcut: "d" }), + ...(depth < 4 + ? [action("Transform This Result", () => chain(r.value, depth + 1), { + icon: Icons.arrowRight, + shortcut: "t", + })] + : []), + ...(r.extra ?? []).map((e) => copy(e.value, e.title, { section: "Copy" })), + ], + }; +} + +/** The transformations of a result, to apply several in a row (decode, then pretty-print…). */ +async function chain(value: string, depth: number) { + const results = await transforms(value); + return list({ + title: preview(value, 40), + placeholder: "Filter transformations…", + status: `${results.length} transformations of the previous result`, + items: results.map((r) => resultItem(r, depth)), + }); +} + +// --------------------------------------------------------------------------- +// Generators + +const LENGTHS = [8, 12, 16, 24, 32, 48, 64]; + +function passwordItem(length: number, symbols: boolean): Item { + const value = password(length, symbols); + const bits = entropy(length, symbols); + const s = strength(bits); + const kind = symbols ? "letters, digits and symbols" : "letters and digits"; + return { + id: `password-${symbols ? "symbols" : "alnum"}`, + title: value, + subtitle: symbols ? "Password" : "Password · no symbols", + icon: { source: Icons.lock, tint: symbols ? "orange" : "yellow" }, + section: "Passwords", + keywords: ["password", "passwd", "secret", "mot de passe", "random"], + accessories: [{ tag: s.text, color: s.color }, { text: `${length} chars` }], + detail: { + stats: [ + { label: "characters", value: length }, + { label: "bits of entropy", value: bits, color: s.color }, + ], + markdown: fence(value), + metadata: [ + meta.text("Characters", kind, { icon: Icons.text }), + meta.progress("Strength", Math.min(1, bits / 128), s.text), + meta.text("Source", "crypto.getRandomValues", { icon: Icons.shield }), + ], + }, + actions: [ + ...valueActions(value), + action("Generate Another", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + submenu( + "Other Length", + LENGTHS.filter((l) => l !== length).map((l) => copy(password(l, symbols), `${l} characters`)), + { icon: Icons.hash, section: "Generate" }, + ), + submenu( + symbols ? "Without Symbols" : "With Symbols", + LENGTHS.map((l) => copy(password(l, !symbols), `${l} characters`)), + { icon: Icons.key, section: "Generate" }, + ), + ], + }; +} + +function uuidItem(version: 4 | 7): Item { + const value = version === 4 ? uuidV4() : uuidV7(); + const variants: [string, string][] = [ + ["Uppercase", value.toUpperCase()], + ["Without Dashes", value.replace(/-/g, "")], + ["With Braces", `{${value}}`], + ["As a URN", `urn:uuid:${value}`], + ]; + return { + id: `uuid-v${version}`, + title: value, + subtitle: version === 4 ? "UUID v4 · random" : "UUID v7 · time-ordered", + icon: { source: Icons.tag, tint: version === 4 ? "blue" : "teal" }, + section: "Identifiers", + keywords: ["uuid", "guid", "id", `v${version}`], + detail: { + markdown: fence(value), + metadata: [ + meta.text("Version", String(version), { icon: Icons.tag }), + meta.text("Format", version === 4 ? "122 random bits" : "48-bit Unix ms timestamp + 74 random bits"), + ...(version === 7 ? [meta.text("Sorts by", "Creation time", { icon: Icons.clock })] : []), + meta.text("Standard", "RFC 9562"), + ], + }, + actions: [ + ...valueActions(value), + action("Generate Another", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + submenu("Copy As", variants.map(([title, v]) => copy(v, title)), { icon: Icons.copy, section: "Copy" }), + submenu( + "Copy Several", + [5, 10, 25].map((n) => + copy( + Array.from({ length: n }, () => version === 4 ? uuidV4() : uuidV7()).join("\n"), + `${n} UUIDs, one per line`, + ) + ), + { icon: Icons.layers, section: "Copy" }, + ), + ], + }; +} + +function loremItems(paragraphs?: number): Item[] { + const entries: [string, string, string][] = paragraphs + ? [[`lorem-${paragraphs}`, `${paragraphs} paragraphs`, loremParagraphs(paragraphs)]] + : [ + ["lorem-sentence", "Sentence", loremSentence()], + ["lorem-paragraph", "Paragraph", loremParagraph()], + ["lorem-3", "3 paragraphs", loremParagraphs(3)], + ]; + return entries.map(([id, what, value]) => ({ + id, + title: preview(value, 90), + subtitle: `Lorem ipsum · ${what}`, + icon: { source: Icons.text, tint: "gray" }, + section: "Placeholder Text", + keywords: ["lorem", "ipsum", "placeholder", "text", "texte"], + detail: { + markdown: value, + metadata: [meta.text("Words", value.split(/\s+/).length), meta.text("Characters", value.length)], + }, + actions: [ + ...valueActions(value), + action("Generate Another", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + })); +} + +function nowItems(): Item[] { + const now = new Date(); + const entries: [string, string, string][] = [ + ["now-s", "Unix seconds", String(Math.floor(now.getTime() / 1000))], + ["now-ms", "Unix milliseconds", String(now.getTime())], + ["now-iso", "ISO 8601 (UTC)", now.toISOString()], + ]; + return entries.map(([id, what, value]) => ({ + id, + title: value, + subtitle: `Now · ${what}`, + icon: { source: Icons.clock, tint: "blue" }, + section: "Current Time", + keywords: ["now", "timestamp", "time", "date", "epoch", "maintenant"], + detail: { + metadata: [ + meta.text("Format", what, { icon: Icons.calendar }), + meta.text("Local time", now.toLocaleString("en-GB", { dateStyle: "full", timeStyle: "long" })), + meta.text("Generated", relative(now, new Date())), + ], + }, + actions: [...valueActions(value), action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + })); +} + +const TRANSFORMATIONS = [ + "Base64 and Base64URL encode / decode", + "URL encode / decode, JSON string escape / unescape", + "MD5, SHA-1, SHA-256, SHA-512", + "JWT: header, payload and expiry", + "Unix timestamps (s, ms, µs, ns) ⇄ ISO dates, with relative time", + "JSON: pretty-print and minify", + "Numbers: decimal, hexadecimal, binary, octal", + "camelCase, snake_case, kebab-case, Title Case, UPPERCASE…", + "Character, word and line counts", +]; + +const tip: Item = { + id: "tip", + title: "Type or paste a value to transform it", + subtitle: "Encodings, hashes, JWT, timestamps, JSON, number bases, case, counts", + icon: { source: Icons.info, tint: "gray" }, + section: "Transform", + detail: { + markdown: `## What Dev Utils recognizes\n\n${ + TRANSFORMATIONS.map((t) => `- ${t.replace(/\S*_\S*/g, (w) => `\`${w}\``)}`).join("\n") + }\n\nType **password 32**, **uuid** or **lorem 5** for a generator.`, + }, +}; + +function generators(kind?: string, n?: number): Item[] { + const items: Item[] = []; + if (!kind || kind === "uuid") items.push(uuidItem(4), uuidItem(7)); + if (!kind || kind === "password") { + const length = n && n >= 4 && n <= 256 ? n : 20; + items.push(passwordItem(length, true), passwordItem(length, false)); + } + if (!kind || kind === "lorem") items.push(...loremItems(n && n <= 50 ? n : undefined)); + if (!kind || kind === "now") items.push(...nowItems()); + return items; +} + +export default list({ + placeholder: "Paste a value to encode, decode, hash, convert… or pick a generator", + status: (items) => + items.some((i) => i.id === "tip") + ? "Generators · type to transform" + : `${items.length} result${items.length === 1 ? "" : "s"}`, + async search(query) { + if (!query.trim()) return [...generators(), tip]; + const wanted = generatorQuery(query); + const results = (await transforms(query)).map((r) => resultItem(r, 0)); + return wanted ? [...generators(wanted.kind, wanted.n), ...results] : results; + }, +}); diff --git a/extensions/dev-utils/lib.ts b/extensions/dev-utils/lib.ts new file mode 100644 index 0000000..8135d4c --- /dev/null +++ b/extensions/dev-utils/lib.ts @@ -0,0 +1,770 @@ +// Transformations and generators of Dev Utils. Pure, tested in lib_test.ts. + +import { type Color, Icons, meta, type Metadata, type Stat } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Encodings + +const utf8 = new TextEncoder(); + +export function base64Encode(text: string): string { + let binary = ""; + for (const b of utf8.encode(text)) binary += String.fromCharCode(b); + return btoa(binary); +} + +/** Standard or URL-safe base64, padding optional. Only text that decodes to readable UTF-8. */ +export function base64Decode(input: string): string | undefined { + const s = input.trim(); + if (s.length < 4 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(s)) return undefined; + const normalized = s.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, ""); + if (normalized.length % 4 === 1) return undefined; + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + let binary: string; + try { + binary = atob(padded); + } catch { + return undefined; + } + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } + // Binary that happens to be valid UTF-8 is mostly control characters. + const printable = [...text].filter((c) => !/[\p{Cc}\p{Cs}�]/u.test(c) || c === "\n" || c === "\t" || c === "\r"); + return text && printable.length / [...text].length > 0.95 ? text : undefined; +} + +export const urlEncode = (text: string): string => encodeURIComponent(text); + +export function urlDecode(text: string): string | undefined { + if (!/%[0-9A-Fa-f]{2}|\+/.test(text)) return undefined; + try { + const decoded = decodeURIComponent(text.replace(/\+/g, " ")); + return decoded === text ? undefined : decoded; + } catch { + return undefined; + } +} + +/** `"a\nb"` → `"\"a\\nb\""`, to embed text in JSON or a JS string. */ +export const jsonEscape = (text: string): string => JSON.stringify(text); + +/** A JSON string literal back to its text. */ +export function jsonUnescape(text: string): string | undefined { + const s = text.trim(); + if (!/^"[\s\S]*"$/.test(s)) return undefined; + try { + const value = JSON.parse(s); + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Hashes + +const hex = (bytes: Uint8Array) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + +const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]; +const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0); + +/** MD5 (RFC 1321): Web Crypto does not offer it, and it is still everywhere in checksums. */ +export function md5(text: string | Uint8Array): string { + const bytes = typeof text === "string" ? utf8.encode(text) : text; + const blocks = ((bytes.length + 8) >>> 6) + 1; + const words = new Uint32Array(blocks * 16); + for (let i = 0; i < bytes.length; i++) words[i >> 2] |= bytes[i] << ((i % 4) * 8); + words[bytes.length >> 2] |= 0x80 << ((bytes.length % 4) * 8); + const bits = bytes.length * 8; + words[blocks * 16 - 2] = bits >>> 0; + words[blocks * 16 - 1] = Math.floor(bits / 2 ** 32); + let [a0, b0, c0, d0] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]; + for (let blk = 0; blk < blocks; blk++) { + let [a, b, c, d] = [a0, b0, c0, d0]; + for (let i = 0; i < 64; i++) { + let f: number, g: number; + if (i < 16) [f, g] = [(b & c) | (~b & d), i]; + else if (i < 32) [f, g] = [(d & b) | (~d & c), (5 * i + 1) % 16]; + else if (i < 48) [f, g] = [b ^ c ^ d, (3 * i + 5) % 16]; + else [f, g] = [c ^ (b | ~d), (7 * i) % 16]; + f = (f + a + K[i] + words[blk * 16 + g]) >>> 0; + a = d; + d = c; + c = b; + const s = S[(i >> 4) * 4 + (i % 4)]; + b = (b + ((f << s) | (f >>> (32 - s)))) >>> 0; + } + a0 = (a0 + a) >>> 0; + b0 = (b0 + b) >>> 0; + c0 = (c0 + c) >>> 0; + d0 = (d0 + d) >>> 0; + } + const out = new Uint8Array(16); + new DataView(out.buffer).setUint32(0, a0, true); + new DataView(out.buffer).setUint32(4, b0, true); + new DataView(out.buffer).setUint32(8, c0, true); + new DataView(out.buffer).setUint32(12, d0, true); + return hex(out); +} + +export async function sha(algorithm: "SHA-1" | "SHA-256" | "SHA-512", text: string): Promise { + return hex(new Uint8Array(await crypto.subtle.digest(algorithm, utf8.encode(text)))); +} + +// --------------------------------------------------------------------------- +// Time + +const UNITS: [string, number][] = [ + ["year", 31536000], + ["month", 2592000], + ["week", 604800], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ["second", 1], +]; + +/** "3 days ago", "in 2 hours", "just now". */ +export function relative(date: Date, now: Date): string { + const diff = (date.getTime() - now.getTime()) / 1000; + const abs = Math.abs(diff); + if (abs < 5) return "just now"; + const [unit, seconds] = UNITS.find(([, s]) => abs >= s) ?? UNITS[UNITS.length - 1]; + const n = Math.floor(abs / seconds); + const text = `${n} ${unit}${n > 1 ? "s" : ""}`; + return diff < 0 ? `${text} ago` : `in ${text}`; +} + +export type TimestampUnit = "s" | "ms" | "µs" | "ns"; + +/** `1700000000` (s), `1700000000000` (ms), µs and ns, by number of digits. */ +export function parseTimestamp(input: string): { date: Date; unit: TimestampUnit } | undefined { + const s = input.trim(); + const m = s.match(/^(\d{9,19})(?:\.(\d+))?$/); + if (!m) return undefined; + const digits = m[1].length; + let unit: TimestampUnit; + let ms: number; + if (digits <= 11) [unit, ms] = ["s", Number(s) * 1000]; + else if (digits <= 14 && !m[2]) [unit, ms] = ["ms", Number(m[1])]; + else if (digits <= 17 && !m[2]) [unit, ms] = ["µs", Number(m[1]) / 1000]; + else if (!m[2]) [unit, ms] = ["ns", Number(BigInt(m[1]) / 1000000n)]; + else return undefined; + const date = new Date(ms); + return isNaN(date.getTime()) ? undefined : { date, unit }; +} + +/** ISO 8601, RFC 2822 and friends; not bare numbers (see {@link parseTimestamp}). */ +export function parseDate(input: string): Date | undefined { + const s = input.trim(); + if (s.length > 64 || !/\d{4}/.test(s) || !/[-/:,]|\s/.test(s) || /^\d+$/.test(s)) return undefined; + const ms = Date.parse(s); + return isNaN(ms) ? undefined : new Date(ms); +} + +// --------------------------------------------------------------------------- +// JWT + +export interface Jwt { + header: Record; + payload: Record; + signature: string; +} + +export function decodeJwt(token: string): Jwt | undefined { + const parts = token.trim().replace(/^Bearer\s+/i, "").split("."); + if (parts.length !== 3 || !parts[0].startsWith("eyJ")) return undefined; + try { + const header = JSON.parse(base64Decode(parts[0]) ?? ""); + const payload = JSON.parse(base64Decode(parts[1]) ?? ""); + if (typeof header !== "object" || typeof payload !== "object" || !header || !payload) return undefined; + return { header, payload, signature: parts[2] }; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Numbers + +export type Base = 2 | 8 | 10 | 16; + +/** `0x1f`, `0b101`, `0o17`, `-42`, or bare hex with letters (`ff`, `deadbeef`). */ +export function parseInteger(input: string): { value: bigint; base: Base } | undefined { + const s = input.trim().replace(/_/g, ""); + const m = s.match(/^(-?)(0x[0-9a-f]+|0b[01]+|0o[0-7]+|\d{1,30}|[0-9a-f]*[a-f][0-9a-f]*)$/i); + if (!m || s.length > 40) return undefined; + const body = m[2]; + const prefix = body.slice(0, 2).toLowerCase(); + let base: Base = 10; + if (prefix === "0x") base = 16; + else if (prefix === "0b") base = 2; + else if (prefix === "0o") base = 8; + else if (/[a-f]/i.test(body)) { + if (body.length < 2 || body.length > 16) return undefined; + base = 16; + } + const digits = base === 10 || !/^0[xbo]/i.test(body) ? body : body.slice(2); + const literal = base === 10 ? digits : `0${({ 2: "b", 8: "o", 16: "x" } as const)[base]}${digits}`; + const value = BigInt(literal); + return { value: m[1] ? -value : value, base }; +} + +export function formatInteger(value: bigint, base: Base): string { + const sign = value < 0n ? "-" : ""; + const abs = value < 0n ? -value : value; + const prefix = { 2: "0b", 8: "0o", 10: "", 16: "0x" }[base]; + return `${sign}${prefix}${abs.toString(base)}`; +} + +/** 1536 → "1.5 KiB". */ +export function humanBytes(n: number): string { + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${i ? n.toFixed(n < 10 ? 2 : 1).replace(/\.?0+$/, "") : n} ${units[i]}`; +} + +// --------------------------------------------------------------------------- +// Case + +/** `getHTTPResponse_code-v2` → `["get", "HTTP", "Response", "code", "v2"]`. */ +export function words(text: string): string[] { + return text + .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2") + .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2") + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean); +} + +const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); + +export const cases: { id: string; name: string; convert: (text: string) => string }[] = [ + { id: "camel", name: "camelCase", convert: (t) => words(t).map((w, i) => i ? cap(w) : w.toLowerCase()).join("") }, + { id: "pascal", name: "PascalCase", convert: (t) => words(t).map(cap).join("") }, + { id: "snake", name: "snake_case", convert: (t) => words(t).map((w) => w.toLowerCase()).join("_") }, + { id: "constant", name: "CONSTANT_CASE", convert: (t) => words(t).map((w) => w.toUpperCase()).join("_") }, + { id: "kebab", name: "kebab-case", convert: (t) => words(t).map((w) => w.toLowerCase()).join("-") }, + { id: "title", name: "Title Case", convert: (t) => words(t).map(cap).join(" ") }, + { id: "upper", name: "UPPERCASE", convert: (t) => t.toUpperCase() }, + { id: "lower", name: "lowercase", convert: (t) => t.toLowerCase() }, +]; + +// --------------------------------------------------------------------------- +// Counts + +export interface Counts { + characters: number; + withoutSpaces: number; + words: number; + lines: number; + bytes: number; + sentences: number; +} + +export function counts(text: string): Counts { + const graphemes = [...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(text)]; + return { + characters: graphemes.length, + withoutSpaces: graphemes.filter((g) => !/^\s+$/.test(g.segment)).length, + words: text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length, + lines: text ? text.split(/\r\n|\r|\n/).length : 0, + bytes: utf8.encode(text).length, + sentences: (text.match(/[^.!?]*[\p{L}\p{N}][^.!?]*[.!?]+/gu) ?? []).length || (text.trim() ? 1 : 0), + }; +} + +const plural = (n: number, word: string) => `${n.toLocaleString("en")} ${word}${n === 1 ? "" : "s"}`; + +// --------------------------------------------------------------------------- +// Generators + +/** A uniform integer in [0, n) from the system's CSPRNG. */ +export function randomInt(n: number): number { + const limit = Math.floor(0x100000000 / n) * n; + const buf = new Uint32Array(1); + do crypto.getRandomValues(buf); while (buf[0] >= limit); + return buf[0] % n; +} + +const randomBytes = (n: number) => crypto.getRandomValues(new Uint8Array(n)); + +const formatUuid = (b: Uint8Array) => { + const h = hex(b); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +}; + +export const uuidV4 = (): string => crypto.randomUUID(); + +/** RFC 9562 UUID v7: 48-bit Unix ms timestamp, then random bits; sorts by creation time. */ +export function uuidV7(now = Date.now(), random: Uint8Array = randomBytes(10)): string { + const b = new Uint8Array(16); + let ms = BigInt(now); + for (let i = 5; i >= 0; i--) { + b[i] = Number(ms & 0xffn); + ms >>= 8n; + } + b.set(random.slice(0, 10), 6); + b[6] = 0x70 | (b[6] & 0x0f); + b[8] = 0x80 | (b[8] & 0x3f); + return formatUuid(b); +} + +/** The creation time inside a UUID v7, or undefined for other versions. */ +export function uuidTime(uuid: string): Date | undefined { + const m = uuid.trim().match(/^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + return m ? new Date(parseInt(m[1] + m[2], 16)) : undefined; +} + +export const LOWER = "abcdefghijklmnopqrstuvwxyz"; +export const UPPER = LOWER.toUpperCase(); +export const DIGITS = "0123456789"; +export const SYMBOLS = "!@#$%^&*()-_=+[]{};:,.?/~"; + +/** At least one character of each class, from the CSPRNG by default. */ +export function password(length: number, symbols: boolean, rand: (n: number) => number = randomInt): string { + const classes = [LOWER, UPPER, DIGITS, ...(symbols ? [SYMBOLS] : [])]; + const all = classes.join(""); + for (;;) { + let out = ""; + for (let i = 0; i < length; i++) out += all[rand(all.length)]; + if (length < classes.length || classes.every((c) => [...out].some((ch) => c.includes(ch)))) return out; + } +} + +/** Bits of entropy of a random password. */ +export const entropy = (length: number, symbols: boolean) => + Math.round(length * Math.log2(LOWER.length * 2 + DIGITS.length + (symbols ? SYMBOLS.length : 0))); + +export function strength(bits: number): { text: string; color: Color } { + if (bits < 50) return { text: "Weak", color: "red" }; + if (bits < 80) return { text: "Fair", color: "orange" }; + if (bits < 120) return { text: "Strong", color: "green" }; + return { text: "Very strong", color: "teal" }; +} + +const LOREM = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore " + + "magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo " + + "consequat duis aute irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint " + + "occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum pellentesque " + + "habitant morbi tristique senectus netus malesuada fames turpis egestas vestibulum mauris" +).split(" "); + +export function loremSentence(rand: () => number = Math.random): string { + const n = 8 + Math.floor(rand() * 8); + const ws = Array.from({ length: n }, () => LOREM[Math.floor(rand() * LOREM.length)]); + if (n > 9) ws[Math.floor(n / 2)] += ","; + const s = ws.join(" "); + return `${s.charAt(0).toUpperCase()}${s.slice(1)}.`; +} + +/** The first paragraph starts with the classic "Lorem ipsum dolor sit amet…". */ +export function loremParagraph(rand: () => number = Math.random, first = true): string { + const n = 4 + Math.floor(rand() * 4); + const sentences = Array.from({ length: n }, () => loremSentence(rand)); + if (first) sentences[0] = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."; + return sentences.join(" "); +} + +export const loremParagraphs = (count: number, rand: () => number = Math.random) => + Array.from({ length: count }, (_, i) => loremParagraph(rand, i === 0)).join("\n\n"); + +// --------------------------------------------------------------------------- +// Everything that applies to an input + +export interface Result { + id: string; + /** What the transformation is called: "Base64 Encode". */ + label: string; + /** The output, copied and pasted as is. */ + value: string; + section: string; + icon: string; + tint: Color; + /** A short title instead of the value (counts, JWT). */ + title?: string; + /** Replaces the subtitle (the label by default). */ + subtitle?: string; + tags?: { tag: string; color?: Color }[]; + stats?: Stat[]; + metadata?: Metadata[]; + /** Replaces the output code block in the side panel. */ + markdown?: string; + /** Other values worth copying: "Copy Payload". */ + extra?: { title: string; value: string }[]; +} + +const SECTION = { + detected: "Detected", + date: "Date & Time", + number: "Number Bases", + json: "JSON", + decode: "Decode", + encode: "Encode", + hash: "Hashes", + case: "Case", + text: "Text", +}; + +const pretty = (v: unknown) => JSON.stringify(v, null, 2); + +function jsonShape(v: unknown, depth = 1): { depth: number; values: number } { + if (v && typeof v === "object") { + const children = Object.values(v as Record).map((c) => jsonShape(c, depth + 1)); + return { + depth: Math.max(depth, ...children.map((c) => c.depth)), + values: children.reduce((n, c) => n + c.values, 0) + children.length, + }; + } + return { depth: depth - 1, values: 0 }; +} + +const fmtDate = (d: Date) => d.toISOString().replace("T", " ").replace(/\.000Z$/, "Z").replace(/\.\d+Z$/, "Z"); + +function timeMeta(name: string, value: unknown, now: Date, future = false): Metadata[] { + if (typeof value !== "number") return []; + const d = new Date(value * 1000); + const past = d < now; + return [ + meta.text(name, `${fmtDate(d)} (${relative(d, now)})`, { + icon: Icons.clock, + color: future ? (past ? "red" : "green") : undefined, + }), + ]; +} + +function jwtResults(jwt: Jwt, now: Date): Result[] { + const { header, payload } = jwt; + const exp = typeof payload.exp === "number" ? new Date(payload.exp * 1000) : undefined; + const expired = exp ? exp < now : false; + const str = (v: unknown) => Array.isArray(v) ? v.join(", ") : String(v); + const claims = ["iss", "sub", "aud", "jti", "iat", "nbf", "exp"]; + const custom = Object.keys(payload).filter((k) => !claims.includes(k)); + return [{ + id: "jwt", + label: "JWT", + title: `JWT · ${header.alg ?? "?"}${payload.sub ? ` · ${str(payload.sub)}` : ""}`, + subtitle: exp ? (expired ? `Expired ${relative(exp, now)}` : `Expires ${relative(exp, now)}`) : "No expiry", + value: pretty(payload), + section: SECTION.detected, + icon: Icons.key, + tint: expired ? "red" : "purple", + tags: exp ? [{ tag: expired ? "Expired" : "Valid", color: expired ? "red" : "green" }] : [], + markdown: [ + "## Header", + "```json", + pretty(header), + "```", + "## Payload", + "```json", + pretty(payload), + "```", + "## Signature", + "```", + jwt.signature || "(unsigned)", + "```", + ].join("\n"), + metadata: [ + meta.text("Algorithm", String(header.alg ?? "none"), { icon: Icons.lock }), + ...(header.typ ? [meta.text("Type", String(header.typ))] : []), + ...(header.kid ? [meta.text("Key ID", String(header.kid), { icon: Icons.key })] : []), + meta.separator(), + ...(payload.iss ? [meta.text("Issuer", str(payload.iss), { icon: Icons.globe })] : []), + ...(payload.sub ? [meta.text("Subject", str(payload.sub), { icon: Icons.person })] : []), + ...(payload.aud ? [meta.text("Audience", str(payload.aud))] : []), + ...timeMeta("Issued", payload.iat, now), + ...timeMeta("Not before", payload.nbf, now), + ...timeMeta("Expires", payload.exp, now, true), + ...(custom.length ? [meta.tags("Claims", custom.slice(0, 12))] : []), + ], + extra: [ + { title: "Copy Payload", value: pretty(payload) }, + { title: "Copy Header", value: pretty(header) }, + ], + }]; +} + +function dateResults(date: Date, now: Date, source: string): Result[] { + const ms = date.getTime(); + const out: Result[] = []; + const add = (id: string, label: string, value: string, icon: string = Icons.calendar) => + out.push({ id, label: `${label} · ${source}`, value, section: SECTION.date, icon, tint: "blue" }); + if (source !== "Unix seconds") add("unix-s", "Unix seconds", String(Math.floor(ms / 1000)), Icons.hash); + if (source !== "Unix milliseconds") add("unix-ms", "Unix milliseconds", String(ms), Icons.hash); + add("iso", "ISO 8601 (UTC)", date.toISOString()); + add( + "local", + "Local time", + date.toLocaleString("en-GB", { dateStyle: "full", timeStyle: "long" }), + ); + add("rfc2822", "RFC 2822", date.toUTCString()); + add("relative", "Relative", relative(date, now), Icons.clock); + return out; +} + +/** Every transformation that makes sense for `input`, the most specific first. */ +export async function transforms(input: string, now = new Date()): Promise { + const text = input; + const trimmed = input.trim(); + if (!trimmed) return []; + const out: Result[] = []; + const singleLine = !/\n/.test(trimmed); + + // What the input is. + const jwt = decodeJwt(trimmed); + if (jwt) out.push(...jwtResults(jwt, now)); + + const ts = parseTimestamp(trimmed); + const unitName = { s: "Unix seconds", ms: "Unix milliseconds", "µs": "Unix microseconds", ns: "Unix nanoseconds" }; + if (ts) out.push(...dateResults(ts.date, now, unitName[ts.unit])); + const date = ts ? undefined : parseDate(trimmed); + if (date) out.push(...dateResults(date, now, "date")); + const v7 = uuidTime(trimmed); + if (v7) out.push(...dateResults(v7, now, "UUID v7 time")); + + if (/^[[{]/.test(trimmed)) { + try { + const value = JSON.parse(trimmed); + const shape = jsonShape(value); + const stats: Stat[] = [ + { + label: Array.isArray(value) ? "items" : "keys", + value: Array.isArray(value) ? value.length : Object.keys(value).length, + }, + { label: "values", value: shape.values }, + { label: "depth", value: shape.depth }, + ]; + const minified = JSON.stringify(value); + out.push( + { + id: "json-pretty", + label: "Pretty JSON", + value: pretty(value), + section: SECTION.json, + icon: Icons.braces, + tint: "yellow", + stats, + }, + { + id: "json-min", + label: "Minified JSON", + value: minified, + section: SECTION.json, + icon: Icons.braces, + tint: "yellow", + stats, + }, + { + id: "json-string", + label: "As a JSON string", + value: JSON.stringify(minified), + section: SECTION.json, + icon: Icons.braces, + tint: "yellow", + }, + ); + } catch { /* not JSON */ } + } + + const int = parseInteger(trimmed); + if (int) { + const names = { 2: "Binary", 8: "Octal", 10: "Decimal", 16: "Hexadecimal" }; + for (const base of [10, 16, 2, 8] as Base[]) { + if (base === int.base) continue; + out.push({ + id: `base-${base}`, + label: `${names[base]} · from ${names[int.base].toLowerCase()}`, + value: formatInteger(int.value, base), + section: SECTION.number, + icon: Icons.hash, + tint: "teal", + }); + } + const n = Number(int.value); + if (n >= 1024 && Number.isSafeInteger(n)) { + out.push({ + id: "bytes", + label: "As a size in bytes", + value: humanBytes(n), + section: SECTION.number, + icon: Icons.drive, + tint: "teal", + }); + } + if (n >= 32 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) && n !== 127) { + const cp = `U+${n.toString(16).toUpperCase().padStart(4, "0")}`; + out.push({ + id: "char", + label: `Unicode character ${cp}`, + value: String.fromCodePoint(n), + section: SECTION.number, + icon: Icons.text, + tint: "teal", + }); + } + } + + // Decoding. + const unescaped = jsonUnescape(trimmed); + if (unescaped !== undefined) { + out.push({ + id: "json-unescape", + label: "JSON String Unescape", + value: unescaped, + section: SECTION.decode, + icon: Icons.braces, + tint: "green", + }); + } + const b64 = jwt ? undefined : base64Decode(trimmed); + if (b64 !== undefined && b64 !== trimmed) { + out.push({ + id: "b64-decode", + label: "Base64 Decode", + value: b64, + section: SECTION.decode, + icon: Icons.code, + tint: "green", + }); + } + const url = urlDecode(trimmed); + if (url !== undefined) { + out.push({ + id: "url-decode", + label: "URL Decode", + value: url, + section: SECTION.decode, + icon: Icons.link, + tint: "green", + }); + } + + // Encoding. + out.push({ + id: "b64-encode", + label: "Base64 Encode", + value: base64Encode(text), + section: SECTION.encode, + icon: Icons.code, + tint: "orange", + }); + const b64url = base64Encode(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + if (b64url !== base64Encode(text)) { + out.push({ + id: "b64url-encode", + label: "Base64URL Encode", + value: b64url, + section: SECTION.encode, + icon: Icons.code, + tint: "orange", + }); + } + const encoded = urlEncode(text); + if (encoded !== text) { + out.push({ + id: "url-encode", + label: "URL Encode", + value: encoded, + section: SECTION.encode, + icon: Icons.link, + tint: "orange", + }); + } + if (unescaped === undefined && /["\\\n\t]/.test(text)) { + out.push({ + id: "json-escape", + label: "JSON String Escape", + value: jsonEscape(text), + section: SECTION.encode, + icon: Icons.braces, + tint: "orange", + }); + } + + // Hashes. + const hashes: [string, string][] = [ + ["MD5", md5(text)], + ["SHA-1", await sha("SHA-1", text)], + ["SHA-256", await sha("SHA-256", text)], + ["SHA-512", await sha("SHA-512", text)], + ]; + for (const [name, value] of hashes) { + out.push({ + id: `hash-${name}`, + label: name, + value, + section: SECTION.hash, + icon: Icons.hash, + tint: "purple", + metadata: [meta.text("Algorithm", name, { icon: Icons.shield }), meta.text("Bits", value.length * 4)], + extra: [{ title: "Copy Uppercase", value: value.toUpperCase() }], + }); + } + + // Case. + if (singleLine && trimmed.length <= 200 && /\p{L}/u.test(trimmed) && !jwt) { + const seen = new Set([trimmed]); + for (const c of cases) { + const value = c.convert(trimmed); + if (!value || seen.has(value)) continue; + seen.add(value); + out.push({ id: `case-${c.id}`, label: c.name, value, section: SECTION.case, icon: Icons.text, tint: "pink" }); + } + } + + // Counts. + const c = counts(text); + const summary = `${plural(c.characters, "character")} · ${plural(c.words, "word")} · ${plural(c.lines, "line")}`; + out.push({ + id: "counts", + label: "Counts", + title: summary, + subtitle: `${plural(c.bytes, "byte")} in UTF-8`, + value: summary, + section: SECTION.text, + icon: Icons.calculator, + tint: "gray", + stats: [ + { label: "characters", value: c.characters }, + { label: "words", value: c.words }, + { label: "lines", value: c.lines }, + ], + markdown: "", + metadata: [ + meta.text("Characters", c.characters.toLocaleString("en")), + meta.text("Without spaces", c.withoutSpaces.toLocaleString("en")), + meta.text("Words", c.words.toLocaleString("en")), + meta.text("Sentences", c.sentences.toLocaleString("en")), + meta.text("Lines", c.lines.toLocaleString("en")), + meta.text("Bytes (UTF-8)", `${c.bytes.toLocaleString("en")} (${humanBytes(c.bytes)})`), + meta.text("Reading time", `${Math.max(1, Math.round(c.words / 230))} min`, { icon: Icons.clock }), + ], + extra: [ + { title: "Copy Character Count", value: String(c.characters) }, + { title: "Copy Word Count", value: String(c.words) }, + ], + }); + return out; +} + +/** "password 32" → which generator the query asks for, and an optional number. */ +export function generatorQuery(query: string): { kind: "uuid" | "password" | "lorem" | "now"; n?: number } | undefined { + const m = query.trim().toLowerCase().match(/^([a-z][a-z ]*?)\s*(\d{1,3})?$/); + if (!m) return undefined; + const word = m[1].trim(); + const n = m[2] ? Number(m[2]) : undefined; + if (/^(uuid|guid|uid)$/.test(word)) return { kind: "uuid", n }; + if (/^(password|passwd|pwd|pass|mot de passe|mdp)$/.test(word)) return { kind: "password", n }; + if (/^(lorem|lorem ipsum|ipsum|lipsum)$/.test(word)) return { kind: "lorem", n }; + if (/^(now|timestamp|time|date|maintenant)$/.test(word)) return { kind: "now" }; + return undefined; +} diff --git a/extensions/dev-utils/lib_test.ts b/extensions/dev-utils/lib_test.ts new file mode 100644 index 0000000..421bdc5 --- /dev/null +++ b/extensions/dev-utils/lib_test.ts @@ -0,0 +1,278 @@ +import { assert, assertEquals, assertMatch } from "@std/assert"; +import { + base64Decode, + base64Encode, + cases, + counts, + decodeJwt, + entropy, + formatInteger, + generatorQuery, + humanBytes, + jsonUnescape, + loremParagraph, + loremSentence, + md5, + parseDate, + parseInteger, + parseTimestamp, + password, + relative, + sha, + SYMBOLS, + transforms, + urlDecode, + uuidTime, + uuidV7, + words, +} from "./lib.ts"; + +const NOW = new Date("2026-10-28T12:00:00Z"); + +/** A deterministic PRNG (mulberry32) for generators. */ +function seeded(seed = 42) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +Deno.test("base64 round trips UTF-8 and refuses binary or plain words", () => { + assertEquals(base64Encode("héllo wörld ✓"), "aMOpbGxvIHfDtnJsZCDinJM="); + assertEquals(base64Decode("aMOpbGxvIHfDtnJsZCDinJM="), "héllo wörld ✓"); + assertEquals(base64Decode("aGVsbG8"), "hello"); // no padding + assertEquals(base64Decode("PDw_Pz4-"), "<>"); // URL-safe alphabet + assertEquals(base64Decode("/w=="), undefined); // 0xff is not UTF-8 + assertEquals(base64Decode("AAECAwQF"), undefined); // control characters + assertEquals(base64Decode("hi"), undefined); + assertEquals(base64Decode("not base64!"), undefined); +}); + +Deno.test("URL and JSON string escaping", () => { + assertEquals(urlDecode("a%20b%26c%3Dd"), "a b&c=d"); + assertEquals(urlDecode("q=hello+world"), "q=hello world"); + assertEquals(urlDecode("plain"), undefined); + assertEquals(urlDecode("%E0%A4%A"), undefined); + assertEquals(jsonUnescape('"line\\nnext \\"quoted\\""'), 'line\nnext "quoted"'); + assertEquals(jsonUnescape("no quotes"), undefined); +}); + +Deno.test("md5 and SHA digests match the reference vectors", async () => { + assertEquals(md5(""), "d41d8cd98f00b204e9800998ecf8427e"); + assertEquals(md5("The quick brown fox jumps over the lazy dog"), "9e107d9d372bb6826bd81d3542a419d6"); + assertEquals(md5("a".repeat(1000)), "cabe45dcc9ae5b66ba86600cca6b8ba8"); + assertEquals(md5("héllo"), "be50e8478cf24ff3595bc7307fb91b50"); + assertEquals(await sha("SHA-1", "abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + assertEquals( + await sha("SHA-256", "abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + assertEquals((await sha("SHA-512", "abc")).slice(0, 16), "ddaf35a193617aba"); +}); + +Deno.test("relative times", () => { + assertEquals(relative(new Date("2026-10-28T11:59:58Z"), NOW), "just now"); + assertEquals(relative(new Date("2026-10-28T09:00:00Z"), NOW), "3 hours ago"); + assertEquals(relative(new Date("2026-10-30T12:00:00Z"), NOW), "in 2 days"); + assertEquals(relative(new Date("2023-11-14T22:13:20Z"), NOW), "2 years ago"); + assertEquals(relative(new Date("2026-10-28T12:01:30Z"), NOW), "in 1 minute"); +}); + +Deno.test("timestamps by number of digits, and dates", () => { + assertEquals(parseTimestamp("1700000000")?.date.toISOString(), "2023-11-14T22:13:20.000Z"); + assertEquals(parseTimestamp("1700000000")?.unit, "s"); + assertEquals(parseTimestamp("1700000000123")?.date.toISOString(), "2023-11-14T22:13:20.123Z"); + assertEquals(parseTimestamp("1700000000123")?.unit, "ms"); + assertEquals(parseTimestamp("1700000000123456")?.unit, "µs"); + assertEquals(parseTimestamp("1700000000123456789")?.date.toISOString(), "2023-11-14T22:13:20.123Z"); + assertEquals(parseTimestamp("1700000000.5")?.date.getTime(), 1700000000500); + assertEquals(parseTimestamp("12345"), undefined); + assertEquals(parseDate("2024-02-29T10:00:00Z")?.getTime(), Date.UTC(2024, 1, 29, 10)); + assertEquals(parseDate("Tue, 14 Nov 2023 22:13:20 GMT")?.getTime(), 1700000000000); + assertEquals(parseDate("2024"), undefined); + assertEquals(parseDate("hello world"), undefined); +}); + +// {"alg":"HS256","typ":"JWT"} . {"sub":"1234567890","name":"John Doe","iat":1516239022,"exp":1893456000} +const JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE4OTM0NTYwMDB9." + + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + +Deno.test("JWT decoding", () => { + const jwt = decodeJwt(JWT)!; + assertEquals(jwt.header, { alg: "HS256", typ: "JWT" }); + assertEquals(jwt.payload.sub, "1234567890"); + assertEquals(jwt.payload.exp, 1893456000); + assertEquals(decodeJwt(`Bearer ${JWT}`)?.payload.name, "John Doe"); + assertEquals(decodeJwt("a.b.c"), undefined); + assertEquals(decodeJwt("eyJhbGciOiJIUzI1NiJ9.bm90IGpzb24.x"), undefined); +}); + +Deno.test("integers in any base", () => { + assertEquals(parseInteger("255"), { value: 255n, base: 10 }); + assertEquals(parseInteger("0xFF"), { value: 255n, base: 16 }); + assertEquals(parseInteger("deadbeef"), { value: 3735928559n, base: 16 }); + assertEquals(parseInteger("0b1010_1010"), { value: 170n, base: 2 }); + assertEquals(parseInteger("0o755"), { value: 493n, base: 8 }); + assertEquals(parseInteger("-42"), { value: -42n, base: 10 }); + assertEquals(parseInteger("123456789012345678901234567890")?.value, 123456789012345678901234567890n); + assertEquals(parseInteger("hello"), undefined); + assertEquals(parseInteger("a"), undefined); + assertEquals(formatInteger(255n, 16), "0xff"); + assertEquals(formatInteger(-5n, 2), "-0b101"); + assertEquals(humanBytes(1536), "1.5 KiB"); + assertEquals(humanBytes(1048576), "1 MiB"); + assertEquals(humanBytes(512), "512 B"); +}); + +Deno.test("words and case conversions", () => { + assertEquals(words("getHTTPResponse_code-v2"), ["get", "HTTP", "Response", "code", "v2"]); + assertEquals(words(" hello wörld "), ["hello", "wörld"]); + const convert = (id: string, text: string) => cases.find((c) => c.id === id)!.convert(text); + assertEquals(convert("camel", "user_profile id"), "userProfileId"); + assertEquals(convert("pascal", "user-profile-id"), "UserProfileId"); + assertEquals(convert("snake", "userProfileID"), "user_profile_id"); + assertEquals(convert("constant", "max retry count"), "MAX_RETRY_COUNT"); + assertEquals(convert("kebab", "XMLHttpRequest"), "xml-http-request"); + assertEquals(convert("title", "the_quick-brownFox"), "The Quick Brown Fox"); + assertEquals(convert("upper", "Straße"), "STRASSE"); +}); + +Deno.test("counts", () => { + assertEquals(counts("Hello, world! 👋🏽\nSecond line."), { + characters: 28, + withoutSpaces: 24, + words: 4, + lines: 2, + bytes: 35, + sentences: 2, + }); + assertEquals(counts("").lines, 0); +}); + +Deno.test("UUID v7 encodes its creation time", () => { + const id = uuidV7(1700000000123, new Uint8Array(10).fill(0xff)); + assertEquals(id, "018bcfe5-687b-7fff-bfff-ffffffffffff"); + assertMatch(uuidV7(), /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assertEquals(uuidTime(id)?.getTime(), 1700000000123); + assertEquals(uuidTime(crypto.randomUUID()), undefined); +}); + +Deno.test("passwords use every character class", () => { + const rand = seeded(7); + const int = (n: number) => Math.floor(rand() * n); + for (const length of [8, 16, 64]) { + const p = password(length, true, int); + assertEquals(p.length, length); + assert(/[a-z]/.test(p) && /[A-Z]/.test(p) && /\d/.test(p), p); + assert([...p].some((c) => SYMBOLS.includes(c)), p); + } + assert(/^[A-Za-z0-9]{24}$/.test(password(24, false))); + assertEquals(entropy(20, true), 129); + assertEquals(entropy(20, false), 119); +}); + +Deno.test("lorem ipsum", () => { + const rand = seeded(1); + const s = loremSentence(rand); + assertMatch(s, /^[A-Z][a-z, ]+\.$/); + assert(loremParagraph(rand).startsWith("Lorem ipsum dolor sit amet, consectetur adipiscing elit. ")); + assert(!loremParagraph(rand, false).startsWith("Lorem ipsum dolor sit amet, consectetur")); +}); + +Deno.test("generator queries", () => { + assertEquals(generatorQuery("password 32"), { kind: "password", n: 32 }); + assertEquals(generatorQuery("UUID"), { kind: "uuid", n: undefined }); + assertEquals(generatorQuery("mot de passe"), { kind: "password", n: undefined }); + assertEquals(generatorQuery("lorem 3"), { kind: "lorem", n: 3 }); + assertEquals(generatorQuery("now")?.kind, "now"); + assertEquals(generatorQuery("hello"), undefined); +}); + +const ids = async (input: string) => (await transforms(input, NOW)).map((r) => r.id); + +Deno.test("transforms: a timestamp", async () => { + const results = await transforms("1700000000", NOW); + const iso = results.find((r) => r.id === "iso"); + assertEquals(iso?.value, "2023-11-14T22:13:20.000Z"); + assertEquals(iso?.label, "ISO 8601 (UTC) · Unix seconds"); + assertEquals(results.find((r) => r.id === "relative")?.value, "2 years ago"); + assertEquals(results.find((r) => r.id === "unix-ms")?.value, "1700000000000"); + // It is a number too. + assertEquals(results.find((r) => r.id === "base-16")?.value, "0x6553f100"); + // Date sections come first. + assertEquals(results[0].section, "Date & Time"); +}); + +Deno.test("transforms: a date string", async () => { + const results = await transforms("2024-01-01T00:00:00Z", NOW); + assertEquals(results.find((r) => r.id === "unix-s")?.value, "1704067200"); + assertEquals(results.find((r) => r.id === "unix-ms")?.value, "1704067200000"); +}); + +Deno.test("transforms: a JWT", async () => { + const [jwt] = await transforms(JWT, NOW); + assertEquals(jwt.id, "jwt"); + assertEquals(jwt.title, "JWT · HS256 · 1234567890"); + assertEquals(jwt.subtitle, "Expires in 3 years"); + assertEquals(jwt.tags, [{ tag: "Valid", color: "green" }]); + assert(jwt.markdown?.includes('"name": "John Doe"')); + const labels = jwt.metadata!.map((m) => m.label); + assert(labels.includes("Expires") && labels.includes("Issued") && labels.includes("Subject")); + const expired = await transforms(JWT, new Date("2031-01-01T00:00:00Z")); + assertEquals(expired[0].tags, [{ tag: "Expired", color: "red" }]); + assertEquals(expired[0].tint, "red"); + // Its parts are not offered as base64 or case conversions. + assert(!(await ids(JWT)).some((id) => id === "b64-decode" || id.startsWith("case-"))); +}); + +Deno.test("transforms: JSON", async () => { + const results = await transforms('{"name":"backslash","tags":["a","b"],"nested":{"ok":true}}', NOW); + const pretty = results.find((r) => r.id === "json-pretty")!; + assert(pretty.value.startsWith('{\n "name": "backslash"')); + assertEquals(pretty.stats, [{ label: "keys", value: 3 }, { label: "values", value: 6 }, { + label: "depth", + value: 2, + }]); + assertEquals( + results.find((r) => r.id === "json-min")?.value, + '{"name":"backslash","tags":["a","b"],"nested":{"ok":true}}', + ); + assert(!(await ids("{not json")).includes("json-pretty")); +}); + +Deno.test("transforms: numbers", async () => { + const results = await transforms("0x41", NOW); + assertEquals(results.find((r) => r.id === "base-10")?.value, "65"); + assertEquals(results.find((r) => r.id === "base-2")?.value, "0b1000001"); + assertEquals(results.find((r) => r.id === "base-8")?.value, "0o101"); + assertEquals(results.find((r) => r.id === "char")?.value, "A"); + assert(!results.some((r) => r.id === "base-16")); + assertEquals((await transforms("1048576", NOW)).find((r) => r.id === "bytes")?.value, "1 MiB"); +}); + +Deno.test("transforms: encodings, hashes, case and counts", async () => { + const results = await transforms("hello world", NOW); + const value = (id: string) => results.find((r) => r.id === id)?.value; + assertEquals(value("b64-encode"), "aGVsbG8gd29ybGQ="); + assertEquals(value("url-encode"), "hello%20world"); + assertEquals(value("hash-MD5"), "5eb63bbbe01eeed093cb22bb8f5acdc3"); + assertEquals(value("hash-SHA-256"), "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"); + assertEquals(value("case-camel"), "helloWorld"); + assertEquals(value("case-snake"), "hello_world"); + assertEquals(value("case-title"), "Hello World"); + assertEquals(value("case-upper"), "HELLO WORLD"); + assert(!results.some((r) => r.id === "case-lower")); // same as the input + assertEquals(results.find((r) => r.id === "counts")?.title, "11 characters · 2 words · 1 line"); + assertEquals(value("b64-decode"), undefined); + // Decoding a base64 value and a URL-encoded one. + assertEquals((await transforms("aGVsbG8gd29ybGQ=", NOW)).find((r) => r.id === "b64-decode")?.value, "hello world"); + assertEquals((await transforms("a%2Fb%3Fc", NOW)).find((r) => r.id === "url-decode")?.value, "a/b?c"); + // Sections come in a stable order: specific first, counts last. + const sections = [...new Set(results.map((r) => r.section))]; + assertEquals(sections, ["Encode", "Hashes", "Case", "Text"]); + assertEquals(await transforms(" ", NOW), []); +}); diff --git a/extensions/distrobox/backslash.toml b/extensions/distrobox/backslash.toml new file mode 100644 index 0000000..21329a0 --- /dev/null +++ b/extensions/distrobox/backslash.toml @@ -0,0 +1,57 @@ +[extension] +id = "distrobox" +title = "Containers & VMs" +description = "Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines." +version = "1.0.0" +author = "Backslash" +icon = "bs-layers" +license = "MIT" + +[permissions] +run = [ + "distrobox", + "toolbox", + "podman", + "virsh", + "virt-manager", + "virt-viewer", + "gnome-boxes", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "machines" +title = "Containers & VMs" +description = "distrobox and toolbx containers, libvirt virtual machines" +icon = "bs-layers" +keywords = [ + "distrobox", + "toolbox", + "toolbx", + "container", + "vm", + "virtual machine", + "libvirt", + "qemu", + "kvm", + "virt-manager", + "boxes", + "conteneur", + "machine virtuelle", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/distrobox/commands/machines.ts b/extensions/distrobox/commands/machines.ts new file mode 100644 index 0000000..5a00688 --- /dev/null +++ b/extensions/distrobox/commands/machines.ts @@ -0,0 +1,323 @@ +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + list, + meta, + type Metadata, + refresh, + toast, +} from "@backslash/api"; +import { + type Container, + enterCommand, + memory, + mergeContainers, + parseDistrobox, + parseDomInfo, + parseToolbox, + parseVirshList, + removeCommand, + scope, + shortStatus, + STATE_COLORS, + STATE_TITLES, + stopCommand, + virsh, + type Vm, + vmState, +} from "../lib.ts"; +import { launch, runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +const URIS = ["qemu:///session", "qemu:///system"]; + +/** Output of a program, or undefined when it is missing or fails. */ +async function tryExec(program: string, args: string[]): Promise { + try { + return await exec(program, args); + } catch { + return undefined; + } +} + +interface Found { + containers: Container[]; + vms: Vm[]; + backends: string[]; +} + +async function load(): Promise { + const [db, tb, ...virshes] = await Promise.all([ + tryExec("distrobox", ["list", "--no-color"]), + tryExec("toolbox", ["list", "--containers"]), + ...URIS.map((uri) => tryExec("virsh", ["-c", uri, "list", "--all"])), + ]); + const backends = [ + ...(db !== undefined ? ["distrobox"] : []), + ...(tb !== undefined ? ["toolbx"] : []), + ...(virshes.some((v) => v !== undefined) ? ["libvirt"] : []), + ]; + return { + containers: mergeContainers(parseDistrobox(db ?? ""), parseToolbox(tb ?? "")), + vms: URIS.flatMap((uri, i) => parseVirshList(virshes[i] ?? "", uri)), + backends, + }; +} + +function containerItem(c: Container, prefs: Prefs): Item { + const term = prefs.terminal || undefined; + const name = c.kind === "distrobox" ? "Distrobox" : "Toolbx"; + const run = (argv: string[], done: string) => async () => { + await exec(argv[0], argv.slice(1)); + await toast(done, { style: "success" }); + return refresh(); + }; + const enter = enterCommand(c); + const actions: Action[] = [ + action("Enter in Terminal", () => { + runInTerminal(enter, term); + return close(); + }, { icon: Icons.terminal }), + ]; + if (c.running) { + actions.push(action("Stop", run(stopCommand(c), `${c.name} stopped`), { + icon: Icons.stop, + shortcut: "s", + section: "Container", + })); + } + if (c.kind === "distrobox") { + actions.push(action("Upgrade Packages in Terminal", () => { + runInTerminal(["distrobox", "upgrade", c.name], term); + return close(); + }, { icon: Icons.download, shortcut: "u", section: "Container" })); + } + actions.push( + copy(shellLine(enter), "Copy Enter Command", { icon: Icons.terminal, shortcut: "c", section: "Copy" }), + copy(c.name, "Copy Name", { section: "Copy" }), + copy(c.image, "Copy Image", { icon: Icons.package, section: "Copy" }), + action("Remove Container", run(removeCommand(c), `${c.name} removed`), { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${c.name}? Files outside your home folder are lost.`, + section: "Danger", + }), + ); + const status = shortStatus(c.status); + return { + id: `${c.kind}:${c.name}`, + title: c.name, + subtitle: c.image.replace(/^(docker\.io|registry\.fedoraproject\.org|quay\.io|ghcr\.io)\//, ""), + icon: { source: Icons.box, tint: c.running ? "green" : "gray" }, + section: name, + keywords: [c.image, c.kind, c.id], + accessories: [{ tag: c.running ? "Running" : "Stopped", color: c.running ? "green" : "gray" }, { text: status }], + detail: { + markdown: `\`\`\`sh\n${shellLine(enter)}\n\`\`\``, + metadata: [ + meta.text("Status", status, { icon: Icons.dot, color: c.running ? "green" : "gray" }), + meta.text("Image", c.image, { icon: Icons.package }), + meta.text("Tool", name, { icon: Icons.box }), + ...(c.created ? [meta.text("Created", c.created, { icon: Icons.calendar })] : []), + meta.separator(), + meta.text("ID", c.id.slice(0, 12)), + ], + }, + actions, + }; +} + +function vmItem(vm: Vm, prefs: Prefs, info?: Record): Item { + const state = vmState(vm.state); + const color = STATE_COLORS[state]; + const on = state === "running" || state === "paused"; + const run = (verb: string, done: string) => async () => { + await exec("virsh", virsh(vm, verb).slice(1)); + await toast(done, { style: "success" }); + return refresh(); + }; + const actions: Action[] = []; + const openConsole = action("Open Console", () => { + if ( + !launch("virt-manager", ["--connect", vm.uri, "--show-domain-console", vm.name]) && + !launch("virt-viewer", ["--connect", vm.uri, "--wait", vm.name]) + ) { + throw new Error("Install virt-manager or virt-viewer to open the console"); + } + return close(); + }, { icon: Icons.monitor }); + if (state === "off" || state === "crashed") { + actions.push(action("Start", run("start", `${vm.name} started`), { icon: Icons.play }), openConsole); + } else { + actions.push(openConsole); + if (state === "paused" || state === "suspended") { + actions.push(action("Resume", run(state === "paused" ? "resume" : "dompmwakeup", `${vm.name} resumed`), { + icon: Icons.play, + shortcut: "p", + section: "Power", + })); + } else { + actions.push(action("Pause", run("suspend", `${vm.name} paused`), { + icon: Icons.pause, + shortcut: "p", + section: "Power", + })); + } + actions.push( + action("Shut Down", run("shutdown", `Shutting down ${vm.name}`), { + icon: Icons.power, + shortcut: "s", + section: "Power", + }), + action("Reboot", run("reboot", `Rebooting ${vm.name}`), { + icon: Icons.refresh, + shortcut: "r", + section: "Power", + }), + ); + } + actions.push( + action("Open in Virtual Machine Manager", () => { + if (!launch("virt-manager", ["--connect", vm.uri, "--show-domain-editor", vm.name])) { + throw new Error("virt-manager is not installed"); + } + return close(); + }, { icon: Icons.settings, section: "Open" }), + action("Serial Console in Terminal", () => { + runInTerminal(virsh(vm, "console"), prefs.terminal || undefined); + return close(); + }, { icon: Icons.terminal, section: "Open" }), + action("Open GNOME Boxes", () => { + if (!launch("gnome-boxes")) throw new Error("GNOME Boxes is not installed"); + return close(); + }, { icon: Icons.apps, section: "Open" }), + copy(vm.name, "Copy Name", { section: "Copy" }), + copy(shellLine(virsh(vm, on ? "shutdown" : "start")), on ? "Copy Shutdown Command" : "Copy Start Command", { + icon: Icons.terminal, + section: "Copy", + }), + ); + if (on) { + actions.push(action("Force Off", run("destroy", `${vm.name} powered off`), { + icon: Icons.power, + destructive: true, + confirm: `Force off ${vm.name}? Like pulling the plug: unsaved work is lost.`, + section: "Danger", + })); + } + const cpus = info?.["CPU(s)"]; + const mem = memory(info?.["Max memory"]); + const metadata: Metadata[] = [ + meta.text("State", STATE_TITLES[state], { icon: Icons.dot, color }), + meta.text("Connection", `${scope(vm.uri)} (${vm.uri})`, { icon: Icons.plug }), + ...(info?.["OS Type"] ? [meta.text("OS type", info["OS Type"])] : []), + ...(info?.Autostart ? [meta.text("Autostart", info.Autostart === "enable" ? "Yes" : "No")] : []), + ...(info?.Persistent ? [meta.text("Persistent", info.Persistent)] : []), + meta.separator(), + ...(vm.id ? [meta.text("ID", vm.id)] : []), + ...(info?.UUID ? [meta.text("UUID", info.UUID)] : []), + ]; + return { + id: `${vm.uri}:${vm.name}`, + title: vm.name, + subtitle: [cpus ? `${cpus} vCPU` : "", mem ?? ""].filter(Boolean).join(" · ") || undefined, + icon: { source: Icons.monitor, tint: color }, + section: `Virtual Machines (${scope(vm.uri)})`, + keywords: ["vm", "libvirt", "qemu", scope(vm.uri)], + accessories: [{ tag: STATE_TITLES[state], color }], + detail: { + stats: info + ? [ + ...(cpus ? [{ label: "vCPUs", value: cpus }] : []), + ...(mem ? [{ label: "memory", value: mem }] : []), + ...(memory(info["Used memory"]) && on ? [{ label: "in use", value: memory(info["Used memory"])! }] : []), + ] + : undefined, + metadata, + }, + actions, + }; +} + +let summary = ""; + +const missingItem: Item = { + id: "missing", + title: "No container or VM tool found", + subtitle: "Install distrobox, toolbx or libvirt", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This command lists:", + "", + "- **distrobox** and **toolbx** containers, to enter them in a terminal", + "- **libvirt** virtual machines (virt-manager, GNOME Boxes), to start, stop and open them", + "", + "```sh", + "sudo dnf install distrobox libvirt-client virt-manager # Fedora", + "sudo apt install distrobox libvirt-clients virt-manager # Debian, Ubuntu", + "```", + ].join("\n"), + }, + actions: [copy("sudo apt install distrobox libvirt-clients virt-manager", "Copy apt Command")], +}; + +export default list({ + placeholder: "Filter containers and virtual machines…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "containers", title: "Containers" }, + { value: "vms", title: "Virtual Machines" }, + ], + default: "all", + }, + empty: { + title: "Nothing here yet", + description: "Create a container with distrobox create, or a VM with virt-manager or GNOME Boxes", + icon: Icons.layers, + }, + status: () => summary, + async *items({ preferences, filter }) { + const prefs = preferences as Prefs; + const found = await load(); + if (!found.backends.length) { + summary = ""; + yield [missingItem]; + return; + } + const containers = filter === "vms" ? [] : found.containers; + const vms = filter === "containers" ? [] : found.vms; + const up = (n: number, of: number, word: string) => `${of} ${word}${of === 1 ? "" : "s"} (${n} running)`; + summary = [ + ...(filter !== "vms" + ? [up(found.containers.filter((c) => c.running).length, found.containers.length, "container")] + : []), + ...(filter !== "containers" + ? [up(found.vms.filter((v) => vmState(v.state) === "running").length, found.vms.length, "VM")] + : []), + ].join(" · "); + const sorted = [...containers].sort((a, b) => Number(b.running) - Number(a.running)); + const vmOrder = [...vms].sort((a, b) => + Number(vmState(b.state) === "running") - Number(vmState(a.state) === "running") + ); + yield [...sorted.map((c) => containerItem(c, prefs)), ...vmOrder.map((vm) => vmItem(vm, prefs))]; + if (!vmOrder.length) return; + // Then CPUs and memory, one virsh call per VM. + const infos = await Promise.all( + vmOrder.map(async (vm) => parseDomInfo(await tryExec("virsh", virsh(vm, "dominfo").slice(1)) ?? "")), + ); + yield [ + ...sorted.map((c) => containerItem(c, prefs)), + ...vmOrder.map((vm, i) => vmItem(vm, prefs, infos[i])), + ]; + }, +}); diff --git a/extensions/distrobox/lib.ts b/extensions/distrobox/lib.ts new file mode 100644 index 0000000..2335bc2 --- /dev/null +++ b/extensions/distrobox/lib.ts @@ -0,0 +1,143 @@ +// Parsing distrobox, toolbx and virsh output. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +export type Kind = "distrobox" | "toolbox"; + +export interface Container { + kind: Kind; + id: string; + name: string; + /** As printed: "Up 2 hours", "Exited (0) 3 days ago", "running"… */ + status: string; + running: boolean; + image: string; + created?: string; +} + +export interface Vm { + /** `qemu:///session` or `qemu:///system`. */ + uri: string; + id?: string; + name: string; + state: string; +} + +const running = (status: string) => /^(up|running)\b/i.test(status.trim()); + +/** `distrobox list --no-color`: a table with `|` between columns. */ +export function parseDistrobox(out: string): Container[] { + return out.split("\n").flatMap((line) => { + const cols = line.split("|").map((c) => c.trim()); + if (cols.length < 4 || cols[0] === "ID" || !cols[1]) return []; + return [{ + kind: "distrobox" as const, + id: cols[0], + name: cols[1], + status: cols[2], + running: running(cols[2]), + image: cols[3], + }]; + }); +} + +/** `toolbox list --containers`: columns separated by two spaces or more. */ +export function parseToolbox(out: string): Container[] { + const lines = out.split("\n").filter((l) => l.trim()); + const header = lines.findIndex((l) => /^CONTAINER ID\s/.test(l)); + if (header < 0) return []; + return lines.slice(header + 1).flatMap((line) => { + if (/^IMAGE ID\s/.test(line)) return []; + const [id, name, created, status, image] = line.trim().split(/\s{2,}/); + if (!id || !name || !status) return []; + return [{ kind: "toolbox" as const, id, name, created, status, running: running(status), image: image ?? "" }]; + }); +} + +/** Same name in both lists (distrobox also lists toolbx containers): keep the distrobox one. */ +export function mergeContainers(distrobox: Container[], toolbox: Container[]): Container[] { + const names = new Set(distrobox.map((c) => c.name)); + return [...distrobox, ...toolbox.filter((c) => !names.has(c.name))]; +} + +/** `virsh list --all`. */ +export function parseVirshList(out: string, uri: string): Vm[] { + return out.split("\n").flatMap((line) => { + const t = line.trim(); + if (!t || /^-+$/.test(t) || /^Id\s+Name\s+State/.test(t)) return []; + const cols = t.split(/\s{2,}/); + if (cols.length < 3) return []; + const [id, name, ...state] = cols; + return [{ uri, id: id === "-" ? undefined : id, name, state: state.join(" ") }]; + }); +} + +/** `virsh dominfo`: `Key: value` lines. */ +export function parseDomInfo(out: string): Record { + const info: Record = {}; + for (const line of out.split("\n")) { + const m = line.match(/^([^:]+):\s*(.*)$/); + if (m) info[m[1].trim()] = m[2].trim(); + } + return info; +} + +/** "4194304 KiB" → "4 GiB". */ +export function memory(text: string | undefined): string | undefined { + const m = text?.match(/^(\d+)\s*KiB$/); + if (!m) return text || undefined; + const kib = Number(m[1]); + if (kib >= 1048576) return `${+(kib / 1048576).toFixed(1)} GiB`; + if (kib >= 1024) return `${Math.round(kib / 1024)} MiB`; + return `${kib} KiB`; +} + +export type VmState = "running" | "paused" | "off" | "crashed" | "suspended" | "transition"; + +export function vmState(state: string): VmState { + const s = state.toLowerCase(); + if (s === "running" || s === "idle" || s === "blocked") return "running"; + if (s === "paused") return "paused"; + if (s === "crashed") return "crashed"; + if (s === "pmsuspended") return "suspended"; + if (s === "in shutdown") return "transition"; + return "off"; +} + +export const STATE_COLORS: Record = { + running: "green", + paused: "yellow", + off: "gray", + crashed: "red", + suspended: "blue", + transition: "orange", +}; + +export const STATE_TITLES: Record = { + running: "Running", + paused: "Paused", + off: "Shut off", + crashed: "Crashed", + suspended: "Suspended", + transition: "Shutting down", +}; + +/** `Exited (0) 3 days ago` → "Exited 3 days ago"; `Up 2 hours` → "Up 2 hours". */ +export const shortStatus = (status: string) => status.replace(/\s*\(\d+\)/, "").trim(); + +export function enterCommand(c: Container): string[] { + return c.kind === "distrobox" ? ["distrobox", "enter", c.name] : ["toolbox", "enter", c.name]; +} + +export function stopCommand(c: Container): string[] { + // toolbx has no stop command: its containers are plain Podman ones. + return c.kind === "distrobox" ? ["distrobox", "stop", "--yes", c.name] : ["podman", "stop", c.name]; +} + +export function removeCommand(c: Container): string[] { + return c.kind === "distrobox" ? ["distrobox", "rm", "--force", c.name] : ["toolbox", "rm", "--force", c.name]; +} + +export const virsh = (vm: Vm, verb: string) => ["virsh", "-c", vm.uri, verb, vm.name]; + +export const scope = (uri: string) => (uri.endsWith("/system") ? "System" : "Session"); diff --git a/extensions/distrobox/lib_test.ts b/extensions/distrobox/lib_test.ts new file mode 100644 index 0000000..9f39fc5 --- /dev/null +++ b/extensions/distrobox/lib_test.ts @@ -0,0 +1,108 @@ +import { assertEquals } from "@std/assert"; +import { + enterCommand, + memory, + mergeContainers, + parseDistrobox, + parseDomInfo, + parseToolbox, + parseVirshList, + removeCommand, + shortStatus, + stopCommand, + virsh, + vmState, +} from "./lib.ts"; +import { terminalArgs } from "./terminal.ts"; + +const DISTROBOX = `ID | NAME | STATUS | IMAGE +d8a8e1ce1b85 | arch | Up 2 hours | quay.io/toolbx/arch-toolbox:latest +5f3c2a1b0e9d | ubuntu-24 | Exited (0) 3 days ago | docker.io/library/ubuntu:24.04 +a1b2c3d4e5f6 | fedora-toolbox-41 | Created | registry.fedoraproject.org/fedora-toolbox:41`; + +const TOOLBOX = `CONTAINER ID CONTAINER NAME CREATED STATUS IMAGE NAME +ee4e1ed1a7c2 fedora-toolbox-41 2 months ago exited registry.fedoraproject.org/fedora-toolbox:41 +1234567890ab rhel-dev 5 days ago running registry.access.redhat.com/ubi9/toolbox:latest`; + +const VIRSH = ` Id Name State +------------------------------ + 1 win11 running + 3 debian-srv paused + - fedora-41 shut off + - broken vm crashed +`; + +const DOMINFO = `Id: 1 +Name: win11 +UUID: 3f2504e0-4f89-11d3-9a0c-0305e82c3301 +OS Type: hvm +State: running +CPU(s): 4 +CPU time: 1234.5s +Max memory: 8388608 KiB +Used memory: 8388608 KiB +Persistent: yes +Autostart: disable +Managed save: no +Security model: none +`; + +Deno.test("distrobox list", () => { + const boxes = parseDistrobox(DISTROBOX); + assertEquals(boxes.map((b) => [b.name, b.running, b.image]), [ + ["arch", true, "quay.io/toolbx/arch-toolbox:latest"], + ["ubuntu-24", false, "docker.io/library/ubuntu:24.04"], + ["fedora-toolbox-41", false, "registry.fedoraproject.org/fedora-toolbox:41"], + ]); + assertEquals(boxes[0].id, "d8a8e1ce1b85"); + assertEquals(shortStatus(boxes[1].status), "Exited 3 days ago"); + assertEquals(parseDistrobox(""), []); +}); + +Deno.test("toolbox list, merged without duplicates", () => { + const tools = parseToolbox( + `IMAGE ID IMAGE NAME CREATED\nc49cb1e19ad3 registry.fedoraproject.org/fedora-toolbox:41 2 months ago\n\n${TOOLBOX}`, + ); + assertEquals(tools.map((t) => [t.name, t.running, t.created]), [ + ["fedora-toolbox-41", false, "2 months ago"], + ["rhel-dev", true, "5 days ago"], + ]); + const merged = mergeContainers(parseDistrobox(DISTROBOX), tools); + assertEquals(merged.map((c) => `${c.kind}:${c.name}`), [ + "distrobox:arch", + "distrobox:ubuntu-24", + "distrobox:fedora-toolbox-41", + "toolbox:rhel-dev", + ]); + assertEquals(parseToolbox("No containers"), []); +}); + +Deno.test("virsh list and dominfo", () => { + const vms = parseVirshList(VIRSH, "qemu:///session"); + assertEquals(vms.map((v) => [v.id, v.name, v.state, vmState(v.state)]), [ + ["1", "win11", "running", "running"], + ["3", "debian-srv", "paused", "paused"], + [undefined, "fedora-41", "shut off", "off"], + [undefined, "broken vm", "crashed", "crashed"], + ]); + const info = parseDomInfo(DOMINFO); + assertEquals(info["CPU(s)"], "4"); + assertEquals(info["OS Type"], "hvm"); + assertEquals(memory(info["Max memory"]), "8 GiB"); + assertEquals(memory("1572864 KiB"), "1.5 GiB"); + assertEquals(memory("524288 KiB"), "512 MiB"); + assertEquals(memory(undefined), undefined); +}); + +Deno.test("commands", () => { + const [arch] = parseDistrobox(DISTROBOX); + const [, rhel] = parseToolbox(TOOLBOX); + assertEquals(enterCommand(arch), ["distrobox", "enter", "arch"]); + assertEquals(enterCommand(rhel), ["toolbox", "enter", "rhel-dev"]); + assertEquals(stopCommand(arch), ["distrobox", "stop", "--yes", "arch"]); + assertEquals(stopCommand(rhel), ["podman", "stop", "rhel-dev"]); + assertEquals(removeCommand(rhel), ["toolbox", "rm", "--force", "rhel-dev"]); + const [win] = parseVirshList(VIRSH, "qemu:///system"); + assertEquals(virsh(win, "shutdown"), ["virsh", "-c", "qemu:///system", "shutdown", "win11"]); + assertEquals(terminalArgs("ptyxis", enterCommand(arch)), ["--", "distrobox", "enter", "arch"]); +}); diff --git a/extensions/distrobox/terminal.ts b/extensions/distrobox/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/distrobox/terminal.ts @@ -0,0 +1,77 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/docs/backslash.toml b/extensions/docs/backslash.toml new file mode 100644 index 0000000..bc759a6 --- /dev/null +++ b/extensions/docs/backslash.toml @@ -0,0 +1,78 @@ +[extension] +id = "docs" +title = "Docs" +description = "Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages." +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +[permissions] +net = ["devdocs.io", "documents.devdocs.io", "tldr.sh", "raw.githubusercontent.com", "wiki.archlinux.org"] +run = [ + "man", + "apropos", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "devdocs" +title = "Search DevDocs" +description = "API documentation of your languages and frameworks, from devdocs.io" +icon = "bs-book" +keywords = ["devdocs", "docs", "documentation", "api", "reference", "mdn", "javascript", "python", "référence"] + +[[commands]] +name = "choose-docs" +title = "Choose DevDocs" +description = "Enable the documentations Search DevDocs looks in" +icon = "bs-settings" +keywords = ["devdocs", "docs", "enable", "documentation", "choisir", "activer"] + +[[commands]] +name = "tldr" +title = "tldr Pages" +description = "Practical examples of command-line tools" +icon = "bs-terminal" +keywords = ["tldr", "cheatsheet", "examples", "command", "cli", "help", "exemples", "aide", "commande"] + +[[commands]] +name = "arch-wiki" +title = "Search Arch Wiki" +description = "Articles of wiki.archlinux.org, readable in the launcher" +icon = "bs-globe" +keywords = ["arch", "wiki", "archlinux", "linux", "howto", "guide", "tutoriel"] + +[[commands]] +name = "man" +title = "Man Pages" +description = "Search and read the manual pages installed on this computer" +icon = "bs-book" +keywords = ["man", "manual", "apropos", "help", "page", "manuel", "aide"] + +[[preferences]] +name = "docs" +title = "DevDocs to search" +description = "Comma-separated names or slugs (javascript, python, rust, python~3.12), on top of the ones picked with Choose DevDocs" + +[[preferences]] +name = "language" +title = "tldr language" +description = "Two-letter code (fr, de, es…); pages not translated yet are shown in English" +default = "en" + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For man pages opened in a terminal. Leave empty to use the first one installed" diff --git a/extensions/docs/commands/arch-wiki.ts b/extensions/docs/commands/arch-wiki.ts new file mode 100644 index 0000000..ca161ab --- /dev/null +++ b/extensions/docs/commands/arch-wiki.ts @@ -0,0 +1,124 @@ +import { action, close, copy, detail, Icons, type Item, list, meta, open, openExternal, storage } from "@backslash/api"; +import { parseWikiSearch, type WikiHit, wikitextToMarkdown, wikiUrl } from "../lib.ts"; +import { reach } from "../devdocs.ts"; + +const API = "https://wiki.archlinux.org/api.php"; + +async function remember(title: string) { + const recent = (await storage.get("wiki-recent")) ?? []; + await storage.set("wiki-recent", [title, ...recent.filter((t) => t !== title)].slice(0, 12)); +} + +async function article(title: string) { + const url = `${API}?action=parse&format=json&formatversion=2&redirects=1&prop=wikitext|sections&page=${ + encodeURIComponent(title) + }`; + const res = await reach(url); + if (!res.ok) throw new Error(`wiki.archlinux.org answered ${res.status}`); + const data = await res.json(); + if (data.error) throw new Error(data.error.info ?? "Page not found"); + await remember(data.parse.title); + const page = data.parse.title as string; + const sections = (data.parse.sections ?? []) as { line: string; toclevel: number }[]; + const markdown = wikitextToMarkdown(data.parse.wikitext ?? ""); + const words = (data.parse.wikitext as string).split(/\s+/).length; + return detail({ + title: page, + markdown: `# ${page}\n\n${markdown}`, + metadata: [ + meta.text("Article", page, { icon: Icons.book }), + meta.text("Sections", sections.filter((s) => s.toclevel === 1).length, { icon: Icons.text }), + meta.text("Reading time", `${Math.max(1, Math.round(words / 230))} min`, { icon: Icons.clock }), + ...(sections.length + ? [ + meta.tags( + "Contents", + sections.filter((s) => s.toclevel === 1).slice(0, 10).map((s) => s.line.replace(/<[^>]+>/g, "")), + ), + ] + : []), + meta.separator(), + meta.link("Wiki", `wiki.archlinux.org/title/${page.replace(/ /g, "_")}`.slice(0, 60), wikiUrl(page)), + ], + status: "Arch Wiki · CC BY-SA 4.0", + actions: [ + open(wikiUrl(page), "Open in Browser"), + copy(wikiUrl(page), "Copy URL", { shortcut: "c" }), + copy(`[${page}](${wikiUrl(page)})`, "Copy as Markdown Link"), + ], + }); +} + +function item(hit: WikiHit, section?: string): Item { + const url = wikiUrl(hit.title); + const updated = hit.timestamp ? new Date(hit.timestamp) : undefined; + return { + id: hit.title, + title: hit.title, + subtitle: hit.snippet || undefined, + icon: { source: Icons.book, tint: "blue" }, + section, + accessories: hit.words ? [{ text: `${hit.words.toLocaleString("en")} words`, tooltip: "Article length" }] : [], + detail: section ? undefined : { + markdown: hit.snippet ? `…${hit.snippet}…` : undefined, + metadata: [ + meta.text("Article", hit.title, { icon: Icons.book }), + ...(hit.words ? [meta.text("Words", hit.words.toLocaleString("en"), { icon: Icons.text })] : []), + ...(hit.words + ? [meta.text("Reading time", `${Math.max(1, Math.round(hit.words / 230))} min`, { icon: Icons.clock })] + : []), + ...(updated + ? [ + meta.text("Updated", updated.toLocaleDateString("en-GB", { dateStyle: "medium" }), { + icon: Icons.calendar, + }), + ] + : []), + meta.separator(), + meta.link("Wiki", url.replace(/^https:\/\//, "").slice(0, 60), url), + ], + }, + actions: [ + action("Read Here", () => article(hit.title), { icon: Icons.book }), + action("Open in Browser", async () => { + await openExternal(url); + await remember(hit.title); + return close(); + }, { icon: Icons.external }), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${hit.title}](${url})`, "Copy as Markdown Link", { section: "Copy" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Search the Arch Wiki…", + empty: { title: "Nothing found on the Arch Wiki", icon: Icons.search }, + status: () => summary, + async search(query, { signal }) { + summary = ""; + if (!query.trim()) { + const recent = (await storage.get("wiki-recent")) ?? []; + return recent.map((title) => item({ title, snippet: "" }, "Recently Read")); + } + const url = `${API}?action=query&list=search&format=json&formatversion=2&srlimit=25` + + `&srprop=snippet|wordcount|size|timestamp&srsearch=${encodeURIComponent(query)}`; + const res = await reach(url, signal); + if (!res.ok) throw new Error(`wiki.archlinux.org answered ${res.status}`); + const { total, hits } = parseWikiSearch(await res.text()); + summary = `${total.toLocaleString("en")} article${total === 1 ? "" : "s"}`; + const search = `https://wiki.archlinux.org/index.php?search=${encodeURIComponent(query)}&fulltext=1`; + return [ + ...hits.map((h) => item(h)), + { + id: "search-web", + title: `Search “${query}” on wiki.archlinux.org`, + icon: { source: Icons.globe, tint: "gray" }, + section: "More", + actions: [open(search, "Open Search Page")], + }, + ]; + }, +}); diff --git a/extensions/docs/commands/choose-docs.ts b/extensions/docs/commands/choose-docs.ts new file mode 100644 index 0000000..8d8be92 --- /dev/null +++ b/extensions/docs/commands/choose-docs.ts @@ -0,0 +1,80 @@ +import { action, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { allDocs, chosen, enabledDocs, setChosen } from "../devdocs.ts"; +import { devdocsUrl, type Doc, docTitle, size } from "../lib.ts"; + +interface Prefs { + docs?: string; +} + +let counts = { enabled: 0, all: 0 }; + +function item(doc: Doc, enabled: boolean, fromPrefs: boolean, current: string[]): Item { + const toggle = action(enabled ? "Disable" : "Enable", async () => { + if (enabled && fromPrefs) { + await toast(`${doc.name} is set in the preferences`, { + style: "failure", + message: "Remove it from Settings → Extensions → Docs", + }); + return; + } + await setChosen(enabled ? current.filter((s) => s !== doc.slug) : [...current, doc.slug]); + return refresh(); + }, { icon: enabled ? Icons.minus : Icons.plus }); + return { + id: doc.slug, + title: doc.name, + subtitle: doc.version, + icon: { source: enabled ? Icons.squareCheck : Icons.square, tint: enabled ? "green" : "gray" }, + section: enabled ? "Enabled" : "Available", + keywords: [doc.slug, doc.release ?? ""], + accessories: [ + ...(fromPrefs ? [{ tag: "Preferences", color: "blue" as const }] : []), + ...(size(doc.db_size) ? [{ text: size(doc.db_size)! }] : []), + ], + detail: { + metadata: [ + meta.text("Documentation", docTitle(doc), { icon: Icons.book }), + ...(doc.release ? [meta.text("Release", doc.release, { icon: Icons.tag })] : []), + meta.text("Slug", doc.slug, { icon: Icons.code }), + ...(size(doc.db_size) ? [meta.text("Size", size(doc.db_size)!, { icon: Icons.download })] : []), + ...(doc.mtime + ? [meta.text("Updated", new Date(doc.mtime * 1000).toLocaleDateString("en-GB"), { icon: Icons.calendar })] + : []), + meta.text("Status", enabled ? (fromPrefs ? "Enabled in preferences" : "Enabled") : "Not enabled", { + icon: Icons.dot, + color: enabled ? "green" : "gray", + }), + meta.separator(), + meta.link("DevDocs", `devdocs.io/${doc.slug}`, devdocsUrl(doc.slug)), + ...(doc.links?.home ? [meta.link("Homepage", doc.links.home.replace(/^https?:\/\//, ""), doc.links.home)] : []), + ...(doc.links?.code ? [meta.link("Source", doc.links.code.replace(/^https?:\/\//, ""), doc.links.code)] : []), + ], + }, + actions: [toggle, open(devdocsUrl(doc.slug), "Open in DevDocs", { shortcut: "o" })], + }; +} + +export default list({ + title: "Choose DevDocs", + placeholder: "Find a documentation to enable…", + filter: { options: [{ value: "all", title: "All" }, { value: "enabled", title: "Enabled" }], default: "all" }, + status: () => `${counts.enabled} enabled · ${counts.all} available`, + actions: [ + action("Reload the DevDocs List", async () => { + await allDocs(true); + return refresh(); + }, { icon: Icons.refresh }), + ], + async items({ preferences, filter }) { + const all = await allDocs(); + const { docs, fromPrefs } = await enabledDocs(preferences as Prefs); + const enabled = new Set(docs.map((d) => d.slug)); + // The first pick starts from what is enabled now (the defaults), not from nothing. + const current = (await chosen()) ?? docs.filter((d) => !fromPrefs.has(d.slug)).map((d) => d.slug); + counts = { enabled: enabled.size, all: all.length }; + const shown = filter === "enabled" ? all.filter((d) => enabled.has(d.slug)) : all; + return [...shown] + .sort((a, b) => Number(enabled.has(b.slug)) - Number(enabled.has(a.slug))) + .map((d) => item(d, enabled.has(d.slug), fromPrefs.has(d.slug), current)); + }, +}); diff --git a/extensions/docs/commands/devdocs.ts b/extensions/docs/commands/devdocs.ts new file mode 100644 index 0000000..1de7bbf --- /dev/null +++ b/extensions/docs/commands/devdocs.ts @@ -0,0 +1,116 @@ +import { action, type Color, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { docIndex, enabledDocs } from "../devdocs.ts"; +import { devdocsUrl, type Doc, docTitle, type Entry, rank, scopeQuery, size } from "../lib.ts"; +import chooseDocs from "./choose-docs.ts"; + +interface Prefs { + docs?: string; +} + +const TINTS: Color[] = ["blue", "purple", "teal", "orange", "pink", "green", "yellow", "red"]; +const tint = (slug: string) => TINTS[[...slug].reduce((n, c) => n + c.charCodeAt(0), 0) % TINTS.length]; + +const choose = action("Choose DevDocs", () => chooseDocs, { + icon: Icons.settings, + shortcut: "e", + section: "DevDocs", +}); + +function docItem(doc: Doc, entries?: number): Item { + return { + id: `doc:${doc.slug}`, + title: doc.name, + subtitle: doc.version, + icon: { source: Icons.book, tint: tint(doc.slug) }, + section: "Enabled Docs", + keywords: [doc.slug], + accessories: entries !== undefined ? [{ text: `${entries.toLocaleString("en")} entries` }] : [], + detail: { + markdown: `Type **${doc.slug.split("~")[0]} ‹query›** to search only this documentation.`, + metadata: [ + meta.text("Documentation", docTitle(doc), { icon: Icons.book }), + ...(doc.release ? [meta.text("Release", doc.release, { icon: Icons.tag })] : []), + ...(entries !== undefined ? [meta.text("Entries", entries.toLocaleString("en"))] : []), + ...(size(doc.db_size) ? [meta.text("Size", size(doc.db_size)!, { icon: Icons.download })] : []), + meta.separator(), + meta.link("DevDocs", `devdocs.io/${doc.slug}`, devdocsUrl(doc.slug)), + ...(doc.links?.home ? [meta.link("Homepage", doc.links.home.replace(/^https?:\/\//, ""), doc.links.home)] : []), + ], + }, + actions: [open(devdocsUrl(doc.slug), "Open in DevDocs"), choose], + }; +} + +function entryItem(doc: Doc, e: Entry): Item { + const url = devdocsUrl(doc.slug, e.path); + return { + id: `${doc.slug}/${e.path}`, + title: e.name, + subtitle: e.type, + icon: { source: Icons.book, tint: tint(doc.slug) }, + accessories: [{ tag: docTitle(doc) }], + detail: { + metadata: [ + meta.text("Entry", e.name, { icon: Icons.code }), + meta.text("Type", e.type, { icon: Icons.tag }), + meta.text("Documentation", docTitle(doc), { icon: Icons.book, color: tint(doc.slug) }), + meta.separator(), + meta.link("Page", `devdocs.io/${doc.slug}/${e.path}`.slice(0, 60), url), + ], + }, + actions: [ + open(url, "Open in Browser"), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${e.name}](${url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(e.name, "Copy Name", { shortcut: "period", section: "Copy" }), + open(devdocsUrl(doc.slug), `Open ${doc.name} Home`, { icon: Icons.book, section: "DevDocs" }), + choose, + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Search DevDocs… (prefix with a doc to narrow: “py dict”)", + empty: { + title: "No match in the enabled docs", + description: "Press Ctrl+E to enable more documentations", + icon: Icons.search, + }, + status: () => summary, + actions: [choose, open("https://devdocs.io", "Open DevDocs", { icon: Icons.globe })], + async search(query, { preferences }) { + const { docs } = await enabledDocs(preferences as Prefs); + if (!docs.length) { + summary = ""; + return [{ + id: "none", + title: "No documentation enabled", + subtitle: "Choose the ones you use", + icon: { source: Icons.settings, tint: "orange" }, + actions: [choose], + }]; + } + const loaded = await Promise.allSettled(docs.map(docIndex)); + const failed = docs.filter((_, i) => loaded[i].status === "rejected").map((d) => d.name); + const indexOf = (i: number) => + loaded[i].status === "fulfilled" ? (loaded[i] as PromiseFulfilledResult).value : []; + if (failed.length === docs.length) { + throw new Error(`Could not load the DevDocs indexes (${(loaded[0] as PromiseRejectedResult).reason})`); + } + const total = docs.reduce((n, _, i) => n + indexOf(i).length, 0); + summary = `${docs.length} docs · ${total.toLocaleString("en")} entries${ + failed.length ? ` · ${failed.join(", ")} unavailable` : "" + }`; + if (!query.trim()) { + return docs.map((d, i) => docItem(d, loaded[i].status === "fulfilled" ? indexOf(i).length : undefined)); + } + const scope = scopeQuery(query, docs); + const pool = docs.flatMap(( + doc, + i, + ) => (scope.slug && doc.slug !== scope.slug ? [] : indexOf(i).map((e) => ({ doc, e })))); + return rank(pool, scope.text, (x) => x.e.name, 60).map(({ doc, e }) => entryItem(doc, e)); + }, +}); diff --git a/extensions/docs/commands/man.ts b/extensions/docs/commands/man.ts new file mode 100644 index 0000000..c687310 --- /dev/null +++ b/extensions/docs/commands/man.ts @@ -0,0 +1,135 @@ +import { + action, + close, + type Color, + copy, + detail, + exec, + Icons, + type Item, + list, + meta, + open, + storage, +} from "@backslash/api"; +import { type ManEntry, manToMarkdown, parseApropos, rank, sectionName, stripOverstrike } from "../lib.ts"; +import { runInTerminal } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +const COLORS: Record = { + "1": "green", + "2": "red", + "3": "purple", + "5": "orange", + "7": "teal", + "8": "blue", +}; +const color = (section: string) => COLORS[section[0]] ?? "gray"; +const webUrl = (e: ManEntry) => `https://man.archlinux.org/man/${encodeURIComponent(e.name)}.${e.section}`; + +async function remember(e: ManEntry) { + const recent = (await storage.get<[string, string, string][]>("man-recent")) ?? []; + const kept = recent.filter(([n, s]) => !(n === e.name && s === e.section)); + await storage.set("man-recent", [[e.name, e.section, e.description], ...kept].slice(0, 12)); +} + +async function page(e: ManEntry, prefs: Prefs) { + const raw = await exec("man", ["-P", "cat", e.section, e.name]); + if (/This system has been minimized/.test(raw)) { + throw new Error("Man pages were removed from this system: run `unminimize` to restore them"); + } + await remember(e); + const markdown = manToMarkdown(raw); + const sections = [...markdown.matchAll(/^## (.+)$/gm)].map((m) => m[1]); + return detail({ + title: `${e.name}(${e.section})`, + markdown, + metadata: [ + meta.text("Page", `${e.name}(${e.section})`, { icon: Icons.book }), + meta.text("Section", `${e.section} · ${sectionName(e.section)}`, { icon: Icons.hash, color: color(e.section) }), + ...(e.description ? [meta.text("Summary", e.description)] : []), + ...(sections.length ? [meta.tags("Contents", sections.slice(0, 10))] : []), + meta.separator(), + meta.link("Online", `man.archlinux.org/man/${e.name}.${e.section}`, webUrl(e)), + ], + status: `man ${e.section} ${e.name}`, + actions: [ + action("Open in Terminal", () => { + runInTerminal(["man", e.section, e.name], prefs.terminal || undefined); + return close(); + }, { icon: Icons.terminal, shortcut: "t" }), + open(webUrl(e), "Open Online", { shortcut: "o" }), + copy(stripOverstrike(raw), "Copy Page Text", { shortcut: "c" }), + ], + }); +} + +function item(e: ManEntry, prefs: Prefs, section?: string): Item { + return { + id: `${e.name}(${e.section})`, + title: e.name, + subtitle: e.description, + icon: { source: Icons.book, tint: color(e.section) }, + section, + keywords: [e.description], + accessories: [{ tag: e.section, color: color(e.section) }], + detail: { + metadata: [ + meta.text("Page", `${e.name}(${e.section})`, { icon: Icons.book }), + meta.text("Section", `${e.section} · ${sectionName(e.section)}`, { icon: Icons.hash, color: color(e.section) }), + ...(e.description ? [meta.text("Summary", e.description)] : []), + ], + }, + actions: [ + action("Read Page", () => page(e, prefs), { icon: Icons.book }), + action("Open in Terminal", async () => { + runInTerminal(["man", e.section, e.name], prefs.terminal || undefined); + await remember(e); + return close(); + }, { icon: Icons.terminal, shortcut: "t" }), + open(webUrl(e), "Open Online", { shortcut: "o" }), + copy(`man ${e.section} ${e.name}`, "Copy man Command", { icon: Icons.terminal, section: "Copy" }), + copy(e.name, "Copy Name", { shortcut: "period", section: "Copy" }), + ], + }; +} + +/** apropos takes regular expressions: each word is matched literally, all of them required. */ +const literal = (w: string) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +let summary = ""; + +export default list({ + placeholder: "Search man pages by name or description…", + empty: { + title: "No man page found", + description: "Try another word, or check that man-db is installed", + icon: Icons.book, + }, + status: () => summary, + async search(query, { preferences, signal }) { + const prefs = preferences as Prefs; + summary = ""; + if (!query.trim()) { + const recent = (await storage.get<[string, string, string][]>("man-recent")) ?? []; + return recent.map(([name, section, description]) => item({ name, section, description }, prefs, "Recently Read")); + } + let out = ""; + try { + out = await exec("apropos", ["-a", ...query.trim().split(/\s+/).map(literal)], { signal }); + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new Error("apropos is not installed: install man-db"); + // Exit 16: nothing appropriate. + return []; + } + const entries = parseApropos(out); + const byName = rank(entries, query.trim(), (e) => e.name, 80); + const rest = entries.filter((e) => !byName.includes(e)); + const shown = [...byName, ...rest].slice(0, 80); + summary = `${entries.length} page${entries.length === 1 ? "" : "s"}`; + return shown.map((e) => item(e, prefs)); + }, +}); diff --git a/extensions/docs/commands/tldr.ts b/extensions/docs/commands/tldr.ts new file mode 100644 index 0000000..73f1d87 --- /dev/null +++ b/extensions/docs/commands/tldr.ts @@ -0,0 +1,198 @@ +import { + type Action, + action, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + meta, + open, + paste, + storage, + submenu, +} from "@backslash/api"; +import { reach } from "../devdocs.ts"; +import { + bestPlatform, + parseTldrIndex, + parseTldrPage, + plainCommand, + platformOrder, + rank, + type TldrCommand, + tldrMarkdown, + tldrName, + type TldrPage, + tldrUrl, + tldrWebUrl, +} from "../lib.ts"; + +interface Prefs { + language?: string; +} + +const DAY = 86400_000; +const ORDER = platformOrder(Deno.build.os); + +let indexPromise: Promise | undefined; + +/** The list of pages, cached a day; undefined when tldr.sh can't be reached (exact names still work). */ +function tldrIndex(): Promise { + indexPromise ??= (async () => { + const cached = (await storage.get("index")) as { at: number; commands: [string, string][] } | undefined; + const fromCache = cached?.commands.map(([name, p]) => ({ name, platforms: p.split(",") })); + if (fromCache && Date.now() - cached!.at < DAY) return fromCache; + try { + const res = await fetch("https://tldr.sh/assets/index.json"); + if (!res.ok) throw new Error(`tldr.sh answered ${res.status}`); + const commands = parseTldrIndex(await res.text()); + await storage.set("index", { at: Date.now(), commands: commands.map((c) => [c.name, c.platforms.join(",")]) }); + return commands; + } catch { + return fromCache; + } + })(); + return indexPromise; +} + +interface Loaded { + name: string; + platform: string; + language: string; + page: TldrPage; +} + +const pages = new Map>(); + +/** A page in the user's language, else in English; undefined when it does not exist. */ +function loadPage(name: string, platform: string, language: string): Promise { + const key = `${language}/${platform}/${name}`; + let p = pages.get(key); + if (!p) { + p = (async () => { + for (const lang of language !== "en" ? [language, "en"] : ["en"]) { + const res = await reach(tldrUrl(name, platform, lang)); + if (res.ok) return { name, platform, language: lang, page: parseTldrPage(await res.text()) }; + await res.body?.cancel(); + if (res.status !== 404) throw new Error(`GitHub answered ${res.status}`); + } + return undefined; + })(); + p.catch(() => pages.delete(key)); + pages.set(key, p); + } + return p; +} + +async function remember(name: string) { + const recent = (await storage.get("recent")) ?? []; + await storage.set("recent", [name, ...recent.filter((n) => n !== name)].slice(0, 12)); +} + +function exampleActions(page: TldrPage): Action[] { + if (!page.examples.length) return []; + return [ + submenu( + "Copy Example", + page.examples.map((e) => copy(plainCommand(e.command), e.description)), + { icon: Icons.copy, shortcut: "c", section: "Examples" }, + ), + submenu( + "Paste Example", + page.examples.map((e) => paste(plainCommand(e.command), e.description)), + { icon: Icons.paste, shortcut: "v", section: "Examples" }, + ), + ]; +} + +function metadata(l: Loaded, platforms: string[]) { + return [ + meta.text("Command", l.page.title || l.name, { icon: Icons.terminal }), + meta.tags("Platforms", platforms.map((p) => ({ text: p, color: p === l.platform ? "green" as const : undefined }))), + meta.text("Examples", l.page.examples.length, { icon: Icons.code }), + ...(l.language !== "en" ? [meta.text("Language", l.language, { icon: Icons.globe })] : []), + meta.separator(), + ...(l.page.more + ? [meta.link("More information", l.page.more.replace(/^https?:\/\//, "").slice(0, 50), l.page.more)] + : []), + meta.link("Page", `tldr-pages/${l.platform}/${l.name}.md`, tldrWebUrl(l.name, l.platform)), + ]; +} + +function pageView(l: Loaded, platforms: string[]): DetailSpec { + return detail({ + title: `tldr ${l.name}`, + markdown: tldrMarkdown(l.page), + metadata: metadata(l, platforms), + status: `${l.page.examples.length} examples · ${l.platform}`, + actions: [ + ...exampleActions(l.page), + ...(l.page.more ? [open(l.page.more, "Open Documentation", { icon: Icons.book })] : []), + open(tldrWebUrl(l.name, l.platform), "Open on GitHub", { shortcut: "o" }), + ], + }); +} + +function commandItem(c: TldrCommand, language: string, loaded?: Loaded, section?: string): Item { + const platform = loaded?.platform ?? bestPlatform(c, ORDER); + const show = action("Show Page", async () => { + const l = loaded ?? await loadPage(c.name, platform, language); + if (!l) throw new Error(`No tldr page for ${c.name}`); + await remember(c.name); + return pageView(l, c.platforms); + }, { icon: Icons.book }); + return { + id: c.name, + title: loaded?.page.title || c.name, + subtitle: loaded?.page.description[0], + icon: { source: Icons.terminal, tint: "teal" }, + section, + accessories: c.platforms.slice(0, 3).map((p) => ({ tag: p, color: p === platform ? "teal" as const : undefined })), + detail: loaded ? { markdown: tldrMarkdown(loaded.page), metadata: metadata(loaded, c.platforms) } : { + metadata: [ + meta.tags("Platforms", c.platforms), + meta.text("Page", "Press Enter to load it", { icon: Icons.info }), + ], + }, + actions: [ + show, + ...(loaded ? exampleActions(loaded.page) : []), + open(tldrWebUrl(c.name, platform), "Open on GitHub", { shortcut: "o" }), + copy(c.name, "Copy Command Name", { shortcut: "period", section: "Copy" }), + ], + }; +} + +export default list({ + placeholder: "Search tldr pages: tar, git commit, ffmpeg…", + empty: { title: "No tldr page", description: "Try the base command (git, docker, systemctl…)", icon: Icons.search }, + status: (items) => (items.length ? `${items.length} page${items.length === 1 ? "" : "s"}` : ""), + async search(query, { preferences, signal }) { + const language = ((preferences as Prefs).language || "en").trim().toLowerCase(); + const index = await tldrIndex(); + if (!query.trim()) { + const recent = (await storage.get("recent")) ?? []; + const known = new Map(index?.map((c) => [c.name, c])); + return recent.map((name) => + commandItem(known.get(name) ?? { name, platforms: ["common"] }, language, undefined, "Recently Viewed") + ); + } + const name = tldrName(query); + if (!index) { + // Offline index: look the exact page up on each platform. + for (const platform of ORDER.slice(0, 4)) { + const l = await loadPage(name, platform, language); + if (signal.aborted) return []; + if (l) return [commandItem({ name, platforms: [platform] }, language, l)]; + } + return []; + } + const hits = rank(index, name, (c) => c.name, 40); + if (!hits.length) return []; + // The best match comes with its page in the side panel. + const top = await loadPage(hits[0].name, bestPlatform(hits[0], ORDER), language).catch(() => undefined); + return hits.map((c, i) => commandItem(c, language, i === 0 ? top : undefined)); + }, +}); diff --git a/extensions/docs/devdocs.ts b/extensions/docs/devdocs.ts new file mode 100644 index 0000000..35a3365 --- /dev/null +++ b/extensions/docs/devdocs.ts @@ -0,0 +1,73 @@ +// DevDocs: the list of documentations (cached a week), their indexes (kept for the session), what is enabled. + +import { storage } from "@backslash/api"; +import { compactDoc, DEFAULT_DOCS, type Doc, type Entry, resolveDocs, splitList } from "./lib.ts"; + +const WEEK = 7 * 86400_000; + +export async function getJson(url: string, signal?: AbortSignal): Promise { + const res = await reach(url, signal); + if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`); + return await res.json() as T; +} + +/** fetch(), with an error that says which site could not be reached. */ +export async function reach(url: string, signal?: AbortSignal): Promise { + try { + return await fetch(url, { signal }); + } catch (err) { + if (signal?.aborted) throw err; + throw new Error(`Can't reach ${new URL(url).host}: check your connection (${(err as Error).message})`); + } +} + +let docsPromise: Promise | undefined; + +/** Every documentation DevDocs has, from storage when fresh enough. */ +export function allDocs(force = false): Promise { + if (!docsPromise || force) { + docsPromise = (async () => { + const cached = (await storage.get("docs")) as unknown as { at: number; docs: Doc[] } | undefined; + if (!force && cached && Date.now() - cached.at < WEEK && cached.docs.length) return cached.docs; + try { + const docs = (await getJson("https://devdocs.io/docs.json")).map(compactDoc); + await storage.set("docs", JSON.parse(JSON.stringify({ at: Date.now(), docs }))); + return docs; + } catch (err) { + if (cached?.docs.length) return cached.docs; // offline: an old list is better than none + throw err; + } + })(); + docsPromise.catch(() => (docsPromise = undefined)); + } + return docsPromise; +} + +/** Slugs picked with "Choose DevDocs"; undefined until the user picks. */ +export const chosen = () => storage.get("enabled"); +export const setChosen = (slugs: string[]) => storage.set("enabled", slugs); + +/** Preferences plus the picked ones; the defaults until either says something. */ +export async function enabledDocs(prefs: { docs?: string }): Promise<{ docs: Doc[]; fromPrefs: Set }> { + const docs = await allDocs(); + const fromPrefs = resolveDocs(splitList(prefs.docs), docs); + const picked = await chosen(); + const slugs = picked ?? (fromPrefs.length ? [] : resolveDocs(DEFAULT_DOCS, docs)); + const all = [...new Set([...fromPrefs, ...slugs])]; + const bySlug = new Map(docs.map((d) => [d.slug, d])); + return { docs: all.flatMap((s) => bySlug.get(s) ?? []), fromPrefs: new Set(fromPrefs) }; +} + +const indexes = new Map>(); + +/** A doc's index. Not tied to a keystroke's signal: it keeps loading while the user types. */ +export function docIndex(doc: Doc): Promise { + let p = indexes.get(doc.slug); + if (!p) { + p = getJson<{ entries: Entry[] }>(`https://documents.devdocs.io/${doc.slug}/index.json?${doc.mtime ?? ""}`) + .then((r) => r.entries ?? []); + p.catch(() => indexes.delete(doc.slug)); + indexes.set(doc.slug, p); + } + return p; +} diff --git a/extensions/docs/lib.ts b/extensions/docs/lib.ts new file mode 100644 index 0000000..4247775 --- /dev/null +++ b/extensions/docs/lib.ts @@ -0,0 +1,515 @@ +// DevDocs, tldr, Arch Wiki and man pages: parsing, ranking and markdown. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Ranking + +/** + * How well `name` matches `query` (lower is better), undefined when it does + * not: exact, prefix, word start, substring, then letters in order. + */ +export function score(name: string, query: string): number | undefined { + const n = name.toLowerCase(); + const q = query.toLowerCase().trim(); + if (!q) return 0; + if (n === q) return 0; + if (n.startsWith(q)) return 1 + n.length / 1000; + const at = n.indexOf(q); + if (at > 0 && /[\s.:#/_\-()<>[\]]/.test(n[at - 1])) return 2 + at / 100 + n.length / 1000; + if (at > 0) return 3 + at / 100 + n.length / 1000; + // Every word of the query somewhere in the name. + const words = q.split(/\s+/); + if (words.length > 1 && words.every((w) => n.includes(w))) return 4 + n.length / 1000; + // Letters in order: "arrmap" → "Array.prototype.map". + let i = 0; + for (const c of n) if (c === q[i]) i++; + return i === q.length && q.length >= 3 ? 5 + n.length / 1000 : undefined; +} + +export function rank(items: T[], query: string, key: (t: T) => string, limit = 50): T[] { + return items + .flatMap((t) => { + const s = score(key(t), query); + return s === undefined ? [] : [{ t, s }]; + }) + .sort((a, b) => a.s - b.s) + .slice(0, limit) + .map((x) => x.t); +} + +// --------------------------------------------------------------------------- +// DevDocs + +export interface Doc { + name: string; + slug: string; + type?: string; + version?: string; + release?: string; + mtime?: number; + db_size?: number; + links?: { home?: string; code?: string }; +} + +export interface Entry { + name: string; + path: string; + type: string; +} + +/** Fields of docs.json worth keeping in storage. */ +export const compactDoc = (d: Doc): Doc => ({ + name: d.name, + slug: d.slug, + version: d.version || undefined, + release: d.release || undefined, + mtime: d.mtime, + db_size: d.db_size, + links: d.links?.home || d.links?.code ? { home: d.links.home, code: d.links.code } : undefined, +}); + +export const DEFAULT_DOCS = ["javascript", "typescript", "css", "html", "dom", "http", "node", "python", "bash", "git"]; + +/** Short names DevDocs users type: `js map`. */ +export const ALIASES: Record = { + js: "javascript", + ts: "typescript", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + cpp: "cpp", + "c++": "cpp", + k8s: "kubernetes", + pg: "postgresql", + postgres: "postgresql", + tf: "terraform", +}; + +const base = (slug: string) => slug.split("~")[0]; + +/** "3.13" > "3.9", "22 LTS" > "20 LTS". */ +export function compareVersions(a = "", b = ""): number { + const pa = a.match(/\d+/g)?.map(Number) ?? []; + const pb = b.match(/\d+/g)?.map(Number) ?? []; + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? -1) - (pb[i] ?? -1); + if (d) return d; + } + return 0; +} + +/** + * Slugs for what the user asked: an exact slug (`python~3.12`), or a name + * (`python`, `Python`) resolved to the newest version. + */ +export function resolveDocs(wanted: string[], docs: Doc[]): string[] { + const out: string[] = []; + for (const raw of wanted) { + const w = ALIASES[raw.trim().toLowerCase()] ?? raw.trim().toLowerCase(); + if (!w) continue; + const exact = docs.find((d) => d.slug.toLowerCase() === w); + const named = docs + .filter((d) => base(d.slug).toLowerCase() === w || d.name.toLowerCase() === w) + // An unversioned slug is the current release; otherwise the highest version. + .sort((a, b) => + Number(b.slug === base(b.slug)) - Number(a.slug === base(a.slug)) || compareVersions(b.version, a.version) + ); + const hit = exact ?? named[0]; + if (hit && !out.includes(hit.slug)) out.push(hit.slug); + } + return out; +} + +export const splitList = (text: string | undefined) => (text ?? "").split(/[\s,]+/).filter(Boolean); + +/** `js map` with JavaScript enabled → search "map" in javascript only. */ +export function scopeQuery(query: string, docs: Doc[]): { slug?: string; text: string } { + const m = query.trim().match(/^(\S+)\s+(.+)$/); + if (m) { + const word = (ALIASES[m[1].toLowerCase()] ?? m[1].toLowerCase()).replace(/:$/, ""); + const doc = docs.find((d) => d.slug.toLowerCase() === word || base(d.slug).toLowerCase() === word); + if (doc) return { slug: doc.slug, text: m[2] }; + } + return { text: query.trim() }; +} + +export const devdocsUrl = (slug: string, path = "") => `https://devdocs.io/${slug}/${path}`; + +export const docTitle = (d: Doc) => (d.version ? `${d.name} ${d.version}` : d.name); + +/** 1536000 → "1.5 MB". */ +export function size(bytes: number | undefined): string | undefined { + if (!bytes) return undefined; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1e3))} kB`; +} + +// --------------------------------------------------------------------------- +// tldr + +export interface TldrCommand { + name: string; + platforms: string[]; +} + +/** tldr.sh/assets/index.json → names and platforms. */ +export function parseTldrIndex(json: string): TldrCommand[] { + return (JSON.parse(json).commands ?? []).map((c: any) => ({ name: c.name, platforms: c.platform ?? [] })); +} + +export function platformOrder(os: string): string[] { + const own = os === "darwin" ? "osx" : os === "windows" ? "windows" : os === "linux" ? "linux" : os; + return [own, "common", "linux", "osx", "windows", "android", "freebsd", "netbsd", "openbsd", "sunos"] + .filter((p, i, all) => all.indexOf(p) === i); +} + +/** The best platform for a command: the user's own, else common, else any. */ +export const bestPlatform = (c: TldrCommand, order: string[]) => + order.find((p) => c.platforms.includes(p)) ?? c.platforms[0] ?? "common"; + +/** `git commit` → `git-commit`. */ +export const tldrName = (query: string) => query.trim().toLowerCase().replace(/\s+/g, "-"); + +export function tldrUrl(name: string, platform: string, language = "en"): string { + const folder = language && language !== "en" ? `pages.${language}` : "pages"; + return `https://raw.githubusercontent.com/tldr-pages/tldr/main/${folder}/${platform}/${encodeURIComponent(name)}.md`; +} + +export const tldrWebUrl = (name: string, platform: string) => + `https://github.com/tldr-pages/tldr/blob/main/pages/${platform}/${encodeURIComponent(name)}.md`; + +export interface TldrPage { + title: string; + description: string[]; + more?: string; + examples: { description: string; command: string }[]; +} + +/** `[c]reate` → `create` (mnemonic brackets of recent pages). */ +const mnemonics = (text: string) => text.replace(/\[([A-Za-z0-9]{1,3})\]/g, "$1"); + +export function parseTldrPage(md: string): TldrPage { + const page: TldrPage = { title: "", description: [], examples: [] }; + let pending: string | undefined; + for (const raw of md.split("\n")) { + const line = raw.trim(); + if (line.startsWith("# ")) page.title = line.slice(2).trim(); + else if (line.startsWith("> ")) { + const text = line.slice(2).trim(); + const more = text.match(/^More information:\s*<(.+)>\.?$/i); + if (more) page.more = more[1]; + else page.description.push(text); + } else if (line.startsWith("- ")) pending = mnemonics(line.slice(2).replace(/:$/, "").trim()); + else if (line.startsWith("`") && line.endsWith("`") && pending !== undefined) { + page.examples.push({ description: pending, command: line.slice(1, -1) }); + pending = undefined; + } + } + return page; +} + +/** `{{path/to/file}}` → `path/to/file`, `{{[-v|--verbose]}}` → `--verbose`: ready to edit. */ +export function plainCommand(command: string): string { + return command + .replace(/\{\{\[([^|\]]+)\|([^\]]+)\]\}\}/g, "$2") + .replace(/\{\{(.*?)\}\}/g, "$1"); +} + +/** Placeholders shown as `‹path/to/file›`, so they stand out in the code block. */ +export const displayCommand = (command: string) => + command.replace(/\{\{\[([^|\]]+)\|([^\]]+)\]\}\}/g, "$2").replace(/\{\{(.*?)\}\}/g, "‹$1›"); + +export function tldrMarkdown(page: TldrPage): string { + const out = [`# ${page.title}`, "", ...page.description.map(safeInline), ""]; + for (const ex of page.examples) { + out.push(`**${safeInline(ex.description)}**`, "```sh", displayCommand(ex.command), "```", ""); + } + return out.join("\n").trim(); +} + +// --------------------------------------------------------------------------- +// Arch Wiki + +export interface WikiHit { + title: string; + snippet: string; + words?: number; + size?: number; + timestamp?: string; +} + +const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#039": "'" }; + +export function stripHtml(html: string): string { + return html + .replace(/<[^>]+>/g, "") + .replace(/&(#\d+|#x[0-9a-f]+|\w+);/gi, (m, e: string) => { + if (ENTITIES[e]) return ENTITIES[e]; + if (e.startsWith("#x")) return String.fromCodePoint(parseInt(e.slice(2), 16)); + if (e.startsWith("#")) return String.fromCodePoint(Number(e.slice(1))); + return m; + }) + .replace(/\s+/g, " ") + .trim(); +} + +/** `action=query&list=search` (formatversion 2). */ +export function parseWikiSearch(json: string): { total: number; hits: WikiHit[] } { + const q = JSON.parse(json).query ?? {}; + return { + total: q.searchinfo?.totalhits ?? 0, + hits: (q.search ?? []).map((s: any) => ({ + title: s.title, + snippet: stripHtml(s.snippet ?? ""), + words: s.wordcount, + size: s.size, + timestamp: s.timestamp, + })), + }; +} + +export function wikiUrl(title: string, host = "wiki.archlinux.org"): string { + const path = encodeURIComponent(title.replace(/ /g, "_")).replace(/%2F/g, "/").replace(/%3A/gi, ":"); + return `https://${host}/title/${path}`; +} + +/** A wiki link's target: Wikipedia for `[[Wikipedia:…]]`, the wiki itself otherwise. */ +function pageUrl(page: string, host: string): string { + const wp = page.match(/^(?:wikipedia|w):(.+)$/i); + if (wp) return `https://en.wikipedia.org/wiki/${encodeURIComponent(wp[1].replace(/ /g, "_"))}`; + return wikiUrl(page, host); +} + +/** Words holding `_` or `*` would toggle italics: shown as code instead. */ +export function safeInline(text: string): string { + return text.split(/(`[^`]*`)/).map((part, i) => + i % 2 ? part : part.replace(/[^\s`]*[_*][^\s`]*/g, (w) => /^\*\*.*\*\*$/.test(w) ? w : `\`${w}\``) + ).join(""); +} + +/** Templates, innermost first: `{{ic|…}}`, `{{Note|…}}`, `{{Pkg|…}}`… */ +function template(body: string): string { + const [name, ...args] = body.split("|"); + const key = name.trim().toLowerCase(); + const arg = (i: number) => (args[i] ?? "").replace(/^\s*\d+=/, "").trim(); + switch (key) { + case "ic": + case "keypress": + return `\`${args.join("|").replace(/^1=/, "")}\``; + case "bc": + case "hc": + return `\n\`\`\`\n${args.join("|").replace(/^1=/, "").trim()}\n\`\`\`\n`; + case "pkg": + case "aur": + case "grp": + return `\`${arg(0)}\``; + case "man": + return `\`${arg(1)}(${arg(0)})\``; + case "note": + case "tip": + case "warning": + return `\n> \uE001${name.trim()}:\uE001 ${args.join("|")}\n`; + case "app": + return `\uE001${arg(0)}\uE001 — ${arg(1)}`; + case "lang": + case "lowercase title": + case "related articles start": + case "related articles end": + case "related": + case "out of date": + case "accuracy": + case "expansion": + case "style": + case "merge": + case "move": + return ""; + default: + return args.length ? args[args.length - 1] : ""; + } +} + +/** Arch Wiki wikitext → the launcher's markdown subset. */ +export function wikitextToMarkdown(wikitext: string, host = "wiki.archlinux.org"): string { + let text = wikitext.replace(//g, "").replace(/__\w+__/g, ""); + // Code first, so nothing inside is touched. + const codes: string[] = []; + const keep = (code: string) => `\uE000${codes.push(code) - 1}\uE000`; + text = text.replace( + /<(pre|syntaxhighlight)[^>]*>([\s\S]*?)<\/\1>/gi, + (_, __, code) => keep(`\n\`\`\`\n${code.trim()}\n\`\`\`\n`), + ); + text = text.replace(/<(code|kbd|tt)>([\s\S]*?)<\/\1>/gi, (_, __, code) => keep(`\`${code}\``)); + text = text.replace( + /\{\{(ic|bc|hc)\|(?:1=)?([^{}]*(?:\{\{[^{}]*\}\}[^{}]*)*)\}\}/gi, + (_, name, code) => keep(name.toLowerCase() === "ic" ? `\`${code}\`` : `\n\`\`\`\n${code.trim()}\n\`\`\`\n`), + ); + for (let i = 0; i < 10 && /\{\{[^{}]*\}\}/.test(text); i++) { + text = text.replace(/\{\{([^{}]*)\}\}/g, (_, body) => template(body)); + } + text = text + .replace(/\[\[(?:Category|[a-z]{2}(?:-[a-z]+)?|File|Image):[^\]]*\]\]\n?/g, "") + // Links are kept whole: their URLs often hold underscores. + .replace( + /\[\[([^\]|#]*)(#[^\]|]*)?\|([^\]]+)\]\]/g, + (_, page, _a, label) => page ? keep(`[${label}](${pageUrl(page, host)})`) : label, + ) + .replace(/\[\[([^\]|#]+)(#[^\]]*)?\]\]/g, (_, page) => keep(`[${page}](${pageUrl(page, host)})`)) + .replace(/\[\[#([^\]]+)\]\]/g, "$1") + .replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g, (_, url, label) => keep(`[${label}](${url})`)) + .replace(/\[(https?:\/\/[^\s\]]+)\]/g, (_, url) => keep(url)) + .replace(/'''(.+?)'''/g, "\uE001$1\uE001") + .replace(/''(.+?)''/g, "\uE002$1\uE002") + .replace(//gi, "\n") + .replace(/<\/?[a-z][^>]*>/gi, "") + .replace(/ /g, " "); + const out: string[] = []; + let pre: string[] = []; + const flush = () => { + if (pre.length) out.push("```", ...pre, "```"); + pre = []; + }; + for (const line of text.split("\n")) { + if (/^ \S/.test(line) && !/^\s*\uE000/.test(line)) { + pre.push(line.slice(1)); + continue; + } + flush(); + const h = line.match(/^(={2,5})\s*(.+?)\s*\1\s*$/); + if (h) { + out.push("", `${"#".repeat(Math.min(h[1].length, 4))} ${h[2]}`, ""); + continue; + } + const list = line.match(/^([*#:;]+)\s*(.*)$/); + if (list) { + const depth = list[1].length - 1; + const marker = list[1].endsWith("#") ? "1." : list[1].endsWith("*") ? "-" : ""; + out.push(`${" ".repeat(depth)}${marker ? `${marker} ` : ""}${list[2]}`); + continue; + } + if (/^\{\||^\|\}|^\|-/.test(line)) continue; + const cell = line.match(/^[|!]\s*(.*)$/); + if (cell) { + const cells = cell[1].split(/\s*(?:\|\||!!)\s*/).map((c) => c.replace(/^[^|]*\|(?!\|)/, "").trim()); + out.push(`- ${cells.filter(Boolean).join(" · ")}`); + continue; + } + out.push(line); + } + flush(); + const md = out.join("\n") + .split(/(\uE000\d+\uE000)/) + .map(( + part, + i, + ) => (i % 2 ? part : part.split("\n").map((l) => (l.startsWith("```") ? l : safeInline(l))).join("\n"))) + .join("") + .replace(/\uE000(\d+)\uE000/g, (_, n) => codes[Number(n)]) + .replace(/\uE001/g, "**") + .replace(/\uE002/g, "*"); + return md.replace(/\n{3,}/g, "\n\n").trim(); +} + +// --------------------------------------------------------------------------- +// Man pages + +export interface ManEntry { + name: string; + section: string; + description: string; +} + +/** `apropos` / `man -k`: `tar (1) - an archiving utility`. */ +export function parseApropos(out: string): ManEntry[] { + const seen = new Set(); + return out.split("\n").flatMap((line) => { + const m = line.match(/^(.+?)\s+-\s+(.*)$/); + if (!m) return []; + return [...m[1].matchAll(/([^\s,]+)\s*\(([^)]+)\)/g)].flatMap((n) => { + const key = `${n[1]}(${n[2]})`; + if (seen.has(key)) return []; + seen.add(key); + return [{ name: n[1], section: n[2], description: m[2].trim() }]; + }); + }); +} + +export const SECTIONS: Record = { + "1": "User commands", + "2": "System calls", + "3": "Library functions", + "4": "Devices", + "5": "File formats", + "6": "Games", + "7": "Overviews", + "8": "Administration", + "9": "Kernel routines", +}; + +export const sectionName = (s: string) => SECTIONS[s[0]] ?? `Section ${s}`; + +/** Bold and underline of a terminal (`N\bN`, `_\bx`) removed. */ +// Backspace and escape, built from strings: control characters in regex literals are easy to misread. +const OVERSTRIKE = new RegExp(".\b", "g"); +const ANSI = new RegExp("\u001b\\[[0-9;]*m", "g"); +export const stripOverstrike = (text: string) => text.replace(OVERSTRIKE, "").replace(ANSI, ""); + +const HEADING = /^[A-Z][A-Z0-9 ,/()&'-]*$/; + +/** `man -P cat` output → markdown: headings, options as terms, paragraphs, SYNOPSIS as code. */ +export function manToMarkdown(raw: string): string { + const lines = stripOverstrike(raw).split("\n"); + // Header and footer lines: "TAR(1) General Commands Manual TAR(1)". + const banner = /^\S.*\(\w+\)\s*$/; + while (lines.length && !lines[0].trim()) lines.shift(); + if (lines.length && banner.test(lines[0]) && /\s{3,}/.test(lines[0])) lines.shift(); + while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); + if (lines.length && banner.test(lines[lines.length - 1]) && /\s{3,}/.test(lines[lines.length - 1])) lines.pop(); + + const out: string[] = []; + let section = ""; + let block: string[] = []; + const indent = (l: string) => l.length - l.trimStart().length; + const flush = () => { + if (!block.length) return; + const min = Math.min(...block.map(indent)); + // Leading short lines at the base indent: "-D tablespace" then "--tablespace=tablespace". + let terms = 0; + while (terms < block.length && indent(block[terms]) === min && block[terms].trim().length < 60) terms++; + if (terms > 1 && !block.slice(0, terms).every((l) => /^\s*-/.test(l))) terms = 1; + if (section === "SYNOPSIS") { + out.push("```", ...block.map((l) => l.slice(min)), "```", ""); + } else if (terms > 0 && terms < block.length && block.slice(terms).every((l) => indent(l) > min)) { + // A term (option, possibly spelled on several lines) and its definition. + out.push( + `**\`${block.slice(0, terms).map((l) => l.trim()).join(", ").replace(/`/g, "'")}\`**`, + safeInline(block.slice(terms).map((l) => l.trim()).join(" ")), + "", + ); + } else if (block.length === 1 && /^-\S.*?\s{2,}\S/.test(block[0].trim())) { + // Term and definition on one line: "-v, --verbose Explain what is done". + const [, term, text] = block[0].trim().match(/^(-\S.*?)\s{2,}(\S.*)$/)!; + out.push(`**\`${term}\`** ${safeInline(text)}`, ""); + } else if (block.length === 1 && block[0].trim().length < 40 && /^\s*-/.test(block[0])) { + out.push(`**\`${block[0].trim()}\`**`, ""); + } else { + out.push(safeInline(block.map((l) => l.trim()).join(" ").replace(/(\w)- (\w)/g, "$1-$2")), ""); + } + block = []; + }; + for (const line of lines) { + if (HEADING.test(line) && line.trim().length > 1) { + flush(); + section = line.trim(); + out.push(`## ${section.charAt(0)}${section.slice(1).toLowerCase()}`, ""); + } else if (!line.trim()) flush(); + else { + // A deeper line after a term line opens its definition; a line back at the term level starts a new block. + if (block.length && indent(line) < indent(block[block.length - 1]) && indent(line) <= indent(block[0])) { + flush(); + } + block.push(line); + } + } + flush(); + return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} diff --git a/extensions/docs/lib_test.ts b/extensions/docs/lib_test.ts new file mode 100644 index 0000000..80dc85c --- /dev/null +++ b/extensions/docs/lib_test.ts @@ -0,0 +1,315 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { + bestPlatform, + compareVersions, + displayCommand, + type Doc, + type Entry, + manToMarkdown, + parseApropos, + parseTldrIndex, + parseTldrPage, + parseWikiSearch, + plainCommand, + platformOrder, + rank, + resolveDocs, + safeInline, + scopeQuery, + score, + sectionName, + size, + splitList, + stripHtml, + stripOverstrike, + tldrMarkdown, + tldrName, + tldrUrl, + wikitextToMarkdown, + wikiUrl, +} from "./lib.ts"; +import { terminalArgs } from "./terminal.ts"; + +const DOCS: Doc[] = [ + { name: "JavaScript", slug: "javascript", mtime: 1729000000, db_size: 4567890 }, + { name: "Python", slug: "python~3.9", version: "3.9", release: "3.9.18" }, + { name: "Python", slug: "python~3.13", version: "3.13", release: "3.13.1" }, + { name: "Python", slug: "python~3.12", version: "3.12", release: "3.12.7" }, + { name: "Node.js", slug: "node", version: "", release: "23.1.0" }, + { name: "Node.js", slug: "node~22_lts", version: "22 LTS", release: "22.11.0" }, + { name: "Rust", slug: "rust", release: "1.82.0" }, + { name: "TypeScript", slug: "typescript", release: "5.6.3" }, +]; + +Deno.test("scores: exact, prefix, word start, substring, letters in order", () => { + assertEquals(score("map", "map"), 0); + assert(score("Array.prototype.map()", "array")! < score("TypedArray", "array")!); + assert(score("Array.prototype.map()", "map")! < score("WeakMap", "map")!); + assertEquals(score("WeakMap", "zzz"), undefined); + assert(score("Array.prototype.map()", "arrmap") !== undefined); + const entries = ["WeakMap", "Map", "Array.prototype.map()", "Map.prototype.get()", "flatMap"]; + assertEquals(rank(entries, "map", (e) => e, 3), ["Map", "Map.prototype.get()", "Array.prototype.map()"]); +}); + +Deno.test("DevDocs: resolving names to slugs, newest version", () => { + assertEquals(compareVersions("3.13", "3.9") > 0, true); + assertEquals(resolveDocs(["python", "JavaScript", "rust", "nope"], DOCS), ["python~3.13", "javascript", "rust"]); + assertEquals(resolveDocs(["python~3.12", "node", "js", "ts"], DOCS), [ + "python~3.12", + "node", + "javascript", + "typescript", + ]); + assertEquals(resolveDocs(["Node.js"], DOCS), ["node"]); + assertEquals(splitList("python, rust javascript"), ["python", "rust", "javascript"]); + assertEquals(size(4567890), "4.6 MB"); +}); + +Deno.test("DevDocs: a doc name before the query narrows it", () => { + const enabled = DOCS.filter((d) => ["javascript", "python~3.13"].includes(d.slug)); + assertEquals(scopeQuery("py dict", enabled), { slug: "python~3.13", text: "dict" }); + assertEquals(scopeQuery("javascript Array.from", enabled), { slug: "javascript", text: "Array.from" }); + assertEquals(scopeQuery("rust vec", enabled), { text: "rust vec" }); + assertEquals(scopeQuery("map", enabled), { text: "map" }); + const index: Entry[] = [ + { name: "dict", path: "library/stdtypes#dict", type: "Built-in Types" }, + { name: "dict.get()", path: "library/stdtypes#dict.get", type: "Built-in Types" }, + { name: "OrderedDict", path: "library/collections#collections.OrderedDict", type: "Data Types" }, + ]; + assertEquals(rank(index, "dict", (e) => e.name).map((e) => e.name), ["dict", "dict.get()", "OrderedDict"]); +}); + +const TAR = `# tar + +> Archiving utility. +> Often combined with a compression method, such as \`gzip\` or \`bzip2\`. +> More information: . + +- [c]reate an archive and write it to a [f]ile: + +\`tar cf {{path/to/target.tar}} {{path/to/file1 path/to/file2 ...}}\` + +- E[x]tract a (compressed) archive [f]ile into the target directory: + +\`tar xf {{path/to/source.tar.ext}} {{[-C|--directory]}} {{path/to/directory}}\` +`; + +Deno.test("tldr pages", () => { + const page = parseTldrPage(TAR); + assertEquals(page.title, "tar"); + assertEquals(page.description, [ + "Archiving utility.", + "Often combined with a compression method, such as `gzip` or `bzip2`.", + ]); + assertEquals(page.more, "https://www.gnu.org/software/tar/manual/tar.html"); + assertEquals(page.examples.map((e) => e.description), [ + "create an archive and write it to a file", + "Extract a (compressed) archive file into the target directory", + ]); + assertEquals(plainCommand(page.examples[1].command), "tar xf path/to/source.tar.ext --directory path/to/directory"); + assertEquals( + displayCommand(page.examples[0].command), + "tar cf ‹path/to/target.tar› ‹path/to/file1 path/to/file2 ...›", + ); + const md = tldrMarkdown(page); + assertStringIncludes(md, "**create an archive and write it to a file**\n```sh\ntar cf ‹path/to/target.tar›"); + assertEquals(tldrName("Git Commit"), "git-commit"); + assertEquals(tldrUrl("tar", "common"), "https://raw.githubusercontent.com/tldr-pages/tldr/main/pages/common/tar.md"); + assertEquals( + tldrUrl("tar", "linux", "fr"), + "https://raw.githubusercontent.com/tldr-pages/tldr/main/pages.fr/linux/tar.md", + ); +}); + +Deno.test("tldr index and platforms", () => { + const index = parseTldrIndex(JSON.stringify({ + commands: [ + { name: "apt", platform: ["linux"], language: ["en", "fr"], targets: [{ os: "linux", language: "en" }] }, + { name: "tar", platform: ["common"], language: ["en"] }, + { name: "brew", platform: ["osx"], language: ["en"] }, + ], + })); + assertEquals(index.map((c) => c.name), ["apt", "tar", "brew"]); + const order = platformOrder("linux"); + assertEquals(order.slice(0, 3), ["linux", "common", "osx"]); + assertEquals(bestPlatform({ name: "x", platforms: ["osx", "common"] }, order), "common"); + assertEquals(bestPlatform(index[2], order), "osx"); + assertEquals(platformOrder("darwin")[0], "osx"); +}); + +const SEARCH = JSON.stringify({ + batchcomplete: true, + query: { + searchinfo: { totalhits: 1234 }, + search: [ + { + ns: 0, + title: "Pacman", + pageid: 3049, + size: 64000, + wordcount: 8500, + snippet: 'pacman is the "package manager" & more', + timestamp: "2026-10-01T10:00:00Z", + }, + { + ns: 0, + title: "Pacman/Tips and tricks", + pageid: 7, + size: 1, + wordcount: 3000, + snippet: "", + timestamp: "2026-09-01T00:00:00Z", + }, + ], + }, +}); + +Deno.test("Arch Wiki search", () => { + const { total, hits } = parseWikiSearch(SEARCH); + assertEquals(total, 1234); + assertEquals(hits[0].snippet, 'pacman is the "package manager" & more'); + assertEquals(hits[0].words, 8500); + assertEquals(wikiUrl(hits[1].title), "https://wiki.archlinux.org/title/Pacman/Tips_and_tricks"); + assertEquals(wikiUrl("C++"), "https://wiki.archlinux.org/title/C%2B%2B"); + assertEquals(stripHtml("a <b> 'c' A"), "a 'c' A"); +}); + +const WIKITEXT = `{{Lowercase title}} +[[Category:Package manager]] +[[de:Pacman]] +{{Related articles start}} +{{Related|pacman/Tips and tricks}} +{{Related articles end}} +The [https://pacman.archlinux.page/ pacman] package manager is one of the '''major distinguishing features''' of ''Arch Linux''. + +== Usage == +=== Installing packages === +To install a single package, use {{ic|pacman -S package_name}}: + + # pacman -S foo bar + +{{Note|Packages often have [[Wikipedia:Optional|optional dependencies]], see [[#Querying]].}} +{{Warning|Never run {{ic|pacman -Sy}} without upgrading.}} + +* First item with {{Pkg|base-devel}} +** Nested item, see {{man|8|pacman}} +# Numbered + +{{bc|1= +# pacman -Syu +}} + +{| class="wikitable" +! Option !! Meaning +|- +| -S || sync +|} +See [[Mirrors]] and [[System maintenance|maintenance]] for my_config details.`; + +Deno.test("wikitext to markdown", () => { + const md = wikitextToMarkdown(WIKITEXT); + assert(!md.includes("Category:"), md); + assert(!md.includes("[[de:"), md); + assert(!md.includes("hidden comment"), md); + assertStringIncludes( + md, + "The [pacman](https://pacman.archlinux.page/) package manager is one of the **major distinguishing features** of *Arch Linux*.", + ); + assertStringIncludes(md, "## Usage"); + assertStringIncludes(md, "### Installing packages"); + assertStringIncludes(md, "use `pacman -S package_name`:"); + assertStringIncludes(md, "```\n# pacman -S foo bar\n```"); + assertStringIncludes( + md, + "> **Note:** Packages often have [optional dependencies](https://en.wikipedia.org/wiki/Optional), see Querying.", + ); + assertStringIncludes(md, "> **Warning:** Never run `pacman -Sy` without upgrading."); + assertStringIncludes(md, "- First item with `base-devel`"); + assertStringIncludes(md, " - Nested item, see `pacman(8)`"); + assertStringIncludes(md, "1. Numbered"); + assertStringIncludes(md, "```\n# pacman -Syu\n```"); + assertStringIncludes(md, "- Option · Meaning"); + assertStringIncludes(md, "- -S · sync"); + // Links keep their underscores; prose words with one are shown as code (they would toggle italics). + assertStringIncludes(md, "[maintenance](https://wiki.archlinux.org/title/System_maintenance)"); + assertStringIncludes(md, "for `my_config` details."); +}); + +Deno.test("safe inline markdown", () => { + assertEquals(safeInline("set MAX_ARGS to `a_b` and **bold**"), "set `MAX_ARGS` to `a_b` and **bold**"); + assertEquals(safeInline("glob *.txt"), "glob `*.txt`"); +}); + +const APROPOS = `tar (1) - an archiving utility +gzip (1), gunzip (1) - compress or expand files +tar (5) - format of tape archive files +tar (1) - an archiving utility +`; + +Deno.test("apropos", () => { + assertEquals(parseApropos(APROPOS), [ + { name: "tar", section: "1", description: "an archiving utility" }, + { name: "gzip", section: "1", description: "compress or expand files" }, + { name: "gunzip", section: "1", description: "compress or expand files" }, + { name: "tar", section: "5", description: "format of tape archive files" }, + ]); + assertEquals(sectionName("3p"), "Library functions"); + assertEquals(sectionName("n"), "Section n"); +}); + +const B = (s: string) => [...s].map((c) => `${c}\b${c}`).join(""); + +const MAN = `TAR(1) GNU TAR Manual TAR(1) + +${B("NAME")} + tar - an archiving utility + +${B("SYNOPSIS")} + ${B("Traditional usage")} + ${B("tar")} {${B("A")}|${B("c")}} [${B("GnoSkpPuvw")}] [${B("ARG")}...] + +${B("DESCRIPTION")} + GNU tar saves many files together into a single tape or disk archive, + and can restore individual files from the archive. + +${B("OPTIONS")} + ${B("-c")}, ${B("--create")} + Create a new archive. Arguments supply the names of the files + to be archived. + + ${B("-v")}, ${B("--verbose")} + Verbosely list files processed. Set TAR_OPTIONS to change it. + + -f, --file=ARCHIVE Use archive file. + + -D tablespace + --tablespace=tablespace + Specifies the default tablespace. + +GNU TAR 1.35 July 2023 TAR(1) +`; + +Deno.test("man pages to markdown", () => { + assertEquals(stripOverstrike(B("NAME")), "NAME"); + assertEquals(stripOverstrike("_\bu_\bn\x1b[1mx\x1b[0m"), "unx"); + const md = manToMarkdown(MAN); + assert(!md.includes("GNU TAR Manual"), md); + assert(!md.includes("July 2023"), md); + assertStringIncludes(md, "## Name\n\ntar - an archiving utility"); + assertStringIncludes(md, "## Synopsis\n\n```\nTraditional usage\n tar {A|c} [GnoSkpPuvw] [ARG...]\n```"); + assertStringIncludes( + md, + "## Description\n\nGNU tar saves many files together into a single tape or disk archive, and can restore", + ); + assertStringIncludes( + md, + "**`-c, --create`**\nCreate a new archive. Arguments supply the names of the files to be archived.", + ); + assertStringIncludes(md, "Set `TAR_OPTIONS` to change it."); + assertStringIncludes(md, "**`-f, --file=ARCHIVE`** Use archive file."); + assertStringIncludes(md, "**`-D tablespace, --tablespace=tablespace`**\nSpecifies the default tablespace."); +}); + +Deno.test("terminal", () => { + assertEquals(terminalArgs("xfce4-terminal", ["man", "1", "tar"]), ["-x", "man", "1", "tar"]); +}); diff --git a/extensions/docs/terminal.ts b/extensions/docs/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/docs/terminal.ts @@ -0,0 +1,77 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/kubernetes/backslash.toml b/extensions/kubernetes/backslash.toml new file mode 100644 index 0000000..04a4cf6 --- /dev/null +++ b/extensions/kubernetes/backslash.toml @@ -0,0 +1,51 @@ +[extension] +id = "kubernetes" +title = "Kubernetes" +description = "Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces." +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +[permissions] +run = [ + "kubectl", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "pods" +title = "Kubernetes Pods" +description = "Pods of the current context and namespace: logs, describe, shell, port-forward, restart" +icon = "bs-box" +keywords = ["kubernetes", "k8s", "kubectl", "pods", "logs", "cluster", "deployment", "conteneurs", "journaux"] + +[[commands]] +name = "contexts" +title = "Switch Kubernetes Context" +description = "Your kubeconfig contexts, the current one first" +icon = "bs-server" +keywords = ["kubernetes", "k8s", "kubectl", "context", "cluster", "kubeconfig", "contexte", "changer"] + +[[commands]] +name = "namespaces" +title = "Switch Kubernetes Namespace" +description = "Namespaces of the current context" +icon = "bs-layers" +keywords = ["kubernetes", "k8s", "kubectl", "namespace", "ns", "espace de noms"] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For logs, shells and port-forwards. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/kubernetes/commands/contexts.ts b/extensions/kubernetes/commands/contexts.ts new file mode 100644 index 0000000..f8d3a5f --- /dev/null +++ b/extensions/kubernetes/commands/contexts.ts @@ -0,0 +1,60 @@ +import { action, copy, Icons, type Item, list, meta, refresh } from "@backslash/api"; +import { type KubeContext, shortServer } from "../lib.ts"; +import { errorItem, isMissing, kubeconfig, kubectlMissing, showTarget, target, useContext } from "../kube.ts"; + +function item(c: KubeContext, current: boolean): Item { + return { + id: c.name, + title: c.name, + subtitle: shortServer(c.server), + icon: { source: current ? Icons.check : Icons.server, tint: current ? "green" : "blue" }, + section: current ? "Current Context" : "Contexts", + keywords: [c.cluster, c.user, c.namespace ?? "", c.server ?? ""], + accessories: [ + ...(c.namespace ? [{ icon: Icons.layers, text: c.namespace, tooltip: "Namespace" }] : []), + ...(current ? [{ tag: "Current", color: "green" as const }] : []), + ], + detail: { + metadata: [ + meta.text("Cluster", c.cluster, { icon: Icons.server }), + ...(c.server ? [meta.text("Server", c.server, { icon: Icons.globe })] : []), + meta.person("User", c.user), + meta.text("Namespace", c.namespace ?? "default", { icon: Icons.layers }), + meta.separator(), + meta.text("Status", current ? "Current context" : "Available", { + icon: Icons.dot, + color: current ? "green" : "gray", + }), + ], + }, + actions: [ + ...(current ? [] : [action("Switch to Context", async () => { + await useContext(c.name); + return refresh(); + }, { icon: Icons.arrowRight })]), + copy(c.name, "Copy Context Name", { shortcut: "period", section: "Copy" }), + copy(`kubectl config use-context ${c.name}`, "Copy use-context Command", { + icon: Icons.terminal, + section: "Copy", + }), + ...(c.server ? [copy(c.server, "Copy Server URL", { icon: Icons.globe, section: "Copy" })] : []), + ], + }; +} + +export default list({ + placeholder: "Switch Kubernetes context…", + empty: { title: "No contexts", description: "Add a cluster to ~/.kube/config", icon: Icons.server }, + status: (items) => `${items.filter((i) => i.id !== "missing" && i.id !== "error").length} contexts`, + async items({ signal }) { + try { + const config = await kubeconfig(signal); + await showTarget(target(config)); + return config.contexts + .map((c) => item(c, c.name === config.current)) + .sort((a, b) => Number(b.section === "Current Context") - Number(a.section === "Current Context")); + } catch (err) { + return [isMissing(err) ? kubectlMissing : errorItem(err)]; + } + }, +}); diff --git a/extensions/kubernetes/commands/namespaces.ts b/extensions/kubernetes/commands/namespaces.ts new file mode 100644 index 0000000..f49af98 --- /dev/null +++ b/extensions/kubernetes/commands/namespaces.ts @@ -0,0 +1,83 @@ +import { action, copy, Icons, type Item, list, meta, refresh } from "@backslash/api"; +import { age, parseNamespaces } from "../lib.ts"; +import { + clusterActions, + errorItem, + isMissing, + kubeconfig, + kubectl, + kubectlMissing, + noContext, + showTarget, + target, + useNamespace, +} from "../kube.ts"; + +let summary = ""; + +export default list({ + placeholder: "Switch namespace…", + empty: { title: "No namespaces", icon: Icons.layers }, + status: () => summary, + actions: clusterActions(), + async items({ signal }) { + summary = ""; + let t; + try { + t = target(await kubeconfig(signal)); + } catch (err) { + return [isMissing(err) ? kubectlMissing : errorItem(err)]; + } + await showTarget(t); + if (!t) return [noContext]; + const current = t; + try { + const namespaces = parseNamespaces( + await kubectl(["--context", t.context, "get", "namespaces", "-o", "json"], signal), + ); + summary = `${current.context} · ${namespaces.length} namespaces`; + return namespaces + .sort((a, b) => Number(b.name === current.namespace) - Number(a.name === current.namespace)) + .map((ns): Item => { + const isCurrent = ns.name === current.namespace; + const active = ns.phase === "Active"; + return { + id: ns.name, + title: ns.name, + icon: { + source: isCurrent ? Icons.check : Icons.layers, + tint: isCurrent ? "green" : active ? "blue" : "orange", + }, + section: isCurrent ? "Current Namespace" : "Namespaces", + accessories: [ + ...(active ? [] : [{ tag: ns.phase, color: "orange" as const }]), + ...(isCurrent ? [{ tag: "Current", color: "green" as const }] : []), + { text: age(ns.created), tooltip: "Age" }, + ], + detail: { + metadata: [ + meta.text("Status", ns.phase, { icon: Icons.dot, color: active ? "green" : "orange" }), + meta.text("Context", current.context, { icon: Icons.server }), + ...(ns.created + ? [meta.text("Created", ns.created.toLocaleString("en-GB"), { icon: Icons.calendar })] + : []), + ], + }, + actions: [ + ...(isCurrent ? [] : [action("Switch to Namespace", async () => { + await useNamespace(current, ns.name); + return refresh(); + }, { icon: Icons.arrowRight })]), + copy(ns.name, "Copy Namespace", { shortcut: "period", section: "Copy" }), + copy(`kubectl config set-context --current --namespace=${ns.name}`, "Copy set-context Command", { + icon: Icons.terminal, + section: "Copy", + }), + ], + }; + }); + } catch (err) { + return [errorItem(err, current)]; + } + }, +}); diff --git a/extensions/kubernetes/commands/pods.ts b/extensions/kubernetes/commands/pods.ts new file mode 100644 index 0000000..4c7ba90 --- /dev/null +++ b/extensions/kubernetes/commands/pods.ts @@ -0,0 +1,290 @@ +import { + type Action, + action, + close, + copy, + detail, + type DetailSpec, + exec, + Icons, + type Item, + list, + meta, + type Metadata, + refresh, + submenu, + toast, +} from "@backslash/api"; +import { + age, + deleteCommand, + describeCommand, + filterPods, + HEALTH_COLORS, + localPort, + logsCommand, + parsePods, + type Pod, + type PodFilter, + portForwardCommand, + shellCommand, + sortPods, + type Target, +} from "../lib.ts"; +import { + clusterActions, + errorItem, + isMissing, + kubeconfig, + kubectl, + kubectlMissing, + noContext, + showTarget, + target, +} from "../kube.ts"; +import { runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +const SECTIONS = { failed: "Problems", pending: "Starting", ok: "Running", done: "Completed" }; + +const fence = (text: string) => `\`\`\`\n${text.replace(/```/g, "ʼʼʼ") || "(empty)"}\n\`\`\``; + +async function logsView(t: Target, pod: Pod, prefs: Prefs, container?: string, previous = false): Promise { + const argv = logsCommand(t, pod.name, { container, previous }); + const text = await exec(argv[0], argv.slice(1)).catch((e) => `${e.message}`); + const lines = text ? text.split("\n").length : 0; + return detail({ + title: `${pod.name}${container ? ` · ${container}` : ""}`, + markdown: fence(text), + metadata: podMetadata(pod, t), + status: `${previous ? "Previous container · " : ""}Last ${Math.min(lines, 200)} lines${ + container ? ` of ${container}` : "" + }`, + actions: [ + copy(text, "Copy Logs", { shortcut: "c" }), + action("Reload", () => logsView(t, pod, prefs, container, previous), { + icon: Icons.refresh, + shortcut: "r", + }), + action("Follow in Terminal", () => { + runInTerminal(logsCommand(t, pod.name, { container, follow: true, tail: 50 }), prefs.terminal || undefined); + return close(); + }, { icon: Icons.terminal, shortcut: "t" }), + ], + }); +} + +async function describeView(t: Target, pod: Pod): Promise { + const argv = describeCommand(t, pod.name); + const text = await exec(argv[0], argv.slice(1)); + return detail({ + title: pod.name, + markdown: fence(text), + metadata: podMetadata(pod, t), + status: `kubectl describe pod ${pod.name}`, + actions: [copy(text, "Copy Description", { shortcut: "c" })], + }); +} + +function podMetadata(pod: Pod, t: Target): Metadata[] { + const color = HEALTH_COLORS[pod.health]; + const appLabels = ["app.kubernetes.io/name", "app", "app.kubernetes.io/version", "version", "tier", "component"] + .filter((k) => pod.labels[k]) + .map((k) => `${k.replace("app.kubernetes.io/", "")}=${pod.labels[k]}`); + const lastCrash = pod.containers.find((c) => c.lastTermination?.reason); + return [ + meta.text("Status", pod.status, { icon: Icons.dot, color }), + meta.tags( + "Containers", + pod.containers.map((c) => ({ + text: c.name, + color: c.ready ? "green" as const : c.state === "terminated" ? "gray" as const : "red" as const, + })), + ), + ...(pod.workload ? [meta.text(pod.workload.kind, pod.workload.name, { icon: Icons.layers })] : []), + ...(pod.node ? [meta.text("Node", pod.node, { icon: Icons.server })] : []), + ...(pod.ip ? [meta.text("Pod IP", pod.ip, { icon: Icons.globe })] : []), + ...(lastCrash + ? [meta.text( + "Last restart", + `${lastCrash.lastTermination!.reason}${ + lastCrash.lastTermination!.exitCode !== undefined ? ` (exit ${lastCrash.lastTermination!.exitCode})` : "" + }`, + { icon: Icons.alert, color: "orange" }, + )] + : []), + ...(appLabels.length ? [meta.tags("Labels", appLabels)] : []), + meta.separator(), + meta.tags("Images", pod.containers.map((c) => c.image.split("/").pop()!)), + ...(pod.qos ? [meta.text("QoS", pod.qos)] : []), + meta.text("Namespace", t.namespace, { icon: Icons.layers }), + meta.text("Context", t.context, { icon: Icons.server }), + ...(pod.created ? [meta.text("Created", pod.created.toLocaleString("en-GB"), { icon: Icons.calendar })] : []), + ]; +} + +function podItem(pod: Pod, t: Target, prefs: Prefs): Item { + const color = HEALTH_COLORS[pod.health]; + const term = prefs.terminal || undefined; + const inTerminal = (title: string, argv: string[], o: { icon?: string; shortcut?: string; section?: string } = {}) => + action(title, () => { + runInTerminal(argv, term); + return close(); + }, { icon: Icons.terminal, ...o }); + const multi = pod.containers.length > 1; + const ports = pod.containers.flatMap((c) => c.ports.map((p) => ({ ...p, container: c.name }))); + const actions: Action[] = [ + action("Show Logs", () => logsView(t, pod, prefs), { icon: Icons.text }), + action("Describe", () => describeView(t, pod), { icon: Icons.info, shortcut: "d" }), + ]; + if (multi) { + actions.push(submenu( + "Logs of Container", + pod.containers.map((c) => action(c.name, () => logsView(t, pod, prefs, c.name), { icon: Icons.box })), + { icon: Icons.text, shortcut: "l" }, + )); + } + if (pod.restarts > 0) { + actions.push(action("Logs Before Last Restart", () => { + const crashed = pod.containers.find((c) => c.restarts > 0); + return logsView(t, pod, prefs, crashed?.name, true); + }, { icon: Icons.history, shortcut: "l" })); + } + actions.push(inTerminal("Follow Logs in Terminal", logsCommand(t, pod.name, { follow: true, tail: 50 }), { + shortcut: "f", + section: "Terminal", + })); + if (pod.health === "ok" || pod.health === "pending") { + actions.push( + multi + ? submenu("Open Shell", pod.containers.map((c) => inTerminal(c.name, shellCommand(t, pod.name, c.name))), { + icon: Icons.terminal, + shortcut: "s", + section: "Terminal", + }) + : inTerminal("Open Shell", shellCommand(t, pod.name), { shortcut: "s", section: "Terminal" }), + ); + } + if (ports.length) { + actions.push(submenu( + "Port Forward", + ports.map((p) => + inTerminal( + `localhost:${localPort(p.port)} → ${p.port}${p.name ? ` (${p.name})` : ""}`, + portForwardCommand(t, pod.name, p.port, localPort(p.port)), + { icon: Icons.plug }, + ) + ), + { icon: Icons.plug, shortcut: "p", section: "Terminal" }, + )); + } + actions.push( + copy(pod.name, "Copy Pod Name", { shortcut: "period", section: "Copy" }), + copy(shellLine(logsCommand(t, pod.name)), "Copy Logs Command", { icon: Icons.terminal, section: "Copy" }), + ...(ports.length + ? [ + copy( + shellLine(portForwardCommand(t, pod.name, ports[0].port, localPort(ports[0].port))), + "Copy Port-Forward Command", + { + icon: Icons.plug, + section: "Copy", + }, + ), + ] + : []), + ...clusterActions(), + action("Delete Pod", async () => { + const argv = deleteCommand(t, pod.name); + await exec(argv[0], argv.slice(1)); + await toast(`Deleted ${pod.name}`, { + style: "success", + message: pod.owner + ? `${pod.workload?.kind ?? pod.owner.kind} ${pod.workload?.name ?? pod.owner.name} recreates it` + : undefined, + }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + shortcut: "Delete", + confirm: pod.owner + ? `Delete ${pod.name}? Its ${pod.workload?.kind ?? pod.owner.kind} starts a new one (restart).` + : `Delete ${pod.name}? It has no controller: it will not come back.`, + section: "Danger", + }), + ); + const restartColor = pod.restarts > 5 ? "red" as const : "orange" as const; + return { + id: pod.name, + title: pod.name, + subtitle: pod.workload ? pod.workload.name : undefined, + icon: { source: Icons.box, tint: color }, + section: SECTIONS[pod.health], + keywords: [pod.status, pod.node ?? "", ...pod.containers.map((c) => c.name), ...Object.values(pod.labels)], + accessories: [ + ...(pod.health !== "ok" ? [{ tag: pod.status, color }] : []), + ...(pod.restarts + ? [{ icon: { source: Icons.refresh, tint: restartColor }, text: String(pod.restarts), tooltip: "Restarts" }] + : []), + { + text: `${pod.ready}/${pod.total}`, + tooltip: "Containers ready", + color: pod.ready === pod.total ? undefined : color, + }, + { text: age(pod.created), tooltip: "Age" }, + ], + detail: { + stats: [ + { label: "ready", value: `${pod.ready}/${pod.total}`, color: pod.ready === pod.total ? "green" : color }, + { label: "restarts", value: pod.restarts, color: pod.restarts ? restartColor : undefined }, + { label: "age", value: age(pod.created) }, + ], + metadata: podMetadata(pod, t), + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter pods by name, status, node, label…", + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "problem", title: "Problems" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "No pods here", description: "Nothing matches in this namespace", icon: Icons.box }, + status: () => summary, + actions: clusterActions(), + async items({ preferences, filter, signal }) { + const prefs = preferences as Prefs; + summary = ""; + let t: Target | undefined; + try { + t = target(await kubeconfig(signal)); + } catch (err) { + return [isMissing(err) ? kubectlMissing : errorItem(err)]; + } + await showTarget(t); + if (!t) return [noContext]; + let pods: Pod[]; + try { + pods = parsePods(await kubectl(["--context", t.context, "-n", t.namespace, "get", "pods", "-o", "json"], signal)); + } catch (err) { + return [errorItem(err, t)]; + } + const problems = pods.filter((p) => p.health === "failed").length; + summary = `${t.context} · ${t.namespace} · ${pods.length} pod${pods.length === 1 ? "" : "s"}${ + problems ? ` · ${problems} with problems` : "" + }`; + return sortPods(filterPods(pods, (filter ?? "all") as PodFilter)).map((p) => podItem(p, t!, prefs)); + }, +}); diff --git a/extensions/kubernetes/kube.ts b/extensions/kubernetes/kube.ts new file mode 100644 index 0000000..b4b0c21 --- /dev/null +++ b/extensions/kubernetes/kube.ts @@ -0,0 +1,123 @@ +// Calling kubectl, and the bits the three commands share. + +import { + type Action, + action, + copy, + exec, + Icons, + type Item, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { type Kubeconfig, parseKubeconfig, parseNamespaces, type Target } from "./lib.ts"; + +/** API calls give up after 10 s instead of hanging on an unreachable cluster. */ +export function kubectl(args: string[], signal?: AbortSignal): Promise { + const api = !["config"].includes(args[0]); + return exec("kubectl", api ? ["--request-timeout=10s", ...args] : args, { signal }); +} + +export const isMissing = (err: unknown) => err instanceof Deno.errors.NotFound; + +export async function kubeconfig(signal?: AbortSignal): Promise { + return parseKubeconfig(await kubectl(["config", "view", "-o", "json"], signal)); +} + +/** The current context and its namespace (default when unset). */ +export function target(config: Kubeconfig): Target | undefined { + const ctx = config.contexts.find((c) => c.name === config.current); + return ctx ? { context: ctx.name, namespace: ctx.namespace ?? "default" } : undefined; +} + +export async function showTarget(t: Target | undefined) { + await updateCommandMetadata({ subtitle: t ? `${t.context} · ${t.namespace}` : null }).catch(() => {}); +} + +export async function useContext(name: string) { + await kubectl(["config", "use-context", name]); + await toast(`Switched to ${name}`, { style: "success" }); +} + +export async function useNamespace(t: Target, namespace: string) { + await kubectl(["config", "set-context", t.context, `--namespace=${namespace}`]); + await toast(`Namespace ${namespace}`, { style: "success" }); +} + +/** "Switch Context ›" and "Switch Namespace ›", loaded when opened. */ +export function clusterActions(): Action[] { + return [ + submenu("Switch Context", async () => { + const config = await kubeconfig(); + return config.contexts.map((c) => + action(c.name, async () => { + await useContext(c.name); + return refresh(); + }, { icon: c.name === config.current ? Icons.check : Icons.server }) + ); + }, { icon: Icons.server, shortcut: "k", section: "Cluster" }), + submenu("Switch Namespace", async () => { + const t = target(await kubeconfig()); + if (!t) throw new Error("Pick a context first"); + const namespaces = parseNamespaces(await kubectl(["--context", t.context, "get", "namespaces", "-o", "json"])); + return namespaces.map((ns) => + action(ns.name, async () => { + await useNamespace(t, ns.name); + return refresh(); + }, { icon: ns.name === t.namespace ? Icons.check : Icons.layers }) + ); + }, { icon: Icons.layers, shortcut: "n", section: "Cluster" }), + ]; +} + +export const kubectlMissing: Item = { + id: "missing", + title: "kubectl is not installed", + subtitle: "Install it to see your clusters", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This extension uses **kubectl** and your kubeconfig (`~/.kube/config` or `$KUBECONFIG`).", + "", + "```sh", + "sudo dnf install kubernetes-client # Fedora", + "sudo snap install kubectl --classic # Ubuntu", + "sudo pacman -S kubectl # Arch", + "```", + ].join("\n"), + }, + actions: [copy("sudo snap install kubectl --classic", "Copy snap Command", { icon: Icons.terminal })], +}; + +export const noContext: Item = { + id: "no-context", + title: "No current context", + subtitle: "Pick one, or add a cluster to your kubeconfig", + icon: { source: Icons.alert, tint: "yellow" }, + detail: { + markdown: + "kubectl has no current context. Choose one with **Switch Context** (Ctrl+Shift+K), or add a cluster to `~/.kube/config`.", + }, + actions: clusterActions(), +}; + +/** An error as an item, with the way out. */ +export function errorItem(err: unknown, t?: Target): Item { + const message = err instanceof Error ? err.message.replace(/^kubectl exited with \d+: /, "") : String(err); + return { + id: "error", + title: /Unable to connect|timeout|connection refused|no such host/i.test(message) + ? "Cannot reach the cluster" + : "kubectl failed", + subtitle: t ? `${t.context} · ${t.namespace}` : undefined, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`\`\`\n${message}\n\`\`\`` }, + actions: [ + action("Retry", () => refresh(), { icon: Icons.refresh }), + ...clusterActions(), + copy(message, "Copy Error", { section: "Copy" }), + ], + }; +} diff --git a/extensions/kubernetes/lib.ts b/extensions/kubernetes/lib.ts new file mode 100644 index 0000000..ac047a7 --- /dev/null +++ b/extensions/kubernetes/lib.ts @@ -0,0 +1,297 @@ +// Parsing kubectl JSON. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Kubeconfig + +export interface KubeContext { + name: string; + cluster: string; + user: string; + namespace?: string; + server?: string; +} + +export interface Kubeconfig { + current?: string; + contexts: KubeContext[]; +} + +/** `kubectl config view -o json` (with or without `--minify`). */ +export function parseKubeconfig(json: string): Kubeconfig { + const c = JSON.parse(json || "{}"); + const servers = new Map( + (c.clusters ?? []).map((cl: any) => [cl.name, cl.cluster?.server]), + ); + return { + current: c["current-context"] || undefined, + contexts: (c.contexts ?? []).map((ctx: any) => ({ + name: ctx.name, + cluster: ctx.context?.cluster ?? "", + user: ctx.context?.user ?? "", + namespace: ctx.context?.namespace || undefined, + server: servers.get(ctx.context?.cluster), + })), + }; +} + +/** `https://1.2.3.4:6443` → `1.2.3.4:6443`. */ +export const shortServer = (server?: string) => server?.replace(/^https?:\/\//, "").replace(/\/$/, ""); + +// --------------------------------------------------------------------------- +// Namespaces + +export interface Namespace { + name: string; + phase: string; + created?: Date; +} + +export function parseNamespaces(json: string): Namespace[] { + return (JSON.parse(json).items ?? []).map((n: any) => ({ + name: n.metadata.name, + phase: n.status?.phase ?? "Active", + created: n.metadata.creationTimestamp ? new Date(n.metadata.creationTimestamp) : undefined, + })); +} + +// --------------------------------------------------------------------------- +// Pods + +export interface ContainerPort { + port: number; + name?: string; + protocol: string; +} + +export interface PodContainer { + name: string; + image: string; + ready: boolean; + restarts: number; + /** running, waiting, terminated */ + state: string; + reason?: string; + ports: ContainerPort[]; + lastTermination?: { reason?: string; exitCode?: number; finishedAt?: string }; +} + +export type Health = "ok" | "pending" | "failed" | "done"; + +export interface Pod { + name: string; + namespace: string; + phase: string; + /** What `kubectl get pods` shows in STATUS: Running, CrashLoopBackOff, Init:0/1, Terminating… */ + status: string; + health: Health; + ready: number; + total: number; + restarts: number; + created?: Date; + node?: string; + ip?: string; + qos?: string; + owner?: { kind: string; name: string }; + /** The Deployment of a ReplicaSet's pods, by the naming convention. */ + workload?: { kind: string; name: string }; + labels: Record; + containers: PodContainer[]; + deleting: boolean; +} + +function containerReason(s: any): string | undefined { + if (s?.state?.waiting) return s.state.waiting.reason; + const t = s?.state?.terminated; + if (t) return t.reason ?? (t.signal ? `Signal:${t.signal}` : `ExitCode:${t.exitCode}`); + return undefined; +} + +/** STATUS as kubectl prints it (simplified from its printer). */ +export function podStatus(pod: any): string { + let reason: string = pod.status?.reason || pod.status?.phase || "Unknown"; + const inits: any[] = pod.status?.initContainerStatuses ?? []; + const initTotal = (pod.spec?.initContainers ?? []).length; + let initializing = false; + for (const [i, s] of inits.entries()) { + const t = s.state?.terminated; + if (t && t.exitCode === 0) continue; + initializing = true; + if (t) reason = `Init:${t.reason || (t.signal ? `Signal:${t.signal}` : `ExitCode:${t.exitCode}`)}`; + else if (s.state?.waiting?.reason && s.state.waiting.reason !== "PodInitializing") { + reason = `Init:${s.state.waiting.reason}`; + } else reason = `Init:${i}/${initTotal || inits.length}`; + break; + } + if (!initializing) { + let hasRunning = false; + const statuses: any[] = [...(pod.status?.containerStatuses ?? [])].reverse(); + for (const s of statuses) { + const r = containerReason(s); + if (r) reason = r; + else if (s.state?.running && s.ready) hasRunning = true; + } + if (reason === "Completed" && hasRunning) reason = "Running"; + } + if (pod.metadata?.deletionTimestamp) reason = pod.status?.reason === "NodeLost" ? "Unknown" : "Terminating"; + return reason; +} + +export function healthOf(status: string, ready: number, total: number): Health { + if (status === "Completed" || status === "Succeeded") return "done"; + if (status === "Running") return ready === total ? "ok" : "pending"; + if (/^(Pending|ContainerCreating|PodInitializing|Terminating)$/.test(status) || /^Init:\d+\/\d+$/.test(status)) { + return "pending"; + } + return "failed"; +} + +export const HEALTH_COLORS: Record = { ok: "green", pending: "yellow", failed: "red", done: "gray" }; + +/** `api-7d9f8c6b5` (a ReplicaSet) → the Deployment `api`. */ +function workloadOf(owner?: { kind: string; name: string }): { kind: string; name: string } | undefined { + if (!owner) return undefined; + if (owner.kind === "ReplicaSet") { + const m = owner.name.match(/^(.+)-[a-z0-9]{5,10}$/); + if (m) return { kind: "Deployment", name: m[1] }; + } + if (owner.kind === "Job") { + const m = owner.name.match(/^(.+)-\d{8,}$/); + if (m) return { kind: "CronJob", name: m[1] }; + } + return owner; +} + +export function parsePods(json: string): Pod[] { + return (JSON.parse(json).items ?? []).map((p: any): Pod => { + const statuses: any[] = p.status?.containerStatuses ?? []; + const containers: PodContainer[] = (p.spec?.containers ?? []).map((c: any) => { + const s = statuses.find((x) => x.name === c.name); + const last = s?.lastState?.terminated; + return { + name: c.name, + image: c.image, + ready: !!s?.ready, + restarts: s?.restartCount ?? 0, + state: s?.state ? Object.keys(s.state)[0] : "waiting", + reason: containerReason(s), + ports: (c.ports ?? []).map((port: any) => ({ + port: port.containerPort, + name: port.name, + protocol: port.protocol ?? "TCP", + })), + lastTermination: last + ? { reason: last.reason, exitCode: last.exitCode, finishedAt: last.finishedAt } + : undefined, + }; + }); + const ready = containers.filter((c) => c.ready).length; + const status = podStatus(p); + const ref = (p.metadata?.ownerReferences ?? []).find((o: any) => o.controller) ?? p.metadata?.ownerReferences?.[0]; + const owner = ref ? { kind: ref.kind, name: ref.name } : undefined; + return { + name: p.metadata.name, + namespace: p.metadata.namespace, + phase: p.status?.phase ?? "Unknown", + status, + health: healthOf(status, ready, containers.length), + ready, + total: containers.length, + restarts: containers.reduce((n, c) => n + c.restarts, 0), + created: p.metadata.creationTimestamp ? new Date(p.metadata.creationTimestamp) : undefined, + node: p.spec?.nodeName, + ip: p.status?.podIP, + qos: p.status?.qosClass, + owner, + workload: workloadOf(owner), + labels: p.metadata.labels ?? {}, + containers, + deleting: !!p.metadata.deletionTimestamp, + }; + }); +} + +export type PodFilter = "running" | "problem" | "all"; + +export function filterPods(pods: Pod[], filter: PodFilter): Pod[] { + if (filter === "running") return pods.filter((p) => p.health === "ok"); + if (filter === "problem") return pods.filter((p) => p.health === "failed" || p.health === "pending"); + return pods; +} + +/** Problems first, then pending, healthy and completed; by name within each. */ +export function sortPods(pods: Pod[]): Pod[] { + const rank: Record = { failed: 0, pending: 1, ok: 2, done: 3 }; + return [...pods].sort((a, b) => rank[a.health] - rank[b.health] || a.name.localeCompare(b.name)); +} + +/** kubectl's ages: 45s, 7m30s, 2h15m, 5h, 3d4h, 12d, 2y. */ +export function age(from: Date | undefined, now = new Date()): string { + if (!from) return ""; + const s = Math.max(0, Math.floor((now.getTime() - from.getTime()) / 1000)); + const m = Math.floor(s / 60), h = Math.floor(s / 3600), d = Math.floor(s / 86400); + if (s < 120) return `${s}s`; + if (m < 10) return `${m}m${s % 60 ? `${s % 60}s` : ""}`; + if (h < 3) return `${m}m`; + if (h < 8) return `${h}h${m % 60 ? `${m % 60}m` : ""}`; + if (h < 48) return `${h}h`; + if (d < 8) return `${d}d${h % 24 ? `${h % 24}h` : ""}`; + if (d < 365 * 2) return `${d}d`; + return `${Math.floor(d / 365)}y`; +} + +// --------------------------------------------------------------------------- +// Commands + +export interface Target { + context: string; + namespace: string; +} + +const scope = (t: Target) => ["--context", t.context, "-n", t.namespace]; + +export function logsCommand( + t: Target, + pod: string, + o: { container?: string; previous?: boolean; follow?: boolean; tail?: number } = {}, +) { + return [ + "kubectl", + ...scope(t), + "logs", + pod, + ...(o.container ? ["-c", o.container] : ["--all-containers", "--prefix"]), + ...(o.previous ? ["--previous"] : []), + ...(o.follow ? ["-f"] : []), + `--tail=${o.tail ?? 200}`, + ]; +} + +export const describeCommand = (t: Target, pod: string) => ["kubectl", ...scope(t), "describe", "pod", pod]; +export const deleteCommand = (t: Target, pod: string) => ["kubectl", ...scope(t), "delete", "pod", pod, "--wait=false"]; + +export const portForwardCommand = (t: Target, pod: string, port: number, local = port) => [ + "kubectl", + ...scope(t), + "port-forward", + `pod/${pod}`, + `${local}:${port}`, +]; + +/** Bash when the image has it, sh otherwise. */ +export const shellCommand = (t: Target, pod: string, container?: string) => [ + "kubectl", + ...scope(t), + "exec", + "-it", + pod, + ...(container ? ["-c", container] : []), + "--", + "sh", + "-c", + "command -v bash >/dev/null && exec bash || exec sh", +]; + +/** Ports below 1024 need root on the local side: forward them from 8000 + port instead. */ +export const localPort = (port: number) => (port < 1024 ? 8000 + port : port); diff --git a/extensions/kubernetes/lib_test.ts b/extensions/kubernetes/lib_test.ts new file mode 100644 index 0000000..2c43366 --- /dev/null +++ b/extensions/kubernetes/lib_test.ts @@ -0,0 +1,286 @@ +import { assertEquals } from "@std/assert"; +import { + age, + deleteCommand, + filterPods, + healthOf, + localPort, + logsCommand, + parseKubeconfig, + parseNamespaces, + parsePods, + podStatus, + portForwardCommand, + shellCommand, + shortServer, + sortPods, +} from "./lib.ts"; + +const CONFIG = JSON.stringify({ + apiVersion: "v1", + kind: "Config", + "current-context": "prod", + clusters: [ + { + name: "prod-cluster", + cluster: { server: "https://10.0.0.1:6443", "certificate-authority-data": "DATA+OMITTED" }, + }, + { name: "kind-dev", cluster: { server: "https://127.0.0.1:41235" } }, + ], + contexts: [ + { name: "prod", context: { cluster: "prod-cluster", user: "admin", namespace: "shop" } }, + { name: "kind-dev", context: { cluster: "kind-dev", user: "kind-dev" } }, + ], + users: [{ name: "admin", user: { token: "REDACTED" } }], +}); + +const NOW = new Date("2026-10-28T12:00:00Z"); + +const pod = (name: string, extra: Record) => ({ + metadata: { + name, + namespace: "shop", + creationTimestamp: "2026-10-25T08:00:00Z", + labels: { app: "api", "pod-template-hash": "7d9f8c6b5" }, + ownerReferences: [{ kind: "ReplicaSet", name: "api-7d9f8c6b5", controller: true }], + }, + spec: { + nodeName: "node-1", + containers: [ + { name: "api", image: "ghcr.io/acme/api:1.4.2", ports: [{ containerPort: 8080, name: "http", protocol: "TCP" }] }, + { name: "proxy", image: "envoyproxy/envoy:v1.31", ports: [{ containerPort: 443 }] }, + ], + }, + ...extra, +}); + +const running = pod("api-7d9f8c6b5-x2x4z", { + status: { + phase: "Running", + podIP: "10.244.1.17", + qosClass: "Burstable", + containerStatuses: [ + { + name: "api", + ready: true, + restartCount: 2, + state: { running: { startedAt: "2026-10-27T10:00:00Z" } }, + lastState: { terminated: { reason: "OOMKilled", exitCode: 137 } }, + }, + { name: "proxy", ready: true, restartCount: 0, state: { running: {} } }, + ], + }, +}); + +const crashing = pod("api-7d9f8c6b5-k8s9q", { + status: { + phase: "Running", + containerStatuses: [ + { name: "api", ready: false, restartCount: 14, state: { waiting: { reason: "CrashLoopBackOff" } } }, + { name: "proxy", ready: true, restartCount: 0, state: { running: {} } }, + ], + }, +}); + +const job = { + metadata: { + name: "backup-29350000-abcde", + namespace: "shop", + creationTimestamp: "2026-10-28T11:50:00Z", + ownerReferences: [{ kind: "Job", name: "backup-29350000", controller: true }], + }, + spec: { containers: [{ name: "backup", image: "alpine:3" }] }, + status: { + phase: "Succeeded", + containerStatuses: [{ + name: "backup", + ready: false, + restartCount: 0, + state: { terminated: { reason: "Completed", exitCode: 0 } }, + }], + }, +}; + +const initializing = { + metadata: { + name: "db-0", + namespace: "shop", + ownerReferences: [{ kind: "StatefulSet", name: "db", controller: true }], + }, + spec: { + initContainers: [{ name: "migrate" }, { name: "seed" }], + containers: [{ name: "postgres", image: "postgres:17" }], + }, + status: { + phase: "Pending", + initContainerStatuses: [ + { name: "migrate", state: { terminated: { exitCode: 0, reason: "Completed" } } }, + { name: "seed", state: { running: {} } }, + ], + containerStatuses: [{ + name: "postgres", + ready: false, + restartCount: 0, + state: { waiting: { reason: "PodInitializing" } }, + }], + }, +}; + +const pulling = { + metadata: { name: "web", namespace: "shop", deletionTimestamp: undefined }, + spec: { containers: [{ name: "web", image: "nginx:doesnotexist" }] }, + status: { + phase: "Pending", + containerStatuses: [{ + name: "web", + ready: false, + restartCount: 0, + state: { waiting: { reason: "ImagePullBackOff" } }, + }], + }, +}; + +const terminating = { + metadata: { name: "old", namespace: "shop", deletionTimestamp: "2026-10-28T11:59:00Z" }, + spec: { containers: [{ name: "old", image: "busybox" }] }, + status: { + phase: "Running", + containerStatuses: [{ name: "old", ready: true, restartCount: 0, state: { running: {} } }], + }, +}; + +const PODS = JSON.stringify({ items: [running, crashing, job, initializing, pulling, terminating] }); + +Deno.test("kubeconfig", () => { + const c = parseKubeconfig(CONFIG); + assertEquals(c.current, "prod"); + assertEquals(c.contexts, [ + { name: "prod", cluster: "prod-cluster", user: "admin", namespace: "shop", server: "https://10.0.0.1:6443" }, + { + name: "kind-dev", + cluster: "kind-dev", + user: "kind-dev", + namespace: undefined, + server: "https://127.0.0.1:41235", + }, + ]); + assertEquals(parseKubeconfig("{}"), { current: undefined, contexts: [] }); + assertEquals(shortServer("https://10.0.0.1:6443"), "10.0.0.1:6443"); +}); + +Deno.test("namespaces", () => { + const ns = parseNamespaces(JSON.stringify({ + items: [ + { metadata: { name: "default", creationTimestamp: "2026-01-01T00:00:00Z" }, status: { phase: "Active" } }, + { metadata: { name: "old" }, status: { phase: "Terminating" } }, + ], + })); + assertEquals(ns.map((n) => [n.name, n.phase]), [["default", "Active"], ["old", "Terminating"]]); +}); + +Deno.test("pod statuses as kubectl prints them", () => { + assertEquals(podStatus(running), "Running"); + assertEquals(podStatus(crashing), "CrashLoopBackOff"); + assertEquals(podStatus(job), "Completed"); + assertEquals(podStatus(initializing), "Init:1/2"); + assertEquals(podStatus(pulling), "ImagePullBackOff"); + assertEquals(podStatus(terminating), "Terminating"); + assertEquals( + podStatus({ + status: { + phase: "Pending", + initContainerStatuses: [{ state: { terminated: { exitCode: 1, reason: "Error" } } }], + }, + }), + "Init:Error", + ); + assertEquals(podStatus({ status: { phase: "Failed", reason: "Evicted" } }), "Evicted"); +}); + +Deno.test("pods", () => { + const [api, crash, backup, db, web, old] = parsePods(PODS); + assertEquals([api.ready, api.total, api.restarts, api.health], [2, 2, 2, "ok"]); + assertEquals(api.workload, { kind: "Deployment", name: "api" }); + assertEquals(api.node, "node-1"); + assertEquals(api.ip, "10.244.1.17"); + assertEquals(api.containers[0].ports, [{ port: 8080, name: "http", protocol: "TCP" }]); + assertEquals(api.containers[0].lastTermination, { reason: "OOMKilled", exitCode: 137, finishedAt: undefined }); + assertEquals([crash.status, crash.health, crash.restarts, crash.ready], ["CrashLoopBackOff", "failed", 14, 1]); + assertEquals([backup.health, backup.workload], ["done", { kind: "CronJob", name: "backup" }]); + assertEquals([db.status, db.health, db.workload], ["Init:1/2", "pending", { kind: "StatefulSet", name: "db" }]); + assertEquals(web.health, "failed"); + assertEquals([old.health, old.deleting], ["pending", true]); + assertEquals(healthOf("Running", 1, 2), "pending"); +}); + +Deno.test("filters and order", () => { + const pods = parsePods(PODS); + assertEquals(filterPods(pods, "running").map((p) => p.name), ["api-7d9f8c6b5-x2x4z"]); + assertEquals(filterPods(pods, "problem").map((p) => p.name), ["api-7d9f8c6b5-k8s9q", "db-0", "web", "old"]); + assertEquals(filterPods(pods, "all").length, 6); + assertEquals(sortPods(pods).map((p) => p.name), [ + "api-7d9f8c6b5-k8s9q", + "web", + "db-0", + "old", + "api-7d9f8c6b5-x2x4z", + "backup-29350000-abcde", + ]); +}); + +Deno.test("ages like kubectl", () => { + const ago = (s: number) => age(new Date(NOW.getTime() - s * 1000), NOW); + assertEquals(ago(45), "45s"); + assertEquals(ago(7 * 60 + 30), "7m30s"); + assertEquals(ago(2 * 3600 + 15 * 60), "135m"); + assertEquals(ago(5 * 3600 + 20 * 60), "5h20m"); + assertEquals(ago(30 * 3600), "30h"); + assertEquals(ago(3 * 86400 + 4 * 3600), "3d4h"); + assertEquals(ago(40 * 86400), "40d"); + assertEquals(ago(800 * 86400), "2y"); + assertEquals(age(undefined), ""); +}); + +Deno.test("commands", () => { + const t = { context: "prod", namespace: "shop" }; + assertEquals(logsCommand(t, "api"), [ + "kubectl", + "--context", + "prod", + "-n", + "shop", + "logs", + "api", + "--all-containers", + "--prefix", + "--tail=200", + ]); + assertEquals(logsCommand(t, "api", { container: "proxy", previous: true, follow: true, tail: 50 }), [ + "kubectl", + "--context", + "prod", + "-n", + "shop", + "logs", + "api", + "-c", + "proxy", + "--previous", + "-f", + "--tail=50", + ]); + assertEquals(deleteCommand(t, "api"), [ + "kubectl", + "--context", + "prod", + "-n", + "shop", + "delete", + "pod", + "api", + "--wait=false", + ]); + assertEquals(portForwardCommand(t, "api", 443, localPort(443)).slice(-2), ["pod/api", "8443:443"]); + assertEquals(localPort(8080), 8080); + assertEquals(shellCommand(t, "api", "proxy").slice(5, 11), ["exec", "-it", "api", "-c", "proxy", "--"]); +}); diff --git a/extensions/kubernetes/terminal.ts b/extensions/kubernetes/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/kubernetes/terminal.ts @@ -0,0 +1,77 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/ssh/backslash.toml b/extensions/ssh/backslash.toml new file mode 100644 index 0000000..f058b1f --- /dev/null +++ b/extensions/ssh/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "ssh" +title = "SSH Hosts" +description = "Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager." +version = "1.0.0" +author = "Backslash" +icon = "bs-server" +license = "MIT" + +[permissions] +env = ["HOME"] +read = ["~/.ssh"] +# Terminals that open the session (ssh itself runs inside them). +run = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "hosts" +title = "SSH Hosts" +description = "Hosts of ~/.ssh/config and known_hosts, recent first" +icon = "bs-server" +keywords = ["ssh", "sftp", "remote", "server", "host", "connect", "serveur", "distant", "connexion"] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/ssh/commands/hosts.ts b/extensions/ssh/commands/hosts.ts new file mode 100644 index 0000000..5dbbba9 --- /dev/null +++ b/extensions/ssh/commands/hosts.ts @@ -0,0 +1,163 @@ +import { + type Accessory, + action, + close, + copy, + Icons, + type Item, + list, + meta, + type Metadata, + open, + openExternal, + refresh, + storage, +} from "@backslash/api"; +import { address, ago, type Host, sftpUrl, sortHosts, sshCommand } from "../lib.ts"; +import { loadHosts, SSH_DIR, tilde } from "../hosts.ts"; +import { runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +type Recent = Record; + +const recentKey = (h: Host) => (h.source === "config" ? h.alias : `known:${h.alias}:${h.port ?? 22}`); + +async function remember(h: Host) { + const recent = (await storage.get("recent")) ?? {}; + recent[recentKey(h)] = Date.now(); + // Keep the 30 newest. + const kept = Object.entries(recent).sort((a, b) => b[1] - a[1]).slice(0, 30); + await storage.set("recent", Object.fromEntries(kept)); +} + +async function forget(h: Host) { + const recent = (await storage.get("recent")) ?? {}; + delete recent[recentKey(h)]; + await storage.set("recent", recent); +} + +const FORWARDS = ["LocalForward", "RemoteForward", "DynamicForward"]; + +function metadata(h: Host, last?: number): Metadata[] { + const lines: Metadata[] = []; + if (h.source === "config") { + lines.push(meta.text("HostName", h.hostName ?? h.alias, { icon: Icons.server })); + if (h.user) lines.push(meta.person("User", h.user)); + lines.push(meta.text("Port", h.port ?? "22", { icon: Icons.plug })); + if (h.identityFiles.length) { + lines.push(meta.tags("Identity", h.identityFiles.map((f) => ({ text: tilde(f), color: "yellow" as const })))); + } + if (h.proxyJump) lines.push(meta.text("ProxyJump", h.proxyJump, { icon: Icons.arrowRight, color: "purple" })); + const forwards = h.options.filter(([k]) => FORWARDS.some((f) => f.toLowerCase() === k.toLowerCase())); + if (forwards.length) { + lines.push(meta.tags("Forwards", forwards.map(([k, v]) => `${k.replace("Forward", "")} ${v}`))); + } + const others = h.options.filter((o) => !forwards.includes(o)); + if (others.length) lines.push(meta.tags("Options", others.slice(0, 10).map(([k, v]) => `${k} ${v}`))); + lines.push(meta.separator()); + if (h.file) lines.push(meta.text("Defined in", `${tilde(h.file)}:${h.line}`, { icon: Icons.file })); + } else { + lines.push(meta.text("Host", h.alias, { icon: Icons.globe })); + if (h.port) lines.push(meta.text("Port", h.port, { icon: Icons.plug })); + if (h.addresses?.length) lines.push(meta.tags("Addresses", h.addresses.slice(0, 6))); + if (h.keyTypes?.length) { + lines.push( + meta.tags("Host keys", h.keyTypes.map((k) => ({ text: k.replace(/^ssh-/, ""), color: "green" as const }))), + ); + } + lines.push(meta.separator()); + lines.push(meta.text("Found in", "~/.ssh/known_hosts", { icon: Icons.file })); + } + if (last) lines.push(meta.text("Last opened", ago(last), { icon: Icons.history })); + return lines; +} + +function item(h: Host, prefs: Prefs, recent: Recent): Item { + const last = recent[recentKey(h)]; + const argv = sshCommand(h); + const command = shellLine(argv); + const addr = address(h); + const accessories: Accessory[] = []; + if (h.proxyJump) accessories.push({ icon: Icons.arrowRight, text: h.proxyJump, tooltip: "ProxyJump" }); + if (h.identityFiles.length) { + accessories.push({ icon: { source: Icons.key, tint: "yellow" }, tooltip: h.identityFiles.map(tilde).join(", ") }); + } + if (h.source === "known_hosts") accessories.push({ tag: "known_hosts", color: "gray" }); + if (last) accessories.push({ text: ago(last), icon: Icons.history }); + const connect = action("Connect in Terminal", async () => { + runInTerminal(argv, prefs.terminal || undefined); + await remember(h); + return close(); + }, { icon: Icons.terminal }); + return { + id: recentKey(h), + title: h.alias, + subtitle: addr !== h.alias ? addr : undefined, + icon: { + source: h.source === "config" ? Icons.server : Icons.globe, + tint: h.source === "known_hosts" ? "gray" : h.proxyJump ? "purple" : "blue", + }, + section: last ? "Recent" : h.source === "config" ? "SSH Config" : "Known Hosts", + keywords: [h.hostName ?? "", h.user ?? "", h.proxyJump ?? "", ...(h.addresses ?? [])], + accessories, + detail: { + markdown: [ + "```sh", + command, + "```", + ...(h.raw.length ? ["", "```", ...h.raw.map((l) => l.trimEnd()), "```"] : []), + ].join("\n"), + metadata: metadata(h, last), + }, + actions: [ + connect, + action("Open SFTP in File Manager", async () => { + await openExternal(sftpUrl(h)); + await remember(h); + return close(); + }, { icon: Icons.folder, shortcut: "o" }), + copy(command, "Copy SSH Command", { icon: Icons.terminal, shortcut: "c", section: "Copy" }), + copy(h.hostName ?? h.alias, "Copy Hostname", { icon: Icons.server, shortcut: "period", section: "Copy" }), + ...(addr !== (h.hostName ?? h.alias) ? [copy(addr, "Copy Address", { section: "Copy" })] : []), + copy(sftpUrl(h), "Copy SFTP URL", { icon: Icons.link, section: "Copy" }), + copy(shellLine(["ssh-copy-id", ...argv.slice(1)]), "Copy ssh-copy-id Command", { + icon: Icons.key, + section: "Copy", + }), + ...(h.file ? [open(h.file, "Open Config File", { icon: Icons.file, section: "Config" })] : []), + ...(last + ? [action("Remove from Recent", async () => { + await forget(h); + return refresh(); + }, { icon: Icons.history, section: "Config" })] + : []), + ], + }; +} + +let counts = { config: 0, known: 0 }; + +export default list({ + placeholder: "Search SSH hosts by alias, hostname, user…", + empty: { + title: "No SSH hosts", + description: "Add hosts to ~/.ssh/config, or connect once so they are remembered in ~/.ssh/known_hosts", + icon: Icons.server, + }, + status: () => + `${counts.config} host${counts.config === 1 ? "" : "s"} in config` + + (counts.known ? ` · ${counts.known} more in known_hosts` : ""), + actions: [open(`${SSH_DIR}/config`, "Open SSH Config", { icon: Icons.file })], + async items({ preferences }) { + const prefs = preferences as Prefs; + const { config, known } = loadHosts(); + counts = { config: config.length, known: known.length }; + const recent = (await storage.get("recent")) ?? {}; + const byKey: Record = {}; + for (const h of [...config, ...known]) if (recent[recentKey(h)]) byKey[h.alias] = recent[recentKey(h)]; + return sortHosts([...config, ...known], byKey).map((h) => item(h, prefs, recent)); + }, +}); diff --git a/extensions/ssh/hosts.ts b/extensions/ssh/hosts.ts new file mode 100644 index 0000000..b77dbeb --- /dev/null +++ b/extensions/ssh/hosts.ts @@ -0,0 +1,56 @@ +// Reading the files: ~/.ssh/config with its Includes, ~/.ssh/known_hosts. + +import { extraKnownHosts, globRegExp, type Host, parseBlocks, parseKnownHosts, resolveHosts } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +export const SSH_DIR = `${HOME}/.ssh`; + +const read = (path: string) => { + try { + return Deno.readTextFileSync(path); + } catch { + return undefined; // missing, or outside the extension's permissions + } +}; + +/** `~/x`, `config.d/*` (relative to ~/.ssh), absolute paths; globs in the file name. */ +export function expand(pattern: string): { file: string; text: string }[] { + let path = pattern.replace(/^~(?=\/|$)/, HOME); + if (!path.startsWith("/")) path = `${SSH_DIR}/${path}`; + const slash = path.lastIndexOf("/"); + const [dir, name] = [path.slice(0, slash), path.slice(slash + 1)]; + if (!/[*?]/.test(name)) { + const text = read(path); + return text === undefined ? [] : [{ file: path, text }]; + } + const re = globRegExp(name); + try { + return [...Deno.readDirSync(dir)] + .filter((e) => !e.isDirectory && re.test(e.name)) + .map((e) => e.name) + .sort() + .flatMap((n) => { + const text = read(`${dir}/${n}`); + return text === undefined ? [] : [{ file: `${dir}/${n}`, text }]; + }); + } catch { + return []; + } +} + +export interface Found { + config: Host[]; + known: Host[]; + hasConfig: boolean; +} + +export function loadHosts(): Found { + const configText = read(`${SSH_DIR}/config`); + const config = configText === undefined ? [] : resolveHosts(parseBlocks(configText, `${SSH_DIR}/config`, expand)); + const known = parseKnownHosts( + [read(`${SSH_DIR}/known_hosts`) ?? "", read(`${SSH_DIR}/known_hosts2`) ?? ""].join("\n"), + ); + return { config, known: extraKnownHosts(config, known), hasConfig: configText !== undefined }; +} + +export const tilde = (path: string) => (HOME && path.startsWith(HOME) ? `~${path.slice(HOME.length)}` : path); diff --git a/extensions/ssh/lib.ts b/extensions/ssh/lib.ts new file mode 100644 index 0000000..e0b3bab --- /dev/null +++ b/extensions/ssh/lib.ts @@ -0,0 +1,256 @@ +// Parsing ~/.ssh/config and ~/.ssh/known_hosts. Pure, tested in lib_test.ts. + +/** One `Host` (or `Match`) block, in file order. */ +export interface Block { + patterns: string[]; + /** `[key in lowercase, Key as written, value]` */ + options: [string, string, string][]; + file: string; + line: number; + /** The block as written, for the side panel. */ + raw: string[]; + /** `Match` blocks are kept out of the resolution: their conditions need a live ssh. */ + match?: boolean; +} + +export interface Host { + alias: string; + hostName?: string; + user?: string; + port?: string; + identityFiles: string[]; + proxyJump?: string; + /** Other options that apply, first value wins (as ssh does), `[Key, value]`. */ + options: [string, string][]; + /** Where the host is defined. */ + file?: string; + line?: number; + raw: string[]; + source: "config" | "known_hosts"; + /** known_hosts: key types and other names (IP addresses) of the host. */ + keyTypes?: string[]; + addresses?: string[]; +} + +/** `Key value`, `Key=value`, `Key "quoted value"`. */ +export function splitLine(line: string): [string, string] | undefined { + const m = line.match(/^\s*([A-Za-z][A-Za-z0-9]*)\s*(?:=\s*|\s+)(.*?)\s*$/); + if (!m || line.trimStart().startsWith("#")) return undefined; + let value = m[2]; + if (/^"[^"]*"$/.test(value)) value = value.slice(1, -1); + return [m[1], value]; +} + +/** Words of a value, honoring double quotes: `a "b c"` → `["a", "b c"]`. */ +export const words = (value: string) => [...value.matchAll(/"([^"]*)"|(\S+)/g)].map((m) => m[1] ?? m[2]); + +const isPattern = (p: string) => /[*?!]/.test(p); + +/** ssh_config(5) patterns: `*`, `?`, and `!negation` within a list. */ +export function matches(patterns: string[], host: string): boolean { + let hit = false; + for (const p of patterns) { + const negated = p.startsWith("!"); + const glob = negated ? p.slice(1) : p; + const re = new RegExp( + `^${glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`, + "i", + ); + if (re.test(host)) { + if (negated) return false; + hit = true; + } + } + return hit; +} + +/** Files read for an `Include` pattern, already expanded by the caller (globs, `~`). */ +export type Resolver = (pattern: string) => { file: string; text: string }[]; + +/** + * The blocks of a config file, `Include`s inlined in place. Lines before the + * first `Host` apply to every host (as if under `Host *`). + */ +export function parseBlocks(text: string, file: string, include: Resolver, depth = 0): Block[] { + const blocks: Block[] = []; + let current: Block = { patterns: ["*"], options: [], file, line: 0, raw: [] }; + const push = () => { + if (current.options.length || current.raw.length) blocks.push(current); + }; + text.split(/\r?\n/).forEach((line, i) => { + const kv = splitLine(line); + if (!kv) { + if (line.trim() && current.raw.length) current.raw.push(line); + return; + } + const [key, value] = kv; + const lower = key.toLowerCase(); + if (lower === "host" || lower === "match") { + push(); + current = { + patterns: lower === "host" ? words(value) : [], + options: [], + file, + line: i + 1, + raw: [line], + match: lower === "match", + }; + return; + } + current.raw.push(line); + if (lower === "include") { + if (depth >= 8) return; // ssh's own limit: guards against loops + for (const pattern of words(value)) { + for (const inc of include(pattern)) { + const inner = parseBlocks(inc.text, inc.file, include, depth + 1); + // Lines at the top of an included file belong to the block that includes it. + for (const b of inner) { + if (b.line === 0) current.options.push(...b.options); + else { + push(); + blocks.push(b); + current = { ...current, options: [], raw: [] }; + } + } + } + } + return; + } + current.options.push([lower, key, value]); + }); + push(); + return blocks; +} + +const MULTI = new Set([ + "identityfile", + "localforward", + "remoteforward", + "dynamicforward", + "certificatefile", + "sendenv", +]); + +/** Every concrete host of the config with the options that apply to it, in file order. */ +export function resolveHosts(blocks: Block[]): Host[] { + const aliases: string[] = []; + for (const b of blocks) { + if (b.match) continue; + for (const p of b.patterns) if (!isPattern(p) && !aliases.includes(p)) aliases.push(p); + } + return aliases.map((alias) => { + const own = blocks.find((b) => !b.match && b.patterns.includes(alias))!; + const values = new Map(); + for (const b of blocks) { + if (b.match || !matches(b.patterns, alias)) continue; + for (const [lower, key, value] of b.options) { + const seen = values.get(lower); + if (!seen) values.set(lower, [[key, value]]); + else if (MULTI.has(lower)) seen.push([key, value]); + } + } + const one = (k: string) => values.get(k)?.[0][1]; + const hostName = one("hostname")?.replace(/%h/g, alias).replace(/%%/g, "%"); + const known = new Set(["hostname", "user", "port", "identityfile", "proxyjump"]); + return { + alias, + hostName, + user: one("user"), + port: one("port"), + identityFiles: (values.get("identityfile") ?? []).map(([, v]) => v), + proxyJump: one("proxyjump"), + options: [...values].filter(([k]) => !known.has(k)).flatMap(([, kvs]) => kvs), + file: own.file, + line: own.line, + raw: own.raw, + source: "config" as const, + }; + }); +} + +const IP = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:.]+)$/i; + +/** Hosts of a known_hosts file; hashed entries, markers and patterns are skipped. */ +export function parseKnownHosts(text: string): Host[] { + const byName = new Map(); + for (const line of text.split(/\r?\n/)) { + const t = line.trim(); + if (!t || t.startsWith("#") || t.startsWith("@") || t.startsWith("|")) continue; + const [names, keyType] = t.split(/\s+/); + if (!names || !keyType) continue; + const parsed = names.split(",").filter((n) => n && !isPattern(n)).map((n) => { + const m = n.match(/^\[([^\]]+)\]:(\d+)$/); + return m ? { host: m[1], port: m[2] } : { host: n, port: undefined }; + }); + if (!parsed.length) continue; + const main = parsed.find((p) => !IP.test(p.host)) ?? parsed[0]; + const id = main.port ? `[${main.host}]:${main.port}` : main.host; + const entry = byName.get(id) ?? { + alias: main.host, + hostName: main.host, + port: main.port, + identityFiles: [], + options: [], + raw: [], + source: "known_hosts" as const, + keyTypes: [], + addresses: [], + }; + if (!entry.keyTypes!.includes(keyType)) entry.keyTypes!.push(keyType); + for (const p of parsed) { + if (p.host !== main.host && !entry.addresses!.includes(p.host)) entry.addresses!.push(p.host); + } + byName.set(id, entry); + } + return [...byName.values()]; +} + +/** Known hosts that the config does not already cover (by alias or HostName). */ +export function extraKnownHosts(config: Host[], known: Host[]): Host[] { + const covered = new Set(config.flatMap((h) => [h.alias, h.hostName ?? ""]).map((n) => n.toLowerCase())); + return known.filter((k) => !covered.has(k.alias.toLowerCase())); +} + +/** `ssh alias`, or `ssh -p 2222 host` for a host that only is in known_hosts. */ +export function sshCommand(h: Host): string[] { + if (h.source === "config") return ["ssh", h.alias]; + return ["ssh", ...(h.port && h.port !== "22" ? ["-p", h.port] : []), h.alias]; +} + +/** For GVfs / KIO: aliases work there too, as they run ssh with your config. */ +export function sftpUrl(h: Host): string { + if (h.source === "config") return `sftp://${h.alias}/`; + const host = h.alias.includes(":") ? `[${h.alias}]` : h.alias; + return `sftp://${host}${h.port && h.port !== "22" ? `:${h.port}` : ""}/`; +} + +/** `deploy@10.0.0.5:2222`, defaults left out. */ +export function address(h: Host): string { + const host = h.hostName ?? h.alias; + return `${h.user ? `${h.user}@` : ""}${host}${h.port && h.port !== "22" ? `:${h.port}` : ""}`; +} + +/** Recently opened first (newest first), then the config order, then known hosts by name. */ +export function sortHosts(hosts: Host[], recent: Record): Host[] { + const order = new Map(hosts.map((h, i) => [h, i])); + return [...hosts].sort((a, b) => + (recent[b.alias] ?? 0) - (recent[a.alias] ?? 0) || + Number(a.source === "known_hosts") - Number(b.source === "known_hosts") || + (a.source === "known_hosts" ? a.alias.localeCompare(b.alias) : order.get(a)! - order.get(b)!) + ); +} + +/** "3h ago" */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, (now - ms) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; + return new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short", year: "numeric" }); +} + +/** A glob's last path component as a RegExp: `*.conf` → /^.*\.conf$/. */ +export function globRegExp(glob: string): RegExp { + return new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`); +} diff --git a/extensions/ssh/lib_test.ts b/extensions/ssh/lib_test.ts new file mode 100644 index 0000000..7b6f4bf --- /dev/null +++ b/extensions/ssh/lib_test.ts @@ -0,0 +1,158 @@ +import { assertEquals } from "@std/assert"; +import { + address, + ago, + extraKnownHosts, + type Host, + matches, + parseBlocks, + parseKnownHosts, + resolveHosts, + type Resolver, + sftpUrl, + sortHosts, + splitLine, + sshCommand, +} from "./lib.ts"; +import { shellLine, terminalArgs } from "./terminal.ts"; + +const CONFIG = `# Personal servers +Include config.d/* + +Host bastion + HostName bastion.example.com + User admin + IdentityFile ~/.ssh/id_ed25519 + +Host web-1 web-2 + HostName %h.internal.example.com + ProxyJump bastion + LocalForward 8080 localhost:80 + +Host db + HostName=10.0.0.5 + Port 2222 + User "postgres admin" + IdentityFile ~/.ssh/db_key + IdentityFile ~/.ssh/fallback + +Match host *.corp exec "true" + User corp + +Host *.example.com !legacy.example.com + ForwardAgent yes + +Host * + User me + ServerAliveInterval 30 +`; + +const FILES: Record = { + "config.d/*": "Host homelab\n HostName 192.168.1.10\n Port 22\n", +}; +const resolver: Resolver = (pattern) => + FILES[pattern] ? [{ file: `/home/me/.ssh/${pattern}`, text: FILES[pattern] }] : []; + +Deno.test("config lines", () => { + assertEquals(splitLine(" HostName example.com"), ["HostName", "example.com"]); + assertEquals(splitLine("Port=2222"), ["Port", "2222"]); + assertEquals(splitLine('User "a b"'), ["User", "a b"]); + assertEquals(splitLine("# comment"), undefined); + assertEquals(splitLine(""), undefined); +}); + +Deno.test("patterns", () => { + assertEquals(matches(["*.example.com", "!legacy.example.com"], "web.example.com"), true); + assertEquals(matches(["*.example.com", "!legacy.example.com"], "legacy.example.com"), false); + assertEquals(matches(["web-?"], "web-1"), true); + assertEquals(matches(["*"], "anything"), true); + assertEquals(matches(["db"], "db2"), false); +}); + +Deno.test("hosts of a config, with includes, wildcards skipped", () => { + const hosts = resolveHosts(parseBlocks(CONFIG, "/home/me/.ssh/config", resolver)); + assertEquals(hosts.map((h) => h.alias), ["homelab", "bastion", "web-1", "web-2", "db"]); + const [homelab, bastion, web1, , db] = hosts; + assertEquals(homelab.hostName, "192.168.1.10"); + assertEquals(homelab.file, "/home/me/.ssh/config.d/*"); + assertEquals(bastion.user, "admin"); + assertEquals(bastion.identityFiles, ["~/.ssh/id_ed25519"]); + assertEquals(bastion.line, 4); + assertEquals(web1.hostName, "web-1.internal.example.com"); + assertEquals(web1.proxyJump, "bastion"); + // From `Host *`, as the block does not set one. + assertEquals(web1.user, "me"); + assertEquals(web1.options, [["LocalForward", "8080 localhost:80"], ["ServerAliveInterval", "30"]]); + assertEquals(db.port, "2222"); + assertEquals(db.user, "postgres admin"); + assertEquals(db.identityFiles, ["~/.ssh/db_key", "~/.ssh/fallback"]); + assertEquals(db.raw[0], "Host db"); + // `Host *.example.com` applies to the bastion by its alias? No: patterns match the alias. + assertEquals(bastion.options.some(([k]) => k === "ForwardAgent"), false); +}); + +Deno.test("include loops stop", () => { + const loop: Resolver = () => [{ file: "/x", text: "Include x\nHost a\n" }]; + const hosts = resolveHosts(parseBlocks("Include x", "/c", loop)); + assertEquals(hosts.map((h) => h.alias), ["a"]); +}); + +const KNOWN = `github.com,140.82.121.4 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl +github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT +|1|JfKTdBh7rNbXkVAQCRp4OQoPfmI=|USECr3SWf1JUPsms5AqfD5QfxkM= ssh-rsa AAAAB3NzaC1yc2E +[git.example.org]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA +@cert-authority *.example.com ssh-rsa AAAAB3NzaC1yc2E +192.168.1.10 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB +*.wild.net ssh-rsa AAAA +bastion.example.com ssh-ed25519 AAAA +`; + +Deno.test("known_hosts", () => { + const known = parseKnownHosts(KNOWN); + assertEquals(known.map((h) => [h.alias, h.port]), [ + ["github.com", undefined], + ["git.example.org", "2222"], + ["192.168.1.10", undefined], + ["bastion.example.com", undefined], + ]); + assertEquals(known[0].keyTypes, ["ssh-ed25519", "ecdsa-sha2-nistp256"]); + assertEquals(known[0].addresses, ["140.82.121.4"]); + const config = resolveHosts(parseBlocks(CONFIG, "/c", resolver)); + // The homelab and the bastion are already in the config, by HostName. + assertEquals(extraKnownHosts(config, known).map((h) => h.alias), ["github.com", "git.example.org"]); +}); + +Deno.test("commands and URLs", () => { + const [, bastion, , , db] = resolveHosts(parseBlocks(CONFIG, "/c", resolver)); + const [, gitea] = parseKnownHosts(KNOWN); + assertEquals(sshCommand(db), ["ssh", "db"]); + assertEquals(sshCommand(gitea), ["ssh", "-p", "2222", "git.example.org"]); + assertEquals(sftpUrl(db), "sftp://db/"); + assertEquals(sftpUrl(gitea), "sftp://git.example.org:2222/"); + assertEquals(address(db), "postgres admin@10.0.0.5:2222"); + assertEquals(address(bastion), "admin@bastion.example.com"); +}); + +Deno.test("recent first, then the config order, then known hosts", () => { + const h = (alias: string, source: Host["source"] = "config"): Host => ({ + alias, + identityFiles: [], + options: [], + raw: [], + source, + }); + const sorted = sortHosts([h("b"), h("a"), h("zz", "known_hosts"), h("c"), h("aa", "known_hosts")], { c: 10, zz: 20 }); + assertEquals(sorted.map((x) => x.alias), ["zz", "c", "b", "a", "aa"]); + const now = Date.UTC(2026, 9, 28, 12); + assertEquals(ago(now - 3 * 3600_000, now), "3h ago"); + assertEquals(ago(now - 20_000, now), "just now"); +}); + +Deno.test("terminal command lines", () => { + assertEquals(terminalArgs("kitty", ["ssh", "db"]), ["ssh", "db"]); + assertEquals(terminalArgs("gnome-terminal", ["ssh", "db"]), ["--", "ssh", "db"]); + assertEquals(terminalArgs("wezterm", ["ssh", "db"]), ["start", "--", "ssh", "db"]); + assertEquals(terminalArgs("alacritty", ["ssh", "db"]), ["-e", "ssh", "db"]); + assertEquals(terminalArgs("tilix", ["ssh", "my host"]), ["-e", "ssh 'my host'"]); + assertEquals(shellLine(["ssh", "-p", "22", "it's"]), "ssh -p 22 'it'\\''s'"); +}); diff --git a/extensions/ssh/terminal.ts b/extensions/ssh/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/ssh/terminal.ts @@ -0,0 +1,77 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/terminal-sessions/backslash.toml b/extensions/terminal-sessions/backslash.toml new file mode 100644 index 0000000..b15af00 --- /dev/null +++ b/extensions/terminal-sessions/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "terminal-sessions" +title = "Terminal Sessions" +description = "Attach to, create, rename and kill your tmux and zellij sessions." +version = "1.0.0" +author = "Backslash" +icon = "bs-terminal" +license = "MIT" + +[permissions] +env = ["HOME"] +run = [ + "tmux", + "zellij", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "sessions" +title = "Terminal Sessions" +description = "tmux and zellij sessions: attach, create, rename, kill" +icon = "bs-terminal" +keywords = ["tmux", "zellij", "session", "multiplexer", "attach", "terminal", "multiplexeur", "attacher"] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/terminal-sessions/commands/sessions.ts b/extensions/terminal-sessions/commands/sessions.ts new file mode 100644 index 0000000..1f00875 --- /dev/null +++ b/extensions/terminal-sessions/commands/sessions.ts @@ -0,0 +1,325 @@ +import { + type Action, + action, + close, + copy, + exec, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + refresh, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { + attachCommand, + type Backend, + invalidName, + killCommand, + longDate, + newSessionCommand, + parseTmuxSessions, + parseZellijSessions, + renameCommand, + type Session, + short, + sortSessions, + TMUX_SESSION_FORMAT, + TMUX_WINDOW_FORMAT, +} from "../lib.ts"; +import { runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +const HOME = Deno.env.get("HOME") ?? ""; +const NAMES: Record = { tmux: "tmux", zellij: "zellij" }; + +interface Found { + installed: Backend[]; + sessions: Session[]; +} + +const missing = (err: unknown) => err instanceof Deno.errors.NotFound; + +async function tmux(): Promise { + try { + const out = await exec("tmux", ["list-sessions", "-F", TMUX_SESSION_FORMAT]); + const windows = await exec("tmux", ["list-windows", "-a", "-F", TMUX_WINDOW_FORMAT]).catch(() => ""); + return parseTmuxSessions(out, windows); + } catch (err) { + if (missing(err)) return undefined; + // "no server running on /tmp/tmux-1000/default", "error connecting to …": no session yet. + return []; + } +} + +async function zellij(): Promise { + try { + return parseZellijSessions(await exec("zellij", ["list-sessions", "--no-formatting"])); + } catch (err) { + if (missing(err)) return undefined; + return []; // "No active zellij sessions found." + } +} + +async function load(): Promise { + const [t, z] = await Promise.all([tmux(), zellij()]); + return { + installed: [...(t ? ["tmux" as const] : []), ...(z ? ["zellij" as const] : [])], + sessions: sortSessions([...(t ?? []), ...(z ?? [])]), + }; +} + +const expandHome = (dir: string) => dir.trim().replace(/^~(?=\/|$)/, HOME); +const tilde = (path: string) => (HOME && path.startsWith(HOME) ? `~${path.slice(HOME.length)}` : path); + +function newSessionForm(found: Found, prefs: Prefs, backend?: Backend) { + const existing = found.sessions.map((s) => s.name); + let n = 1; + while (existing.includes(`session-${n}`)) n++; + return form({ + title: "New Terminal Session", + submitTitle: "Create Session", + fields: [ + field.text("name", "Name", { required: true, value: `session-${n}`, placeholder: "work" }), + ...(found.installed.length > 1 + ? [field.dropdown("backend", "Multiplexer", found.installed.map((b) => ({ value: b, title: NAMES[b] })), { + value: backend ?? found.installed[0], + })] + : []), + field.text("dir", "Start directory", { placeholder: "~ (home folder)" }), + field.checkbox("attach", "Attach in a terminal now", { value: true }), + ], + async submit(values) { + const name = String(values.name).trim(); + const error = invalidName(name, existing); + if (error) throw new Error(error); + const chosen = (values.backend as Backend | undefined) ?? found.installed[0]; + const dir = expandHome(String(values.dir ?? "")) || undefined; + if (values.attach) { + runInTerminal( + newSessionCommand(chosen, name, false, dir), + prefs.terminal || undefined, + dir ?? (HOME || undefined), + ); + return close(); + } + if (chosen === "zellij") { + // zellij starts in the directory it is launched from. + await new Deno.Command("zellij", { args: newSessionCommand(chosen, name, true).slice(1), cwd: dir }).output(); + } else { + await exec("tmux", newSessionCommand(chosen, name, true, dir).slice(1)); + } + return close(`Session “${name}” created`); + }, + }); +} + +function renameForm(s: Session, found: Found) { + return form({ + title: `Rename “${s.name}”`, + submitTitle: "Rename", + fields: [field.text("name", "New name", { required: true, value: s.name })], + async submit(values) { + const name = String(values.name).trim(); + if (name === s.name) return close(); + const error = invalidName(name, found.sessions.filter((x) => x.backend === s.backend).map((x) => x.name)); + if (error) throw new Error(error); + const [program, ...args] = renameCommand(s, name); + await exec(program, args); + return close(`Renamed to “${name}”`); + }, + }); +} + +function statusOf(s: Session): { text: string; color: "green" | "blue" | "gray" | "teal" } { + if (s.exited) return { text: "Exited", color: "gray" }; + if (s.current) return { text: "Current", color: "teal" }; + if (s.attached) return { text: s.attached > 1 ? `Attached (${s.attached} clients)` : "Attached", color: "green" }; + return { text: "Detached", color: "blue" }; +} + +function metadata(s: Session): Metadata[] { + const st = statusOf(s); + const panes = s.windowList.reduce((n, w) => n + w.panes, 0); + return [ + meta.text("Status", st.text, { icon: Icons.dot, color: st.color }), + meta.text("Multiplexer", NAMES[s.backend], { icon: Icons.terminal }), + ...(s.windows !== undefined + ? [meta.text("Windows", panes ? `${s.windows} (${panes} pane${panes === 1 ? "" : "s"})` : s.windows)] + : []), + ...(s.path ? [meta.text("Directory", tilde(s.path), { icon: Icons.folder })] : []), + ...(s.group ? [meta.text("Group", s.group, { icon: Icons.layers })] : []), + meta.separator(), + ...(s.created ? [meta.text("Created", longDate(s.created), { icon: Icons.calendar })] : []), + ...(s.activity ? [meta.text("Last activity", longDate(s.activity), { icon: Icons.clock })] : []), + ]; +} + +function windowsMarkdown(s: Session): string | undefined { + if (s.backend === "zellij") { + return s.exited ? "This session has exited. Attaching resurrects it with its layout." : undefined; + } + if (!s.windowList.length) return undefined; + const lines = s.windowList.map((w) => { + const what = [ + w.command ? `\`${w.command}\`` : "", + w.panes > 1 ? `${w.panes} panes` : "", + w.path ? tilde(w.path) : "", + ] + .filter(Boolean).join(" · "); + return `- **${w.index}: ${w.name.replace(/[*_`]/g, "")}**${w.active ? " (active)" : ""}${what ? ` · ${what}` : ""}`; + }); + return `### Windows\n\n${lines.join("\n")}`; +} + +function item(s: Session, found: Found, prefs: Prefs): Item { + const st = statusOf(s); + const term = prefs.terminal || undefined; + const attach = (title: string, window?: number) => + action(title, () => { + runInTerminal(attachCommand(s, window), term, HOME || undefined); + return close(); + }, { icon: Icons.terminal }); + const run = (argv: string[]) => async () => { + await exec(argv[0], argv.slice(1)); + return refresh(); + }; + const actions: Action[] = [attach(s.exited ? "Resurrect in Terminal" : "Attach in Terminal")]; + if (s.windowList.length > 1) { + actions.push(submenu( + "Attach to Window", + s.windowList.map((w) => + action(`${w.index}: ${w.name}`, () => { + runInTerminal(attachCommand(s, w.index), term, HOME || undefined); + return close(); + }, { icon: w.active ? Icons.check : Icons.window }) + ), + { icon: Icons.window, shortcut: "w" }, + )); + } + if (s.backend === "tmux" && s.attached) { + actions.push(action("Detach Clients", run(["tmux", "detach-client", "-s", `=${s.name}`]), { + icon: Icons.eyeOff, + shortcut: "d", + })); + } + actions.push( + action("New Session", () => newSessionForm(found, prefs, s.backend), { + icon: Icons.plus, + shortcut: "n", + section: "Session", + }), + ...(s.exited ? [] : [action("Rename Session", () => renameForm(s, found), { + icon: Icons.text, + shortcut: "F2", + section: "Session", + })]), + copy(shellLine(attachCommand(s)), "Copy Attach Command", { + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(s.name, "Copy Session Name", { section: "Copy" }), + action(s.exited ? "Delete Session" : "Kill Session", run(killCommand(s)), { + icon: Icons.trash, + destructive: true, + shortcut: "Delete", + confirm: s.attached || s.current + ? `Kill “${s.name}”? It is attached: its programs will be closed.` + : `Kill “${s.name}” and everything running in it?`, + section: "Danger", + }), + ); + return { + id: `${s.backend}:${s.name}`, + title: s.name, + subtitle: s.path ? tilde(s.path) : undefined, + icon: { source: Icons.terminal, tint: st.color }, + section: NAMES[s.backend], + keywords: [s.backend, ...s.windowList.flatMap((w) => [w.name, w.command ?? ""])], + accessories: [ + ...(s.windows !== undefined + ? [{ icon: Icons.window, text: String(s.windows), tooltip: `${s.windows} window${s.windows === 1 ? "" : "s"}` }] + : []), + ...(s.attached || s.current || s.exited ? [{ tag: st.text.replace(/ \(.*/, ""), color: st.color }] : []), + ...(s.created + ? [{ text: short(s.activity ?? s.created), tooltip: s.activity ? "Last activity" : "Created" }] + : []), + ], + detail: { + stats: s.backend === "tmux" + ? [ + { label: "windows", value: s.windows ?? 0 }, + { label: "panes", value: s.windowList.reduce((n, w) => n + w.panes, 0) }, + { label: "clients", value: s.attached, color: s.attached ? "green" : undefined }, + ] + : undefined, + markdown: windowsMarkdown(s), + metadata: metadata(s), + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter tmux and zellij sessions…", + empty: { + title: "No terminal sessions", + description: "Press Ctrl+N to start one. Sessions keep running when you close the terminal.", + icon: Icons.terminal, + }, + status: () => summary, + async items({ preferences }) { + const prefs = preferences as Prefs; + const found = await load(); + if (!found.installed.length) { + summary = ""; + return [{ + id: "missing", + title: "Neither tmux nor zellij is installed", + subtitle: "Install one of them with your package manager", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: + "Terminal Sessions lists the sessions of **tmux** and **zellij**, terminal multiplexers that keep your shells running in the background.\n\n```sh\nsudo apt install tmux # Debian, Ubuntu\nsudo dnf install tmux # Fedora\nsudo pacman -S tmux zellij # Arch\n```", + }, + actions: [ + copy("sudo apt install tmux", "Copy apt Command", { icon: Icons.terminal }), + copy("sudo dnf install tmux", "Copy dnf Command", { icon: Icons.terminal }), + ], + }]; + } + const { sessions } = found; + const attached = sessions.filter((s) => s.attached || s.current).length; + summary = found.installed.map((b) => { + const n = sessions.filter((s) => s.backend === b).length; + return `${n} ${NAMES[b]}`; + }).join(" · ") + ` · ${attached} attached`; + await updateCommandMetadata({ + subtitle: sessions.length + ? `${sessions.length} session${sessions.length === 1 ? "" : "s"}, ${attached} attached` + : null, + }).catch(() => {}); + const items = sessions.map((s) => item(s, found, prefs)); + // A way to create a session even when the list is empty or filtered out. + items.push({ + id: "new", + title: "New Session…", + subtitle: found.installed.map((b) => NAMES[b]).join(" or "), + icon: { source: Icons.plus, tint: "gray" }, + section: "Create", + keywords: ["new", "create", "nouvelle"], + actions: [action("New Session", () => newSessionForm(found, prefs), { icon: Icons.plus, shortcut: "n" })], + }); + return items; + }, +}); diff --git a/extensions/terminal-sessions/lib.ts b/extensions/terminal-sessions/lib.ts new file mode 100644 index 0000000..0706a95 --- /dev/null +++ b/extensions/terminal-sessions/lib.ts @@ -0,0 +1,183 @@ +// Parsing tmux and zellij session lists. Pure, tested in lib_test.ts. + +export type Backend = "tmux" | "zellij"; + +export interface Window { + index: number; + name: string; + active: boolean; + panes: number; + command?: string; + path?: string; +} + +export interface Session { + backend: Backend; + name: string; + /** Clients attached right now. */ + attached: number; + created?: Date; + activity?: Date; + windows?: number; + path?: string; + group?: string; + windowList: Window[]; + /** zellij: the session this command runs in. */ + current?: boolean; + /** zellij: exited, can be resurrected by attaching. */ + exited?: boolean; +} + +// tmux prints control characters (tabs) as "_", and non-ASCII too without a UTF-8 locale. +export const SEP = "|:|"; + +/** What `tmux list-sessions -F` prints, one field per {@link SEP}. */ +export const TMUX_SESSION_FORMAT = [ + "#{session_name}", + "#{session_windows}", + "#{session_attached}", + "#{session_created}", + "#{session_activity}", + "#{session_path}", + "#{session_group}", +].join(SEP); + +export const TMUX_WINDOW_FORMAT = [ + "#{session_name}", + "#{window_index}", + "#{window_name}", + "#{window_active}", + "#{window_panes}", + "#{pane_current_command}", + "#{pane_current_path}", +].join(SEP); + +const unix = (s: string | undefined) => (s && /^\d+$/.test(s) ? new Date(Number(s) * 1000) : undefined); + +export function parseTmuxSessions(out: string, windowsOut = ""): Session[] { + const windows = new Map(); + for (const line of windowsOut.split("\n")) { + const [session, index, name, active, panes, command, path] = line.split(SEP); + if (!session || index === undefined) continue; + const list = windows.get(session) ?? []; + list.push({ + index: Number(index), + name: name ?? "", + active: active === "1", + panes: Number(panes) || 1, + command: command || undefined, + path: path || undefined, + }); + windows.set(session, list); + } + return out.split("\n").filter((l) => l.includes(SEP)).map((line) => { + const [name, count, attached, created, activity, path, group] = line.split(SEP); + return { + backend: "tmux" as const, + name, + windows: Number(count) || 0, + attached: Number(attached) || 0, + created: unix(created), + activity: unix(activity), + path: path || undefined, + group: group || undefined, + windowList: windows.get(name) ?? [], + }; + }); +} + +/** "2h 3m 10s", "3days", "1day 4h" → seconds. */ +export function zellijDuration(text: string): number { + let seconds = 0; + for (const m of text.matchAll(/(\d+)\s*(days?|d|h|m|s)\b/g)) { + const n = Number(m[1]); + seconds += m[2].startsWith("d") ? n * 86400 : m[2] === "h" ? n * 3600 : m[2] === "m" ? n * 60 : n; + } + return seconds; +} + +/** `zellij list-sessions --no-formatting`. */ +export function parseZellijSessions(out: string, now = new Date()): Session[] { + return out.split("\n").flatMap((line) => { + const m = line.trim().match(/^(\S+)\s+\[Created (.+?) ago\]\s*(.*)$/); + if (!m) return []; + const flags = m[3]; + return [{ + backend: "zellij" as const, + name: m[1], + attached: 0, + created: new Date(now.getTime() - zellijDuration(m[2]) * 1000), + current: /\(current\)/.test(flags), + exited: /EXITED/.test(flags), + windowList: [], + }]; + }); +} + +/** Why a session name is refused, or undefined when it is fine. */ +export function invalidName(name: string, existing: string[] = []): string | undefined { + const n = name.trim(); + if (!n) return "Give the session a name"; + if (/[.:]/.test(n)) return "Session names can't contain “.” or “:”"; + if (/\s/.test(n)) return "Session names can't contain spaces"; + if (existing.includes(n)) return `A session named “${n}” already exists`; + return undefined; +} + +/** The command a terminal runs to attach (`window` only for tmux). */ +export function attachCommand(s: Session, window?: number): string[] { + if (s.backend === "zellij") return ["zellij", "attach", s.name]; + return ["tmux", "attach-session", "-t", window === undefined ? `=${s.name}` : `=${s.name}:${window}`]; +} + +export function newSessionCommand(backend: Backend, name: string, detached: boolean, dir?: string): string[] { + if (backend === "zellij") { + return ["zellij", "attach", detached ? "--create-background" : "--create", name]; + } + return ["tmux", "new-session", ...(detached ? ["-d"] : []), "-s", name, ...(dir ? ["-c", dir] : [])]; +} + +export function killCommand(s: Session): string[] { + if (s.backend === "zellij") { + return s.exited ? ["zellij", "delete-session", s.name] : ["zellij", "kill-session", s.name]; + } + return ["tmux", "kill-session", "-t", `=${s.name}`]; +} + +export function renameCommand(s: Session, to: string): string[] { + if (s.backend === "zellij") return ["zellij", "--session", s.name, "action", "rename-session", to]; + return ["tmux", "rename-session", "-t", `=${s.name}`, to]; +} + +/** Attached first, then the most recently used. */ +export function sortSessions(sessions: Session[]): Session[] { + return [...sessions].sort((a, b) => + Number(!!a.exited) - Number(!!b.exited) || + Number(b.attached > 0 || !!b.current) - Number(a.attached > 0 || !!a.current) || + ((b.activity ?? b.created)?.getTime() ?? 0) - ((a.activity ?? a.created)?.getTime() ?? 0) + ); +} + +/** "3h", "2d", "now". */ +export function short(date: Date | undefined, now = new Date()): string { + if (!date) return ""; + const s = Math.max(0, (now.getTime() - date.getTime()) / 1000); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`; + return `${Math.floor(s / (86400 * 365))}y`; +} + +/** "Tue 28 Oct, 09:12 (3h ago)" */ +export function longDate(date: Date, now = new Date()): string { + const text = date.toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); + const ago = short(date, now); + return `${text} (${ago === "now" ? "just now" : `${ago} ago`})`; +} diff --git a/extensions/terminal-sessions/lib_test.ts b/extensions/terminal-sessions/lib_test.ts new file mode 100644 index 0000000..787fa75 --- /dev/null +++ b/extensions/terminal-sessions/lib_test.ts @@ -0,0 +1,100 @@ +import { assertEquals } from "@std/assert"; +import { + attachCommand, + invalidName, + killCommand, + newSessionCommand, + parseTmuxSessions, + parseZellijSessions, + renameCommand, + short, + sortSessions, + zellijDuration, +} from "./lib.ts"; +import { terminalArgs } from "./terminal.ts"; + +const TMUX = [ + "work|:|3|:|1|:|1761638400|:|1761645600|:|/home/me/code/api|:|", + "notes|:|1|:|0|:|1761552000|:|1761555600|:|/home/me|:|", + "pair|:|2|:|2|:|1761640000|:|1761649000|:|/srv|:|team", +].join("\n"); + +const WINDOWS = [ + "work|:|0|:|editor|:|1|:|2|:|nvim|:|/home/me/code/api", + "work|:|1|:|server|:|0|:|1|:|node|:|/home/me/code/api", + "work|:|2|:|zsh|:|0|:|1|:|zsh|:|/home/me", + "notes|:|1|:|vim|:|1|:|1|:|vim|:|/home/me", +].join("\n"); + +Deno.test("tmux sessions and their windows", () => { + const [work, notes, pair] = parseTmuxSessions(TMUX, WINDOWS); + assertEquals(work.name, "work"); + assertEquals(work.windows, 3); + assertEquals(work.attached, 1); + assertEquals(work.created?.toISOString(), "2025-10-28T08:00:00.000Z"); + assertEquals(work.path, "/home/me/code/api"); + assertEquals(work.group, undefined); + assertEquals(work.windowList.map((w) => [w.index, w.name, w.active, w.panes, w.command]), [ + [0, "editor", true, 2, "nvim"], + [1, "server", false, 1, "node"], + [2, "zsh", false, 1, "zsh"], + ]); + assertEquals(notes.attached, 0); + assertEquals(pair.group, "team"); + assertEquals(pair.windowList, []); + assertEquals(parseTmuxSessions(""), []); +}); + +Deno.test("zellij sessions", () => { + const now = new Date("2026-10-28T12:00:00Z"); + const out = [ + "cool-panda [Created 2h 3m 10s ago] (current)", + "old-one [Created 3days ago] (EXITED - attach to resurrect)", + "fresh [Created 45s ago] ", + ].join("\n"); + const [cool, old, fresh] = parseZellijSessions(out, now); + assertEquals(cool.name, "cool-panda"); + assertEquals(cool.current, true); + assertEquals(cool.created?.toISOString(), "2026-10-28T09:56:50.000Z"); + assertEquals(old.exited, true); + assertEquals(old.created?.toISOString(), "2026-10-25T12:00:00.000Z"); + assertEquals(fresh.current, false); + assertEquals(parseZellijSessions("No active zellij sessions found."), []); + assertEquals(zellijDuration("1day 4h"), 100800); +}); + +Deno.test("commands", () => { + const [work] = parseTmuxSessions(TMUX, WINDOWS); + const [z, exited] = parseZellijSessions("a [Created 1s ago] \nb [Created 1s ago] (EXITED - attach to resurrect)"); + assertEquals(attachCommand(work), ["tmux", "attach-session", "-t", "=work"]); + assertEquals(attachCommand(work, 2), ["tmux", "attach-session", "-t", "=work:2"]); + assertEquals(attachCommand(z), ["zellij", "attach", "a"]); + assertEquals(killCommand(work), ["tmux", "kill-session", "-t", "=work"]); + assertEquals(killCommand(z), ["zellij", "kill-session", "a"]); + assertEquals(killCommand(exited), ["zellij", "delete-session", "b"]); + assertEquals(renameCommand(work, "job"), ["tmux", "rename-session", "-t", "=work", "job"]); + assertEquals(renameCommand(z, "c"), ["zellij", "--session", "a", "action", "rename-session", "c"]); + assertEquals(newSessionCommand("tmux", "x", true, "/srv"), ["tmux", "new-session", "-d", "-s", "x", "-c", "/srv"]); + assertEquals(newSessionCommand("tmux", "x", false), ["tmux", "new-session", "-s", "x"]); + assertEquals(newSessionCommand("zellij", "x", true), ["zellij", "attach", "--create-background", "x"]); + assertEquals(terminalArgs("konsole", attachCommand(work)), ["-e", "tmux", "attach-session", "-t", "=work"]); +}); + +Deno.test("names", () => { + assertEquals(invalidName("work"), undefined); + assertEquals(invalidName(""), "Give the session a name"); + assertEquals(invalidName("a.b"), "Session names can't contain “.” or “:”"); + assertEquals(invalidName("my work"), "Session names can't contain spaces"); + assertEquals(invalidName("work", ["work"]), "A session named “work” already exists"); +}); + +Deno.test("attached first, then by activity, exited last", () => { + const sessions = [ + ...parseTmuxSessions(TMUX, WINDOWS), + ...parseZellijSessions("z [Created 1s ago] (EXITED - attach to resurrect)\ny [Created 10m ago] "), + ]; + assertEquals(sortSessions(sessions).map((s) => s.name), ["pair", "work", "y", "notes", "z"]); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals(short(new Date("2026-10-28T09:00:00Z"), now), "3h"); + assertEquals(short(new Date("2026-10-20T12:00:00Z"), now), "8d"); +}); diff --git a/extensions/terminal-sessions/terminal.ts b/extensions/terminal-sessions/terminal.ts new file mode 100644 index 0000000..b6f1fb5 --- /dev/null +++ b/extensions/terminal-sessions/terminal.ts @@ -0,0 +1,78 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string, cwd?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + cwd, + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} From fe4a0ffceb74f99f8894bf8ecfd4cd69e055e295 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:57:50 +0000 Subject: [PATCH 03/10] GitLab, Jira, Sentry, Deployments, Notion and Home Assistant Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/deployments/api.ts | 145 ++++ extensions/deployments/backslash.toml | 43 + .../deployments/commands/deployments.ts | 64 ++ extensions/deployments/commands/projects.ts | 36 + extensions/deployments/lib.ts | 608 ++++++++++++++ extensions/deployments/lib_test.ts | 222 +++++ extensions/gitlab/api.ts | 110 +++ extensions/gitlab/backslash.toml | 47 ++ extensions/gitlab/commands/issues.ts | 35 + extensions/gitlab/commands/merge-requests.ts | 47 ++ extensions/gitlab/commands/pipelines.ts | 45 ++ extensions/gitlab/commands/projects.ts | 35 + extensions/gitlab/lib.ts | 738 +++++++++++++++++ extensions/gitlab/lib_test.ts | 251 ++++++ extensions/home-assistant/api.ts | 160 ++++ extensions/home-assistant/backslash.toml | 52 ++ .../home-assistant/commands/entities.ts | 40 + extensions/home-assistant/lib.ts | 756 ++++++++++++++++++ extensions/home-assistant/lib_test.ts | 210 +++++ extensions/jira/api.ts | 138 ++++ extensions/jira/backslash.toml | 52 ++ extensions/jira/commands/create-issue.ts | 96 +++ extensions/jira/commands/my-issues.ts | 40 + extensions/jira/commands/search-issues.ts | 44 + extensions/jira/lib.ts | 531 ++++++++++++ extensions/jira/lib_test.ts | 259 ++++++ extensions/notion/api.ts | 81 ++ extensions/notion/backslash.toml | 39 + extensions/notion/commands/add-to-page.ts | 39 + extensions/notion/commands/create-page.ts | 29 + extensions/notion/commands/search.ts | 26 + extensions/notion/lib.ts | 596 ++++++++++++++ extensions/notion/lib_test.ts | 203 +++++ extensions/sentry/api.ts | 109 +++ extensions/sentry/backslash.toml | 38 + extensions/sentry/commands/issues.ts | 51 ++ extensions/sentry/lib.ts | 423 ++++++++++ extensions/sentry/lib_test.ts | 213 +++++ 38 files changed, 6651 insertions(+) create mode 100644 extensions/deployments/api.ts create mode 100644 extensions/deployments/backslash.toml create mode 100644 extensions/deployments/commands/deployments.ts create mode 100644 extensions/deployments/commands/projects.ts create mode 100644 extensions/deployments/lib.ts create mode 100644 extensions/deployments/lib_test.ts create mode 100644 extensions/gitlab/api.ts create mode 100644 extensions/gitlab/backslash.toml create mode 100644 extensions/gitlab/commands/issues.ts create mode 100644 extensions/gitlab/commands/merge-requests.ts create mode 100644 extensions/gitlab/commands/pipelines.ts create mode 100644 extensions/gitlab/commands/projects.ts create mode 100644 extensions/gitlab/lib.ts create mode 100644 extensions/gitlab/lib_test.ts create mode 100644 extensions/home-assistant/api.ts create mode 100644 extensions/home-assistant/backslash.toml create mode 100644 extensions/home-assistant/commands/entities.ts create mode 100644 extensions/home-assistant/lib.ts create mode 100644 extensions/home-assistant/lib_test.ts create mode 100644 extensions/jira/api.ts create mode 100644 extensions/jira/backslash.toml create mode 100644 extensions/jira/commands/create-issue.ts create mode 100644 extensions/jira/commands/my-issues.ts create mode 100644 extensions/jira/commands/search-issues.ts create mode 100644 extensions/jira/lib.ts create mode 100644 extensions/jira/lib_test.ts create mode 100644 extensions/notion/api.ts create mode 100644 extensions/notion/backslash.toml create mode 100644 extensions/notion/commands/add-to-page.ts create mode 100644 extensions/notion/commands/create-page.ts create mode 100644 extensions/notion/commands/search.ts create mode 100644 extensions/notion/lib.ts create mode 100644 extensions/notion/lib_test.ts create mode 100644 extensions/sentry/api.ts create mode 100644 extensions/sentry/backslash.toml create mode 100644 extensions/sentry/commands/issues.ts create mode 100644 extensions/sentry/lib.ts create mode 100644 extensions/sentry/lib_test.ts diff --git a/extensions/deployments/api.ts b/extensions/deployments/api.ts new file mode 100644 index 0000000..380ccee --- /dev/null +++ b/extensions/deployments/api.ts @@ -0,0 +1,145 @@ +// Vercel and Netlify REST helpers, and the setup rows shown when a token is missing or rejected. + +import { Icons, type Item, open } from "@backslash/api"; + +export type Provider = "vercel" | "netlify"; + +export const VERCEL_API = "https://api.vercel.com"; +export const NETLIFY_API = "https://api.netlify.com/api/v1"; + +export const TOKEN_URLS: Record = { + vercel: "https://vercel.com/account/settings/tokens", + netlify: "https://app.netlify.com/user/applications#personal-access-tokens", +}; + +export const NAMES: Record = { vercel: "Vercel", netlify: "Netlify" }; + +export class AuthError extends Error { + constructor(readonly provider: Provider, message: string) { + super(message); + } +} + +export function tokenOf(preferences: Record, provider: Provider): string | undefined { + const value = String(preferences[provider === "vercel" ? "vercelToken" : "netlifyToken"] ?? "").trim(); + return value || undefined; +} + +/** The providers with a token. */ +export const configured = (preferences: Record): Provider[] => + (["vercel", "netlify"] as Provider[]).filter((p) => tokenOf(preferences, p)); + +/** `team_…` is a team id, anything else a team slug. */ +export function vercelScope(team: unknown): string { + const t = String(team ?? "").trim(); + if (!t) return ""; + return t.startsWith("team_") ? `teamId=${encodeURIComponent(t)}` : `slug=${encodeURIComponent(t)}`; +} + +interface RequestOptions { + method?: string; + body?: unknown; + signal?: AbortSignal; +} + +async function call(provider: Provider, url: string, token: string, options: RequestOptions): Promise { + const res = await fetch(url, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (res.status === 401) throw new AuthError(provider, `${NAMES[provider]} rejected the access token`); + if (res.status === 204) return undefined as T; + const text = await res.text(); + let body: any; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + if (!res.ok) { + const message = body?.error?.message ?? body?.message ?? (typeof body?.error === "string" ? body.error : ""); + if (res.status === 403 && provider === "vercel" && /scope|team/i.test(message)) { + throw new Error(`Vercel: ${message}. Set the team in Settings → Extensions → Deployments`); + } + throw new Error(`${NAMES[provider]} answered ${res.status}${message ? `: ${message}` : ""}`); + } + return body as T; +} + +export function vercel(preferences: Record, path: string, options: RequestOptions = {}) { + const token = tokenOf(preferences, "vercel"); + if (!token) throw new AuthError("vercel", "Add a Vercel access token"); + const scope = vercelScope(preferences.vercelTeam); + const url = `${VERCEL_API}${path}${scope ? `${path.includes("?") ? "&" : "?"}${scope}` : ""}`; + return call("vercel", url, token, options); +} + +export function netlify(preferences: Record, path: string, options: RequestOptions = {}) { + const token = tokenOf(preferences, "netlify"); + if (!token) throw new AuthError("netlify", "Add a Netlify access token"); + return call("netlify", `${NETLIFY_API}${path}`, token, options); +} + +export function setupItem(provider: Provider, title: string): Item { + const name = NAMES[provider]; + const where = provider === "vercel" + ? "**Vercel → Account Settings → Tokens**. For a team, also set its slug or id (`team_…`) in the settings." + : "**Netlify → User settings → Applications → Personal access tokens**."; + return { + id: `setup-${provider}`, + title, + subtitle: `Create a personal access token on ${name}`, + icon: { source: Icons.key, tint: "orange" }, + section: "Setup", + detail: { + markdown: [ + `## Connect ${name}`, + "", + `1. Create a token in ${where} (Enter opens it).`, + `2. Paste it in **Settings → Extensions → Deployments → Configure** (${name} token).`, + "", + "Vercel and Netlify are both optional: set one token or both.", + ].join("\n"), + }, + actions: [open(TOKEN_URLS[provider], `Create ${name} Token`, { icon: Icons.key })], + }; +} + +/** Rows explaining how to connect each provider, when none has a token. */ +export const setupAll = () => [ + setupItem("vercel", "Add a Vercel access token"), + setupItem("netlify", "Add a Netlify access token"), +]; + +/** Loads one provider; a problem becomes a row, so the other provider still shows. */ +export async function guarded(provider: Provider, load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return [setupItem(provider, e.message)]; + if (e instanceof DOMException && e.name === "AbortError") throw e; + const message = e instanceof Error ? e.message : String(e); + return [{ + id: `error-${provider}`, + title: `${NAMES[provider]} could not be loaded`, + subtitle: message, + icon: { source: Icons.alert, tint: "red" }, + section: "Setup", + detail: { markdown: `## ${NAMES[provider]} could not be loaded\n\n${message}` }, + actions: [ + open( + provider === "vercel" ? "https://www.vercel-status.com" : "https://www.netlifystatus.com", + "Open Status Page", + ), + ], + }]; + } +} + +/** Setup and error rows are not deployments. */ +export const isProblem = (item: Item) => /^(setup|error)-/.test(item.id ?? ""); diff --git a/extensions/deployments/backslash.toml b/extensions/deployments/backslash.toml new file mode 100644 index 0000000..4f8ba91 --- /dev/null +++ b/extensions/deployments/backslash.toml @@ -0,0 +1,43 @@ +[extension] +id = "deployments" +title = "Deployments" +description = "Recent Vercel deployments and Netlify deploys with their status, commit, branch and build time; open, copy, redeploy, cancel, build logs" +version = "1.0.0" +author = "Backslash" +icon = "bs-rocket" +license = "MIT" + +# The two APIs, and vercel.com for the avatars of who deployed. +[permissions] +net = ["api.vercel.com", "api.netlify.com", "vercel.com"] + +[[commands]] +name = "deployments" +title = "Recent Deployments" +description = "Vercel and Netlify, newest first; builds in progress on top" +icon = "bs-rocket" +keywords = ["deploy", "deployments", "vercel", "netlify", "build", "ci", "preview", "production", "hosting"] + +[[commands]] +name = "projects" +title = "Deployment Projects" +description = "Vercel projects and Netlify sites, with their production status" +icon = "bs-cloud" +keywords = ["vercel", "netlify", "projects", "sites", "domains", "hosting"] + +[[preferences]] +name = "vercelToken" +title = "Vercel token" +description = "Optional. Vercel → Account Settings → Tokens" +type = "secret" + +[[preferences]] +name = "vercelTeam" +title = "Vercel team" +description = "Optional: the team slug or id (team_…) whose projects to show; empty for your personal account" + +[[preferences]] +name = "netlifyToken" +title = "Netlify token" +description = "Optional. Netlify → User settings → Applications → Personal access tokens" +type = "secret" diff --git a/extensions/deployments/commands/deployments.ts b/extensions/deployments/commands/deployments.ts new file mode 100644 index 0000000..a564c98 --- /dev/null +++ b/extensions/deployments/commands/deployments.ts @@ -0,0 +1,64 @@ +import { type Context, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { configured, guarded, isProblem, NAMES, type Provider, setupAll, setupItem } from "../api.ts"; +import { + daySection, + type Deployment, + deploymentItem, + inProgress, + loadNetlify, + loadVercel, + sortDeployments, +} from "../lib.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Filter deployments by project, branch, commit, author, status…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "vercel", title: "Vercel" }, + { value: "netlify", title: "Netlify" }, + ], + }, + empty: { title: "No deployments yet", icon: "bs-rocket" }, + status: (items) => { + const deployments = items.filter((i) => !isProblem(i)); + if (!deployments.length && items.length) return "Not connected"; + const building = deployments.filter((i) => i.section === "In progress").length; + return `${deployments.length} deployments${building ? ` · ${building} in progress` : ""}`; + }, + async *items(c) { + const only = c.filter === "vercel" || c.filter === "netlify" ? c.filter as Provider : undefined; + const providers = configured(c.preferences).filter((p) => !only || p === only); + if (!providers.length) { + yield only ? [setupItem(only, `Add a ${NAMES[only]} access token`)] : setupAll(); + return; + } + // Each provider shows up as soon as it answers. + const deployments: Deployment[] = []; + const problems: Item[] = []; + const render = () => [ + ...sortDeployments(deployments).map((d) => deploymentItem(d, ctx, { section: daySection(d) })), + ...problems, + ]; + const pending = new Map(providers.map((p, i) => [ + i, + guarded(p, async () => { + deployments.push(...await (p === "vercel" ? loadVercel(c) : loadNetlify(c))); + return []; + }).then((rows) => { + problems.push(...rows); + return i; + }), + ])); + while (pending.size) { + pending.delete(await Promise.race(pending.values())); + if (pending.size) yield render(); + } + if (!only) { + const building = deployments.filter(inProgress).length; + updateCommandMetadata({ subtitle: building ? `${building} deploying…` : null }).catch(() => {}); + } + yield render(); + }, + }); diff --git a/extensions/deployments/commands/projects.ts b/extensions/deployments/commands/projects.ts new file mode 100644 index 0000000..6dd0137 --- /dev/null +++ b/extensions/deployments/commands/projects.ts @@ -0,0 +1,36 @@ +import { type Context, list } from "@backslash/api"; +import { configured, guarded, isProblem, netlify, setupAll, vercel } from "../api.ts"; +import { netlifyItem, type NetlifySite, vercelItem, type VercelProject } from "../lib.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Filter Vercel projects and Netlify sites…", + empty: { title: "No projects", icon: "bs-rocket" }, + status: (items) => { + const n = items.filter((i) => !isProblem(i)).length; + return n || !items.length ? `${n} project${n === 1 ? "" : "s"}` : "Not connected"; + }, + async items(c) { + const providers = configured(c.preferences); + if (!providers.length) return setupAll(); + const rows = await Promise.all(providers.map((p) => + guarded(p, async () => { + if (p === "vercel") { + const data = await vercel<{ projects: VercelProject[] }>(c.preferences, "/v9/projects?limit=50", { + signal: c.signal, + }); + return data.projects.map((project) => vercelItem(project, ctx)); + } + const sites = await netlify( + c.preferences, + "/sites?filter=all&sort_by=updated_at&per_page=50", + { + signal: c.signal, + }, + ); + return sites.map((site) => netlifyItem(site, ctx)); + }) + )); + return rows.flat(); + }, + }); diff --git a/extensions/deployments/lib.ts b/extensions/deployments/lib.ts new file mode 100644 index 0000000..ffa44f5 --- /dev/null +++ b/extensions/deployments/lib.ts @@ -0,0 +1,608 @@ +// Vercel deployments and Netlify deploys in one shape, and how they look in the launcher. + +import { + type Accessory, + type Action, + action, + type Color, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + type Stat, + toast, +} from "@backslash/api"; +import { guarded, isProblem, NAMES, netlify, type Provider, vercel } from "./api.ts"; + +// --------------------------------------------------------------------------- +// Shapes + +export type State = "ready" | "building" | "queued" | "error" | "canceled"; + +export interface Deployment { + provider: Provider; + id: string; + project: string; + projectId: string; + state: State; + /** The provider's own word for it ("READY", "processing"…). */ + rawState: string; + /** The deployed site. */ + url?: string; + /** The deployment's page in the dashboard. */ + dashboardUrl: string; + createdAt: number; + /** Seconds. */ + duration?: number; + production: boolean; + environment: string; + branch?: string; + commitMessage?: string; + commitSha?: string; + commitUrl?: string; + author?: string; + authorAvatar?: string; + error?: string; + source?: string; +} + +/** `GET /v6/deployments` entries. */ +export interface VercelDeployment { + uid: string; + name: string; + url?: string | null; + created: number; + createdAt?: number; + buildingAt?: number; + ready?: number; + state?: string; + readyState?: string; + target?: "production" | "staging" | null; + source?: string; + projectId?: string; + inspectorUrl?: string | null; + errorMessage?: string | null; + creator?: { uid: string; username?: string; email?: string; githubLogin?: string }; + meta?: Record; +} + +/** `GET /api/v1/sites/{id}/deploys` entries. */ +export interface NetlifyDeploy { + id: string; + site_id: string; + name: string; + state: string; + error_message?: string | null; + created_at: string; + updated_at?: string; + published_at?: string | null; + deploy_time?: number | null; + branch?: string | null; + commit_ref?: string | null; + commit_url?: string | null; + title?: string | null; + committer?: string | null; + context?: string | null; + deploy_ssl_url?: string | null; + ssl_url?: string | null; + admin_url?: string | null; + review_url?: string | null; + framework?: string | null; +} + +export interface NetlifySite { + id: string; + name: string; + ssl_url?: string | null; + url?: string | null; + admin_url: string; + updated_at: string; + screenshot_url?: string | null; + build_settings?: { repo_url?: string | null; repo_branch?: string | null } | null; + published_deploy?: NetlifyDeploy | null; +} + +export interface VercelProject { + id: string; + name: string; + framework?: string | null; + updatedAt: number; + link?: { type?: string; org?: string; repo?: string; productionBranch?: string } | null; + latestDeployments?: (Partial & { id?: string })[]; + targets?: { production?: { id?: string; url?: string; alias?: string[]; readyState?: string; createdAt?: number } }; +} + +// --------------------------------------------------------------------------- +// Mapping + +export function vercelState(state: string | undefined): State { + switch ((state ?? "").toUpperCase()) { + case "READY": + return "ready"; + case "ERROR": + return "error"; + case "CANCELED": + return "canceled"; + case "QUEUED": + case "INITIALIZING": + return "queued"; + default: + return "building"; + } +} + +export function netlifyState(deploy: Pick): State { + switch (deploy.state) { + case "ready": + return "ready"; + case "error": + case "rejected": + return /cancel|skipped/i.test(deploy.error_message ?? "") ? "canceled" : "error"; + case "new": + case "enqueued": + case "pending_review": + return "queued"; + case "skipped": + case "canceled": + return "canceled"; + default: + return "building"; + } +} + +/** The commit behind a Vercel deployment, whatever git host it came from. */ +function vercelCommit(m: Record) { + for (const host of ["github", "gitlab", "bitbucket"]) { + const sha = m[`${host}CommitSha`]; + if (!sha && !m[`${host}CommitMessage`]) continue; + let commitUrl: string | undefined; + if (host === "github" && m.githubOrg && m.githubRepo && sha) { + commitUrl = `https://github.com/${m.githubOrg}/${m.githubRepo}/commit/${sha}`; + } + return { + commitMessage: m[`${host}CommitMessage`], + branch: m[`${host}CommitRef`], + commitSha: sha, + commitUrl, + author: m[`${host}CommitAuthorName`] ?? m[`${host}CommitAuthorLogin`], + }; + } + return { branch: m.branchAlias }; +} + +export function fromVercel(d: VercelDeployment): Deployment { + const commit = vercelCommit(d.meta ?? {}); + const duration = d.ready && d.buildingAt && d.ready > d.buildingAt ? (d.ready - d.buildingAt) / 1000 : undefined; + const raw = d.state ?? d.readyState ?? ""; + return { + provider: "vercel", + id: d.uid, + project: d.name, + projectId: d.projectId ?? d.name, + state: vercelState(raw), + rawState: raw, + url: d.url ? `https://${d.url}` : undefined, + dashboardUrl: d.inspectorUrl ?? `https://vercel.com/deployments/${d.url ?? d.uid}`, + createdAt: d.createdAt ?? d.created, + duration, + production: d.target === "production", + environment: d.target === "production" ? "Production" : d.target === "staging" ? "Staging" : "Preview", + ...commit, + author: commit.author ?? d.creator?.username, + authorAvatar: d.creator?.uid ? `https://vercel.com/api/www/avatar/${d.creator.uid}?s=64` : undefined, + error: d.errorMessage ?? undefined, + source: d.source, + }; +} + +const NETLIFY_CONTEXTS: Record = { + production: "Production", + "deploy-preview": "Deploy Preview", + "branch-deploy": "Branch Deploy", + dev: "Dev", +}; + +export function fromNetlify(d: NetlifyDeploy): Deployment { + const admin = d.admin_url ?? `https://app.netlify.com/sites/${d.name}`; + const production = d.context === "production"; + return { + provider: "netlify", + id: d.id, + project: d.name, + projectId: d.site_id, + state: netlifyState(d), + rawState: d.state, + url: (production && d.state === "ready" ? d.ssl_url : d.deploy_ssl_url) ?? d.deploy_ssl_url ?? undefined, + dashboardUrl: `${admin}/deploys/${d.id}`, + createdAt: Date.parse(d.created_at), + duration: d.deploy_time ?? undefined, + production, + environment: NETLIFY_CONTEXTS[d.context ?? ""] ?? (d.context ?? "Deploy"), + branch: d.branch ?? undefined, + commitMessage: d.title ?? undefined, + commitSha: d.commit_ref ?? undefined, + commitUrl: d.commit_url ?? undefined, + author: d.committer ?? undefined, + error: d.state === "error" ? d.error_message ?? undefined : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Formatting + +export function stateLook(state: State): { icon: string; tint: Color; text: string } { + switch (state) { + case "ready": + return { icon: Icons.done, tint: "green", text: "Ready" }; + case "building": + return { icon: Icons.inProgress, tint: "orange", text: "Building" }; + case "queued": + return { icon: Icons.clock, tint: "gray", text: "Queued" }; + case "error": + return { icon: Icons.canceled, tint: "red", text: "Error" }; + case "canceled": + return { icon: Icons.canceled, tint: "gray", text: "Canceled" }; + } +} + +export const inProgress = (d: Deployment) => d.state === "building" || d.state === "queued"; + +/** 75 → "1m 15s". */ +export function duration(seconds: number | undefined): string { + if (seconds == null || !Number.isFinite(seconds)) return ""; + const s = Math.round(seconds); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} + +/** "just now", "5m", "3h", "2d", "25 Oct". */ +export function since(ms: number, now = Date.now()): string { + const s = Math.max(0, (now - ms) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86_400) return `${Math.floor(s / 3600)}h`; + if (s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`; + return new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +/** "In progress", then "Today", "Yesterday", "Monday 26 Oct". */ +export function daySection(d: Deployment, now = new Date()): string { + if (inProgress(d)) return "In progress"; + const day = (t: Date) => new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime(); + const diff = Math.round((day(now) - day(new Date(d.createdAt))) / 86_400_000); + if (diff <= 0) return "Today"; + if (diff === 1) return "Yesterday"; + return new Date(d.createdAt).toLocaleDateString("en", { weekday: "long", day: "numeric", month: "short" }); +} + +const host = (url: string | undefined) => url?.replace(/^https?:\/\//, "").replace(/\/$/, ""); + +/** In progress first, then newest first. */ +export function sortDeployments(all: Deployment[]): Deployment[] { + return [...all].sort((a, b) => Number(inProgress(b)) - Number(inProgress(a)) || b.createdAt - a.createdAt); +} + +// --------------------------------------------------------------------------- +// Items + +export function deploymentMetadata(d: Deployment): Metadata[] { + const look = stateLook(d.state); + const lines: Metadata[] = [ + meta.text("Status", look.text, { icon: look.icon, color: look.tint }), + meta.tags("Environment", [{ text: d.environment, color: d.production ? "blue" : "gray" }]), + meta.text("Project", d.project, { icon: Icons.project }), + meta.text("Provider", NAMES[d.provider], { icon: Icons.cloud }), + ]; + if (d.url) lines.push(meta.link("URL", host(d.url)!, d.url)); + if (d.branch) lines.push(meta.text("Branch", d.branch, { icon: Icons.branch })); + if (d.commitSha) { + const short = d.commitSha.slice(0, 7); + lines.push( + d.commitUrl ? meta.link("Commit", short, d.commitUrl) : meta.text("Commit", short, { icon: Icons.commit }), + ); + } + if (d.author) lines.push(meta.person("Author", d.author, d.authorAvatar)); + if (d.error) lines.push(meta.text("Error", d.error, { icon: Icons.alert, color: "red" })); + lines.push(meta.separator()); + lines.push( + meta.text("Created", new Date(d.createdAt).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" })), + ); + if (d.duration) lines.push(meta.text("Build time", duration(d.duration), { icon: Icons.timer })); + return lines; +} + +function stats(d: Deployment): Stat[] { + const look = stateLook(d.state); + return [ + { label: "status", value: look.text, color: look.tint }, + { label: "build", value: duration(d.duration) || (inProgress(d) ? "…" : "—") }, + { label: "created", value: since(d.createdAt) }, + ]; +} + +export function deploymentActions(d: Deployment, ctx: Context, page = false): Action[] { + const prefs = ctx.preferences; + const actions: Action[] = []; + if (d.url && d.state === "ready") actions.push(open(d.url, "Open Deployment")); + actions.push(open(d.dashboardUrl, `Open in ${NAMES[d.provider]}`, { icon: Icons.external })); + if (d.url && d.state !== "ready") actions.push(open(d.url, "Open Deployment URL", { section: "Open" })); + if (d.provider === "vercel" && !page) { + actions.push(action("Show Build Logs", () => vercelLogs(d, ctx), { icon: Icons.terminal, shortcut: "l" })); + } + if (d.commitUrl) actions.push(open(d.commitUrl, "Open Commit", { icon: Icons.commit, section: "Open" })); + + if (d.provider === "vercel" && !inProgress(d)) { + actions.push(action("Redeploy", async () => { + const created = await vercel<{ id: string; url: string }>(prefs, "/v13/deployments?forceNew=1", { + method: "POST", + body: { name: d.project, deploymentId: d.id, ...(d.production ? { target: "production" } : {}) }, + }); + await toast(`Redeploying ${d.project}`, { style: "success", message: created?.url }); + return refresh(); + }, { + icon: Icons.refresh, + shortcut: "r", + confirm: `Redeploy ${d.project}${d.production ? " to production" : ""}?`, + section: "Deployment", + })); + } + if (d.provider === "netlify" && d.production && !inProgress(d)) { + const retry = (clearCache: boolean) => async () => { + await netlify(prefs, `/sites/${d.projectId}/builds`, { method: "POST", body: { clear_cache: clearCache } }); + await toast(`Deploying ${d.project}`, { style: "success" }); + return refresh(); + }; + actions.push( + action("Retry Deploy", retry(false), { + icon: Icons.refresh, + shortcut: "r", + confirm: `Deploy ${d.project} again from its production branch?`, + section: "Deployment", + }), + action("Clear Cache and Retry", retry(true), { + icon: Icons.refresh, + confirm: `Clear the build cache and deploy ${d.project}?`, + section: "Deployment", + }), + ); + } + if (inProgress(d)) { + actions.push(action("Cancel Deployment", async () => { + if (d.provider === "vercel") await vercel(prefs, `/v12/deployments/${d.id}/cancel`, { method: "PATCH" }); + else await netlify(prefs, `/deploys/${d.id}/cancel`, { method: "POST" }); + await toast(`Canceled ${d.project}`, { style: "success" }); + return refresh(); + }, { + icon: Icons.stop, + destructive: true, + confirm: `Cancel this deployment of ${d.project}?`, + section: "Deployment", + })); + } + if (d.url) actions.push(copy(d.url, "Copy URL", { shortcut: "c", section: "Copy" })); + actions.push(copy(d.dashboardUrl, "Copy Dashboard URL", { section: "Copy" })); + if (d.commitSha) actions.push(copy(d.commitSha, "Copy Commit SHA", { icon: Icons.commit, section: "Copy" })); + if (d.branch) actions.push(copy(d.branch, "Copy Branch", { icon: Icons.branch, section: "Copy" })); + return actions; +} + +export function deploymentItem(d: Deployment, ctx: Context, options: { section?: string } = {}): Item { + const look = stateLook(d.state); + const accessories: Accessory[] = [ + { tag: d.production ? "Production" : d.environment, color: d.production ? "blue" : "gray" }, + ]; + if (d.branch) accessories.push({ icon: Icons.branch, text: d.branch, tooltip: "Branch" }); + if (d.duration) accessories.push({ icon: Icons.timer, text: duration(d.duration), tooltip: "Build time" }); + if (d.author) accessories.push({ avatar: d.authorAvatar ?? d.author, tooltip: d.author }); + accessories.push({ text: since(d.createdAt), tooltip: new Date(d.createdAt).toLocaleString("en") }); + return { + id: `${d.provider}:${d.id}`, + title: d.project, + subtitle: d.commitMessage?.split("\n")[0] ?? host(d.url) ?? look.text, + icon: { source: look.icon, tint: look.tint }, + section: options.section, + keywords: [ + NAMES[d.provider], + d.branch ?? "", + d.author ?? "", + d.environment, + look.text, + d.commitSha?.slice(0, 7) ?? "", + ] + .filter(Boolean), + accessories, + detail: { + stats: stats(d), + markdown: [d.commitMessage ? `**${d.commitMessage.trim()}**` : "", d.error ? `\`\`\`\n${d.error}\n\`\`\`` : ""] + .filter(Boolean).join("\n\n") || undefined, + metadata: deploymentMetadata(d), + }, + actions: deploymentActions(d, ctx), + }; +} + +// --------------------------------------------------------------------------- +// Vercel build logs + +interface VercelEvent { + type: string; + created: number; + text?: string; + payload?: { text?: string }; +} + +/** Terminal colors in build output. */ +const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"); + +/** Build output as plain lines, the commands prefixed with `$`. */ +export function logLines(events: VercelEvent[]): string[] { + return events + .filter((e) => ["stdout", "stderr", "command"].includes(e.type)) + .map((e) => { + const text = (e.payload?.text ?? e.text ?? "").replace(ANSI, "").trimEnd(); + return e.type === "command" ? `$ ${text}` : text; + }); +} + +export async function vercelLogs(d: Deployment, ctx: Context): Promise { + const events = await vercel(ctx.preferences, `/v3/deployments/${d.id}/events?builds=1&limit=-1`); + const lines = logLines(events ?? []); + const tail = lines.slice(-300); + const look = stateLook(d.state); + return detail({ + title: `${d.project} build logs`, + markdown: lines.length + ? `${lines.length > tail.length ? `_Last ${tail.length} of ${lines.length} lines._\n\n` : ""}\`\`\`\n${ + tail.join("\n") + }\n\`\`\`` + : "_No build output yet._", + stats: stats(d), + metadata: deploymentMetadata(d), + status: `${look.text} · ${d.environment}`, + actions: deploymentActions(d, ctx, true), + }); +} + +// --------------------------------------------------------------------------- +// Loading + +/** Recent Vercel deployments (all projects, or one). */ +export async function loadVercel(ctx: Context, projectId?: string, limit = 40): Promise { + const q = new URLSearchParams({ limit: String(limit) }); + if (projectId) q.set("projectId", projectId); + const data = await vercel<{ deployments: VercelDeployment[] }>(ctx.preferences, `/v6/deployments?${q}`, { + signal: ctx.signal, + }); + return data.deployments.map(fromVercel); +} + +/** Recent Netlify deploys: those of the most recently updated sites (Netlify has no account-wide list). */ +export async function loadNetlify(ctx: Context, siteId?: string): Promise { + const sites = siteId + ? [{ id: siteId }] + : await netlify(ctx.preferences, "/sites?filter=all&sort_by=updated_at&per_page=8", { + signal: ctx.signal, + }); + const perSite = siteId ? 30 : 8; + const lists = await Promise.all( + sites.map((s) => + netlify(ctx.preferences, `/sites/${s.id}/deploys?per_page=${perSite}`, { signal: ctx.signal }) + ), + ); + return lists.flat().map(fromNetlify); +} + +/** The deployments of one project or site. */ +export function projectDeployments(provider: Provider, id: string, name: string, ctx: Context): ListSpec { + return list({ + title: `${name} deployments`, + placeholder: `Filter deployments of ${name} by branch, commit, author, status…`, + empty: { title: "No deployments yet", icon: Icons.rocket }, + status: (items) => `${items.filter((i) => !isProblem(i)).length} deployments · ${NAMES[provider]}`, + items: (c) => + guarded(provider, async () => { + const all = provider === "vercel" ? await loadVercel(c, id, 40) : await loadNetlify(c, id); + return sortDeployments(all).map((d) => deploymentItem(d, ctx, { section: daySection(d) })); + }), + }); +} + +// --------------------------------------------------------------------------- +// Projects and sites + +export function vercelItem(p: VercelProject, ctx: Context): Item { + const prod = p.targets?.production; + const latest = p.latestDeployments?.[0]; + const look = stateLook(vercelState(prod?.readyState ?? latest?.readyState ?? latest?.state ?? "READY")); + const domain = prod?.alias?.[0] ?? `${p.name}.vercel.app`; + const repo = p.link?.org && p.link.repo ? `${p.link.org}/${p.link.repo}` : undefined; + const accessories: Accessory[] = []; + if (p.framework) accessories.push({ tag: p.framework }); + accessories.push({ text: since(p.updatedAt), tooltip: "Updated" }); + return { + id: `vercel:${p.id}`, + title: p.name, + subtitle: domain, + icon: { source: look.icon, tint: look.tint }, + section: "Vercel", + keywords: ["vercel", repo ?? "", p.framework ?? ""].filter(Boolean), + accessories, + detail: { + metadata: [ + meta.text("Production", look.text, { icon: look.icon, color: look.tint }), + meta.link("Domain", domain, `https://${domain}`), + ...(prod?.alias && prod.alias.length > 1 ? [meta.tags("Aliases", prod.alias.slice(1, 6))] : []), + ...(p.framework ? [meta.text("Framework", p.framework, { icon: Icons.code })] : []), + ...(repo ? [meta.text("Repository", repo, { icon: Icons.repository })] : []), + ...(p.link?.productionBranch + ? [meta.text("Production branch", p.link.productionBranch, { icon: Icons.branch })] + : []), + meta.separator(), + ...(prod?.createdAt ? [meta.text("Last production deploy", since(prod.createdAt))] : []), + meta.text("Updated", since(p.updatedAt)), + ], + }, + actions: [ + action("Show Deployments", () => projectDeployments("vercel", p.id, p.name, ctx), { icon: Icons.rocket }), + open(`https://${domain}`, "Open Production Site"), + open(`https://vercel.com/~/${p.name}`, "Open in Vercel", { icon: Icons.external }), + ...(repo && p.link?.type === "github" + ? [open(`https://github.com/${repo}`, "Open Repository", { icon: Icons.repository, section: "Open" })] + : []), + copy(`https://${domain}`, "Copy Production URL", { shortcut: "c", section: "Copy" }), + ], + }; +} + +export function netlifyItem(s: NetlifySite, ctx: Context): Item { + const deploy = s.published_deploy; + const look = stateLook(deploy ? netlifyState(deploy) : "ready"); + const url = s.ssl_url ?? s.url ?? undefined; + const repo = s.build_settings?.repo_url?.replace(/^https?:\/\/(www\.)?/, ""); + return { + id: `netlify:${s.id}`, + title: s.name, + subtitle: url?.replace(/^https?:\/\//, ""), + icon: { source: look.icon, tint: look.tint }, + section: "Netlify", + keywords: ["netlify", repo ?? ""].filter(Boolean), + accessories: [ + ...(s.build_settings?.repo_branch ? [{ icon: Icons.branch, text: s.build_settings.repo_branch }] : []), + { text: since(Date.parse(s.updated_at)), tooltip: "Updated" }, + ], + detail: { + metadata: [ + meta.text("Published", deploy ? look.text : "Never", { icon: look.icon, color: look.tint }), + ...(url ? [meta.link("URL", url.replace(/^https?:\/\//, ""), url)] : []), + ...(repo ? [meta.text("Repository", repo, { icon: Icons.repository })] : []), + ...(s.build_settings?.repo_branch + ? [meta.text("Production branch", s.build_settings.repo_branch, { icon: Icons.branch })] + : []), + ...(deploy?.title ? [meta.text("Last commit", deploy.title.split("\n")[0], { icon: Icons.commit })] : []), + meta.separator(), + ...(deploy?.published_at ? [meta.text("Published", since(Date.parse(deploy.published_at)))] : []), + meta.text("Updated", since(Date.parse(s.updated_at))), + ], + }, + actions: [ + action("Show Deploys", () => projectDeployments("netlify", s.id, s.name, ctx), { icon: Icons.rocket }), + ...(url ? [open(url, "Open Site")] : []), + open(s.admin_url, "Open in Netlify", { icon: Icons.external }), + action("Trigger Deploy", async () => { + await netlify(ctx.preferences, `/sites/${s.id}/builds`, { method: "POST", body: {} }); + await toast(`Deploying ${s.name}`, { style: "success" }); + return refresh(); + }, { icon: Icons.rocket, confirm: `Deploy ${s.name} from its production branch?`, section: "Site" }), + ...(url ? [copy(url, "Copy URL", { shortcut: "c", section: "Copy" })] : []), + ], + }; +} diff --git a/extensions/deployments/lib_test.ts b/extensions/deployments/lib_test.ts new file mode 100644 index 0000000..4a3e3ba --- /dev/null +++ b/extensions/deployments/lib_test.ts @@ -0,0 +1,222 @@ +import { assertEquals } from "@std/assert"; +import type { Context } from "@backslash/api"; +import { AuthError, configured, guarded, setupAll, vercelScope } from "./api.ts"; +import { + daySection, + deploymentItem, + duration, + fromNetlify, + fromVercel, + logLines, + type NetlifyDeploy, + netlifyItem, + type NetlifySite, + netlifyState, + since, + sortDeployments, + stateLook, + type VercelDeployment, + vercelItem, + vercelState, +} from "./lib.ts"; + +const ctx: Context = { + args: {}, + preferences: { vercelToken: "v", netlifyToken: "n" }, + query: "", + signal: new AbortController().signal, +}; + +const T = Date.parse("2026-10-27T10:00:00Z"); + +// Shaped like `GET https://api.vercel.com/v6/deployments`. +const vercelDeployment: VercelDeployment = { + uid: "dpl_89qyp1cskzkLrVicDaZoDbjyHuDJ", + name: "storefront", + url: "storefront-k3f9a1-acme.vercel.app", + created: T, + createdAt: T, + buildingAt: T + 4_000, + ready: T + 79_000, + state: "READY", + target: "production", + source: "git", + projectId: "prj_12HKQaOmR5t5Uy6vdcQsNIiZgHGB", + inspectorUrl: "https://vercel.com/acme/storefront/89qyp1cskzkLrVicDaZoDbjyHuDJ", + creator: { uid: "96SnxkFiMyVKsK3pnoHfx3Hz", username: "ada" }, + meta: { + githubCommitMessage: "Fix the cart total\n\nCloses #12", + githubCommitRef: "main", + githubCommitSha: "dc36199b2234c6586ebe05ec94078a895c707e29", + githubCommitAuthorName: "Ada Lovelace", + githubOrg: "acme", + githubRepo: "storefront", + }, +}; + +// Shaped like `GET https://api.netlify.com/api/v1/sites/{site_id}/deploys`. +const netlifyDeploy: NetlifyDeploy = { + id: "6532f1c2a1b2c3d4e5f6a7b8", + site_id: "3970e0fe-8564-4903-9a55-c5f8de49fb8b", + name: "acme-docs", + state: "error", + error_message: "Build script returned non-zero exit code: 2", + created_at: "2026-10-26T18:00:00.000Z", + deploy_time: 41, + branch: "main", + commit_ref: "a1b2c3d4e5f6", + commit_url: "https://github.com/acme/docs/commit/a1b2c3d4e5f6", + title: "Update the install guide", + committer: "grace", + context: "production", + deploy_ssl_url: "https://6532f1c2a1b2c3d4e5f6a7b8--acme-docs.netlify.app", + ssl_url: "https://docs.acme.dev", + admin_url: "https://app.netlify.com/sites/acme-docs", +}; + +Deno.test("states of both providers", () => { + assertEquals(["READY", "BUILDING", "INITIALIZING", "QUEUED", "ERROR", "CANCELED"].map(vercelState), [ + "ready", + "building", + "queued", + "queued", + "error", + "canceled", + ]); + assertEquals( + ["ready", "building", "processing", "enqueued", "uploading"].map((state) => netlifyState({ state })), + ["ready", "building", "building", "queued", "building"], + ); + assertEquals(netlifyState({ state: "error", error_message: "Canceled build" }), "canceled"); + assertEquals(stateLook("ready"), { icon: "bs-circle-check", tint: "green", text: "Ready" }); + assertEquals(stateLook("building").tint, "orange"); + assertEquals(stateLook("error").tint, "red"); +}); + +Deno.test("Vercel deployment mapping", () => { + const d = fromVercel(vercelDeployment); + assertEquals(d.url, "https://storefront-k3f9a1-acme.vercel.app"); + assertEquals([d.state, d.production, d.environment, d.duration], ["ready", true, "Production", 75]); + assertEquals([d.branch, d.commitSha?.slice(0, 7), d.author], ["main", "dc36199", "Ada Lovelace"]); + assertEquals(d.commitUrl, "https://github.com/acme/storefront/commit/dc36199b2234c6586ebe05ec94078a895c707e29"); + assertEquals(d.authorAvatar, "https://vercel.com/api/www/avatar/96SnxkFiMyVKsK3pnoHfx3Hz?s=64"); + const preview = fromVercel({ ...vercelDeployment, target: null, meta: {}, state: "BUILDING", ready: undefined }); + assertEquals([preview.environment, preview.duration, preview.commitMessage], ["Preview", undefined, undefined]); +}); + +Deno.test("Netlify deploy mapping", () => { + const d = fromNetlify(netlifyDeploy); + assertEquals([d.state, d.environment, d.duration, d.author], ["error", "Production", 41, "grace"]); + assertEquals(d.url, "https://6532f1c2a1b2c3d4e5f6a7b8--acme-docs.netlify.app"); + assertEquals(d.dashboardUrl, "https://app.netlify.com/sites/acme-docs/deploys/6532f1c2a1b2c3d4e5f6a7b8"); + assertEquals(d.error, "Build script returned non-zero exit code: 2"); + assertEquals(fromNetlify({ ...netlifyDeploy, state: "ready" }).url, "https://docs.acme.dev"); +}); + +Deno.test("formatting and order", () => { + assertEquals([duration(41), duration(75), duration(3720)], ["41s", "1m 15s", "1h 2m"]); + assertEquals([since(T, T + 30_000), since(T, T + 600_000), since(T, T + 7_200_000)], ["just now", "10m", "2h"]); + const now = new Date(2026, 9, 27, 18, 0); + const at = (h: number, day = 27) => ({ + ...fromVercel(vercelDeployment), + createdAt: new Date(2026, 9, day, h).getTime(), + }); + assertEquals(daySection(at(9), now), "Today"); + assertEquals(daySection(at(23, 26), now), "Yesterday"); + assertEquals(daySection({ ...at(9), state: "building" }, now), "In progress"); + const sorted = sortDeployments([at(9), { ...at(8), state: "building" }, at(12)]); + assertEquals(sorted.map((d) => [d.state, new Date(d.createdAt).getHours()]), [["building", 8], ["ready", 12], [ + "ready", + 9, + ]]); +}); + +Deno.test("deployment items and actions", () => { + const v = deploymentItem(fromVercel(vercelDeployment), ctx, { section: "Today" }); + assertEquals(v.title, "storefront"); + assertEquals(v.subtitle, "Fix the cart total"); + assertEquals(v.icon, { source: "bs-circle-check", tint: "green" }); + assertEquals(v.accessories?.slice(0, 3), [ + { tag: "Production", color: "blue" }, + { icon: "bs-branch", text: "main", tooltip: "Branch" }, + { icon: "bs-timer", text: "1m 15s", tooltip: "Build time" }, + ]); + assertEquals(v.actions?.map((a) => a.title).slice(0, 5), [ + "Open Deployment", + "Open in Vercel", + "Show Build Logs", + "Open Commit", + "Redeploy", + ]); + assertEquals(v.actions?.find((a) => a.title === "Redeploy")?.confirm, "Redeploy storefront to production?"); + + const n = deploymentItem(fromNetlify(netlifyDeploy), ctx); + assertEquals(n.icon, { source: "bs-circle-x", tint: "red" }); + const titles = n.actions?.map((a) => a.title) ?? []; + assertEquals(titles.slice(0, 2), ["Open in Netlify", "Open Deployment URL"]); + assertEquals(titles.includes("Retry Deploy"), true); + assertEquals(titles.includes("Show Build Logs"), false); + + const building = deploymentItem({ ...fromNetlify(netlifyDeploy), state: "building" }, ctx); + const cancel = building.actions?.find((a) => a.title === "Cancel Deployment"); + assertEquals([cancel?.destructive, building.actions?.some((a) => a.title === "Retry Deploy")], [true, false]); +}); + +Deno.test("projects and sites", () => { + const v = vercelItem({ + id: "prj_1", + name: "storefront", + framework: "nextjs", + updatedAt: T, + link: { type: "github", org: "acme", repo: "storefront", productionBranch: "main" }, + targets: { + production: { id: "dpl_1", alias: ["shop.acme.dev", "storefront.vercel.app"], readyState: "ERROR", createdAt: T }, + }, + }, ctx); + assertEquals([v.subtitle, v.icon], ["shop.acme.dev", { source: "bs-circle-x", tint: "red" }]); + assertEquals(v.actions?.[0].title, "Show Deployments"); + const site: NetlifySite = { + id: "3970e0fe", + name: "acme-docs", + ssl_url: "https://docs.acme.dev", + admin_url: "https://app.netlify.com/sites/acme-docs", + updated_at: "2026-10-26T18:00:00.000Z", + build_settings: { repo_url: "https://github.com/acme/docs", repo_branch: "main" }, + published_deploy: { ...netlifyDeploy, state: "ready", published_at: "2026-10-26T18:01:00.000Z" }, + }; + const n = netlifyItem(site, ctx); + assertEquals([n.subtitle, n.section, n.actions?.[0].title], ["docs.acme.dev", "Netlify", "Show Deploys"]); + assertEquals(n.detail?.metadata?.map((m) => m.label).slice(0, 5), [ + "Published", + "URL", + "Repository", + "Production branch", + "Last commit", + ]); +}); + +Deno.test("Vercel build logs", () => { + assertEquals( + logLines([ + { type: "command", created: 1, payload: { text: "npm run build" } }, + { type: "stdout", created: 2, payload: { text: "\u001b[32m✓\u001b[39m Compiled " } }, + { type: "delimiter", created: 3, payload: { text: "Build" } }, + { type: "stderr", created: 4, text: "warn: old" }, + ]), + ["$ npm run build", "✓ Compiled", "warn: old"], + ); +}); + +Deno.test("tokens: optional, per provider", async () => { + assertEquals(configured({ vercelToken: " v " }), ["vercel"]); + assertEquals(configured({}), []); + assertEquals(setupAll().map((i) => i.actions?.[0].title), ["Create Vercel Token", "Create Netlify Token"]); + assertEquals([vercelScope("team_abc"), vercelScope("acme"), vercelScope("")], ["teamId=team_abc", "slug=acme", ""]); + const rejected = await guarded( + "netlify", + () => Promise.reject(new AuthError("netlify", "Netlify rejected the access token")), + ); + assertEquals([rejected[0].id, rejected[0].title], ["setup-netlify", "Netlify rejected the access token"]); + const failed = await guarded("vercel", () => Promise.reject(new Error("Vercel answered 500"))); + assertEquals(failed[0].id, "error-vercel"); +}); diff --git a/extensions/gitlab/api.ts b/extensions/gitlab/api.ts new file mode 100644 index 0000000..ed1c949 --- /dev/null +++ b/extensions/gitlab/api.ts @@ -0,0 +1,110 @@ +// GitLab REST and GraphQL helpers, and what to show when the token is missing or rejected. + +import { Icons, type Item, open } from "@backslash/api"; + +/** + * gitlab.com only: `[permissions] net` is a static list of hosts, so a + * self-hosted instance would need its own host in the manifest. + */ +export const HOST = "https://gitlab.com"; + +/** Prefilled "new token" page: name and the read-only API scope. */ +export const TOKEN_URL = `${HOST}/-/user_settings/personal_access_tokens?name=Backslash&scopes=read_api`; + +/** The token is missing or GitLab refused it: the list shows how to fix it instead of an error. */ +export class AuthError extends Error {} + +export function needToken(preferences: Record): string { + const token = String(preferences.token ?? "").trim(); + if (!token) throw new AuthError("Add a GitLab personal access token"); + return token; +} + +async function check(res: Response): Promise { + if (res.status === 401) throw new AuthError("GitLab rejected the access token"); + if (res.ok) return; + const body = await res.json().catch(() => ({})) as { message?: unknown; error?: string }; + const message = typeof body.message === "string" ? body.message : body.error ?? JSON.stringify(body.message ?? ""); + if (res.status === 403) { + throw new Error(`GitLab refused it (403${message ? `: ${message}` : ""}). Actions need a token with the api scope`); + } + throw new Error(`GitLab answered ${res.status}${message ? `: ${message}` : ""}`); +} + +export async function rest( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${HOST}/api/v4${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + await check(res); + return res.status === 204 ? undefined as T : await res.json() as T; +} + +export async function graphql( + token: string, + query: string, + variables: Record = {}, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${HOST}/api/graphql`, { + method: "POST", + signal, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + await check(res); + const body = await res.json() as { data?: T; errors?: { message: string }[] }; + if (body.errors?.length) throw new Error(`GitLab: ${body.errors[0].message}`); + if (!body.data) throw new Error("GitLab sent no data"); + return body.data; +} + +/** A project path as REST wants it in URLs: `group%2Fproject`. */ +export const projectRef = (fullPath: string | number) => encodeURIComponent(String(fullPath)); + +/** The one row shown instead of the list when signing in is needed. */ +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Create a personal access token on gitlab.com", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect GitLab", + "", + "1. Open **GitLab → Preferences → Access tokens** (Enter opens it, prefilled).", + "2. Keep the **read_api** scope; add **api** to approve merge requests and retry pipelines.", + "3. Paste the token in **Settings → Extensions → GitLab → Configure**.", + "", + "Only **gitlab.com** is supported: extensions declare the hosts they reach, so a self-hosted instance cannot be added from the settings.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Access Token", { icon: Icons.key })], + }]; +} + +/** Runs `load`; a token problem becomes the setup row. */ +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} + +/** "3 merge requests", or the sign-in hint. */ +export function countStatus(items: Item[], one: string, many = `${one}s`): string { + if (items[0]?.id === "setup") return "Not connected"; + return `${items.length} ${items.length === 1 ? one : many}`; +} diff --git a/extensions/gitlab/backslash.toml b/extensions/gitlab/backslash.toml new file mode 100644 index 0000000..afe5e60 --- /dev/null +++ b/extensions/gitlab/backslash.toml @@ -0,0 +1,47 @@ +[extension] +id = "gitlab" +title = "GitLab" +description = "Merge requests with pipelines and approvals, your issues, projects and pipelines on gitlab.com (self-hosted instances are not supported)" +version = "1.0.0" +author = "Backslash" +icon = "bs-pr-merged" +license = "MIT" + +# gitlab.com for the API and uploaded avatars, Gravatar for the other avatars. +# Hosts are static: a self-hosted GitLab would need its own host here. +[permissions] +net = ["gitlab.com", "secure.gravatar.com"] + +[[commands]] +name = "merge-requests" +title = "My Merge Requests" +description = "To review, opened by you, assigned: with pipeline status and approvals" +icon = "bs-pr-open" +keywords = ["gitlab", "mr", "merge request", "review", "pull request", "pipeline", "ci"] + +[[commands]] +name = "issues" +title = "My GitLab Issues" +description = "Assigned to you or created by you" +icon = "bs-issue-open" +keywords = ["gitlab", "issues", "bugs", "tickets", "tasks"] + +[[commands]] +name = "projects" +title = "Search GitLab Projects" +icon = "bs-repo" +keywords = ["gitlab", "project", "repo", "repository", "git", "code"] + +[[commands]] +name = "pipelines" +title = "GitLab Pipelines" +description = "Pipelines of your projects: status, duration, stages; retry or cancel" +icon = "bs-activity" +keywords = ["gitlab", "pipeline", "ci", "cd", "build", "jobs"] + +[[preferences]] +name = "token" +title = "Personal access token" +description = "gitlab.com → Preferences → Access tokens, scope read_api (api to approve, close issues and retry pipelines)" +type = "secret" +required = true diff --git a/extensions/gitlab/commands/issues.ts b/extensions/gitlab/commands/issues.ts new file mode 100644 index 0000000..282680f --- /dev/null +++ b/extensions/gitlab/commands/issues.ts @@ -0,0 +1,35 @@ +import { type Context, list, updateCommandMetadata } from "@backslash/api"; +import { countStatus, guarded, needToken, rest } from "../api.ts"; +import { type Issue, issueItem, issueProject } from "../lib.ts"; + +const SCOPES: Record = { assigned: "assigned_to_me", created: "created_by_me" }; + +export default (ctx: Context) => + list({ + placeholder: "Filter issues by title, project, label, milestone…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "created", title: "Created by me" }, + ], + }, + empty: { title: "No open issues here", icon: "bs-issue-open" }, + status: (items) => countStatus(items, "open issue"), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const key = filter && SCOPES[filter] ? filter : "assigned"; + const issues = await rest( + needToken(preferences), + `/issues?scope=${SCOPES[key]}&state=opened&order_by=updated_at&per_page=60&with_labels_details=true`, + { signal }, + ); + if (key === "assigned") { + updateCommandMetadata({ subtitle: issues.length ? `${issues.length} assigned` : null }).catch(() => {}); + } + const order = new Map(); + issues.forEach((issue, i) => order.has(issueProject(issue)) || order.set(issueProject(issue), i)); + return [...issues] + .sort((a, b) => order.get(issueProject(a))! - order.get(issueProject(b))!) + .map((issue) => issueItem(issue, ctx)); + }), + }); diff --git a/extensions/gitlab/commands/merge-requests.ts b/extensions/gitlab/commands/merge-requests.ts new file mode 100644 index 0000000..99d2801 --- /dev/null +++ b/extensions/gitlab/commands/merge-requests.ts @@ -0,0 +1,47 @@ +import { type Context, list, updateCommandMetadata } from "@backslash/api"; +import { countStatus, graphql, guarded, needToken } from "../api.ts"; +import { type MergeRequest, MR_FIELDS, mrItem } from "../lib.ts"; + +/** Fields of `currentUser` for each filter. */ +const CONNECTIONS: Record = { + review: "reviewRequestedMergeRequests", + mine: "authoredMergeRequests", + assigned: "assignedMergeRequests", +}; + +export default (ctx: Context) => + list({ + placeholder: "Filter merge requests by title, project, author, branch, label…", + filter: { + options: [ + { value: "review", title: "To review" }, + { value: "mine", title: "Mine" }, + { value: "assigned", title: "Assigned" }, + ], + }, + empty: { title: "Nothing here", description: "No open merge request matches.", icon: "bs-pr-open" }, + status: (items) => countStatus(items, "merge request"), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const key = filter && CONNECTIONS[filter] ? filter : "review"; + const data = await graphql<{ currentUser: Record | null }>( + needToken(preferences), + `query { currentUser { ${ + CONNECTIONS[key] + }(state: opened, first: 50, sort: UPDATED_DESC) { nodes { ${MR_FIELDS} } } } }`, + {}, + signal, + ); + if (!data.currentUser) throw new Error("GitLab did not recognize the token's user"); + const mrs = data.currentUser[CONNECTIONS[key]].nodes; + if (key === "review") { + updateCommandMetadata({ subtitle: mrs.length ? `${mrs.length} to review` : null }).catch(() => {}); + } + // Grouped by project, the most recently updated project first. + const order = new Map(); + mrs.forEach((mr, i) => order.has(mr.project.fullPath) || order.set(mr.project.fullPath, i)); + return [...mrs] + .sort((a, b) => order.get(a.project.fullPath)! - order.get(b.project.fullPath)!) + .map((mr) => mrItem(mr, ctx)); + }), + }); diff --git a/extensions/gitlab/commands/pipelines.ts b/extensions/gitlab/commands/pipelines.ts new file mode 100644 index 0000000..eb2460e --- /dev/null +++ b/extensions/gitlab/commands/pipelines.ts @@ -0,0 +1,45 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { guarded, needToken, rest } from "../api.ts"; +import { type Project, projectItem } from "../lib.ts"; + +/** How many projects get their last pipeline status (one request each). */ +const WITH_STATUS = 15; + +export default (ctx: Context) => + list({ + placeholder: "Pick a project to see its pipelines…", + empty: { title: "No project", icon: Icons.activity }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : "Enter shows the project's pipelines", + async *items({ preferences, signal }) { + let token: string; + let projects: Project[] = []; + const first = await guarded(async () => { + token = needToken(preferences); + projects = await rest( + token, + "/projects?membership=true&order_by=last_activity_at&per_page=40&archived=false", + { signal }, + ); + return projects.map((p) => toItem(p)); + }); + yield first; + if (first[0]?.id === "setup" || !projects.length) return; + // Then the status of the last pipeline of the most active projects. + const latest = new Map(); + await Promise.all( + projects.slice(0, WITH_STATUS).map(async (p) => { + const [last] = await rest<{ status: string }[]>(token, `/projects/${p.id}/pipelines?per_page=1`, { signal }) + .catch(() => []); + if (last) latest.set(p.id, last.status); + }), + ); + yield projects.map((p) => toItem(p, latest.get(p.id))); + + function toItem(p: Project, pipeline?: string) { + const item = projectItem(p, ctx, { pipeline }); + // Enter goes to the pipelines; the other project actions stay in Ctrl+K. + const [openAction, pipelines, ...others] = item.actions ?? []; + return { ...item, actions: [pipelines, openAction, ...others].filter(Boolean) }; + } + }, + }); diff --git a/extensions/gitlab/commands/projects.ts b/extensions/gitlab/commands/projects.ts new file mode 100644 index 0000000..5f2f378 --- /dev/null +++ b/extensions/gitlab/commands/projects.ts @@ -0,0 +1,35 @@ +import { type Context, list } from "@backslash/api"; +import { countStatus, guarded, needToken, rest } from "../api.ts"; +import { type Project, projectItem } from "../lib.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Search GitLab projects…", + empty: { title: "No project found", icon: "bs-repo" }, + status: (items) => countStatus(items, "project"), + search: (query, { preferences, signal }) => + guarded(async () => { + const token = needToken(preferences); + const q = query.trim(); + const search = q ? `&search=${encodeURIComponent(q)}` : ""; + const mine = rest( + token, + `/projects?membership=true&order_by=last_activity_at&per_page=30${search}`, + { signal }, + ); + // Typing also searches every public project, below yours. + const others = q.length >= 2 + ? rest( + token, + `/projects?search=${encodeURIComponent(q)}&search_namespaces=true&order_by=last_activity_at&per_page=20`, + { signal }, + ) + : Promise.resolve([]); + const [own, all] = await Promise.all([mine, others]); + const seen = new Set(own.map((p) => p.id)); + return [ + ...own.map((p) => projectItem(p, ctx, { section: q ? "Your projects" : "Recently active" })), + ...all.filter((p) => !seen.has(p.id)).map((p) => projectItem(p, ctx, { section: "All projects" })), + ]; + }), + }); diff --git a/extensions/gitlab/lib.ts b/extensions/gitlab/lib.ts new file mode 100644 index 0000000..518c14e --- /dev/null +++ b/extensions/gitlab/lib.ts @@ -0,0 +1,738 @@ +// GitLab objects (merge requests, issues, projects, pipelines) as launcher items. + +import { + type Accessory, + type Action, + action, + type Color, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + type Stat, + toast, +} from "@backslash/api"; +import { countStatus, graphql, guarded, HOST, needToken, projectRef, rest } from "./api.ts"; + +// --------------------------------------------------------------------------- +// Formatting + +/** "today", "yesterday", "3d", "25 Oct", "Mar 2024". */ +export function ago(iso: string | null | undefined, now = new Date()): string { + if (!iso) return ""; + const d = new Date(iso); + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (days < 365) return d.toLocaleDateString("en", { day: "numeric", month: "short" }); + return d.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +/** 75 → "1m 15s", 3720 → "1h 2m". */ +export function duration(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds)) return ""; + const s = Math.round(seconds); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} + +/** 1234 → "1.2k". */ +export function compact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`; + return String(n); +} + +/** The palette color closest to a GitLab label color. */ +export function colorOf(hex: string | null | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +/** GraphQL gives uploaded avatars as paths; gravatars are absolute. */ +export function avatar(url: string | null | undefined, fallback: string): string { + if (!url) return fallback; + return url.startsWith("/") ? `${HOST}${url}` : url; +} + +/** The numeric id at the end of a GraphQL global id (`gid://gitlab/Ci::Pipeline/42` → 42). */ +export const gidNumber = (gid: string) => Number(gid.split("/").pop()); + +function excerpt(text: string | null | undefined, max = 400): string | undefined { + const t = (text ?? "").replace(//g, "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +// --------------------------------------------------------------------------- +// Pipelines + +export interface PipelineLook { + icon: string; + color: Color; + text: string; +} + +/** A pipeline, job or stage status (REST lowercase or GraphQL uppercase). */ +export function pipelineStatus(status: string | null | undefined): PipelineLook | undefined { + switch ((status ?? "").toLowerCase()) { + case "success": + return { icon: Icons.check, color: "green", text: "Passed" }; + case "failed": + return { icon: Icons.cross, color: "red", text: "Failed" }; + case "running": + return { icon: Icons.inProgress, color: "blue", text: "Running" }; + case "pending": + case "created": + case "preparing": + case "waiting_for_resource": + case "waiting_for_callback": + return { icon: Icons.clock, color: "yellow", text: "Pending" }; + case "scheduled": + return { icon: Icons.calendar, color: "gray", text: "Scheduled" }; + case "manual": + return { icon: Icons.play, color: "gray", text: "Manual" }; + case "canceled": + case "canceling": + return { icon: Icons.canceled, color: "gray", text: "Canceled" }; + case "skipped": + return { icon: Icons.arrowRight, color: "gray", text: "Skipped" }; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Merge requests (GraphQL) + +interface User { + username: string; + name: string; + avatarUrl?: string | null; +} + +export const MR_FIELDS = ` + id iid title webUrl draft state createdAt updatedAt sourceBranch targetBranch description + conflicts detailedMergeStatus userNotesCount approved approvalsRequired approvalsLeft + author { username name avatarUrl } + project { fullPath name } + headPipeline { iid status path } + approvedBy { nodes { username name avatarUrl } } + reviewers { nodes { username name avatarUrl } } + labels { nodes { title color } } + diffStatsSummary { additions deletions fileCount } +`; + +export interface MergeRequest { + id: string; + iid: string; + title: string; + webUrl: string; + draft: boolean; + state: "opened" | "closed" | "merged" | "locked"; + createdAt: string; + updatedAt: string; + sourceBranch: string; + targetBranch: string; + description?: string | null; + conflicts?: boolean; + detailedMergeStatus?: string | null; + userNotesCount?: number; + approved?: boolean; + approvalsRequired?: number | null; + approvalsLeft?: number | null; + author?: User | null; + project: { fullPath: string; name: string }; + headPipeline?: { iid: string; status: string; path?: string | null } | null; + approvedBy?: { nodes: User[] }; + reviewers?: { nodes: User[] }; + labels?: { nodes: { title: string; color: string }[] }; + diffStatsSummary?: { additions: number; deletions: number; fileCount: number } | null; +} + +export function mrIcon(mr: Pick): { source: string; tint: Color } { + if (mr.state === "merged") return { source: Icons.merged, tint: "blue" }; + if (mr.state === "closed") return { source: Icons.pullRequestClosed, tint: "red" }; + if (mr.draft) return { source: Icons.pullRequestDraft, tint: "gray" }; + return { source: Icons.pullRequest, tint: "green" }; +} + +/** "Approved", "1 of 2 approvals", or nothing when no approval is asked for and none given. */ +export function approvals(mr: MergeRequest): { text: string; color: Color } | undefined { + const given = mr.approvedBy?.nodes.length ?? 0; + const required = mr.approvalsRequired ?? 0; + if (required > 0) { + const left = Math.max(0, mr.approvalsLeft ?? required - given); + if (left === 0) return { text: "Approved", color: "green" }; + return { text: `${required - left} of ${required} approvals`, color: "orange" }; + } + return given > 0 ? { text: given === 1 ? "Approved" : `Approved ×${given}`, color: "green" } : undefined; +} + +const MERGE_STATUS: Record = { + MERGEABLE: { text: "Ready to merge", color: "green" }, + CONFLICT: { text: "Merge conflicts", color: "red" }, + NEED_REBASE: { text: "Needs a rebase", color: "orange" }, + NOT_APPROVED: { text: "Waiting for approvals", color: "orange" }, + DISCUSSIONS_NOT_RESOLVED: { text: "Open discussions", color: "orange" }, + CI_MUST_PASS: { text: "Pipeline must pass", color: "orange" }, + CI_STILL_RUNNING: { text: "Pipeline running", color: "blue" }, + DRAFT_STATUS: { text: "Draft", color: "gray" }, + BLOCKED_STATUS: { text: "Blocked by another MR", color: "red" }, + NOT_OPEN: { text: "Not open", color: "gray" }, + CHECKING: { text: "Checking…", color: "gray" }, + UNCHECKED: { text: "Not checked yet", color: "gray" }, +}; + +export const mergeStatus = (mr: MergeRequest) => + MERGE_STATUS[(mr.detailedMergeStatus ?? "").toUpperCase()] ?? + (mr.conflicts ? MERGE_STATUS.CONFLICT : undefined); + +function mrStateText(mr: MergeRequest): string { + if (mr.state === "merged") return "Merged"; + if (mr.state === "closed") return "Closed"; + return mr.draft ? "Draft" : "Open"; +} + +export function mrMetadata(mr: MergeRequest): Metadata[] { + const icon = mrIcon(mr); + const ci = pipelineStatus(mr.headPipeline?.status); + const ok = approvals(mr); + const status = mergeStatus(mr); + const lines: Metadata[] = [ + meta.text("Status", mrStateText(mr), { icon: icon.source, color: icon.tint }), + meta.person("Author", mr.author?.name ?? "Unknown", avatar(mr.author?.avatarUrl, mr.author?.name ?? "?")), + meta.text("Branch", `${mr.sourceBranch} → ${mr.targetBranch}`, { icon: Icons.branch }), + ]; + if (ci) lines.push(meta.text("Pipeline", ci.text, { icon: ci.icon, color: ci.color })); + if (ok) lines.push(meta.text("Approvals", ok.text, { icon: Icons.check, color: ok.color })); + const approvers = mr.approvedBy?.nodes ?? []; + if (approvers.length) lines.push(meta.tags("Approved by", approvers.map((u) => ({ text: u.name, color: "green" })))); + const reviewers = mr.reviewers?.nodes ?? []; + if (reviewers.length) lines.push(meta.tags("Reviewers", reviewers.map((u) => u.name))); + if (status && mr.state === "opened") lines.push(meta.text("Merge", status.text, { color: status.color })); + const labels = mr.labels?.nodes ?? []; + if (labels.length) lines.push(meta.tags("Labels", labels.map((l) => ({ text: l.title, color: colorOf(l.color) })))); + lines.push(meta.separator()); + lines.push(meta.text("Project", mr.project.fullPath, { icon: Icons.repository })); + lines.push(meta.text("Opened", ago(mr.createdAt))); + lines.push(meta.text("Updated", ago(mr.updatedAt))); + return lines; +} + +function mrStats(mr: MergeRequest): Stat[] { + const stats: Stat[] = []; + const diff = mr.diffStatsSummary; + if (diff) { + stats.push( + { label: "files", value: diff.fileCount }, + { label: "added", value: `+${diff.additions}`, color: "green" }, + { label: "removed", value: `−${diff.deletions}`, color: "red" }, + ); + } + stats.push({ label: "comments", value: mr.userNotesCount ?? 0 }); + return stats; +} + +export function mrActions(mr: MergeRequest, ctx: Context, page = false): Action[] { + const token = needToken(ctx.preferences); + const ref = `${mr.project.fullPath}!${mr.iid}`; + return [ + open(mr.webUrl, "Open in Browser"), + ...(page ? [] : [action("Show Details", () => mrPage(mr, ctx), { icon: Icons.info })]), + open(`${mr.webUrl}/diffs`, "Open Changes", { icon: Icons.code, shortcut: "f" }), + ...(mr.headPipeline?.path + ? [open(`${HOST}${mr.headPipeline.path}`, "Open Pipeline", { icon: Icons.activity, shortcut: "p" })] + : []), + ...(mr.state === "opened" + ? [action("Approve", async () => { + await rest(token, `/projects/${projectRef(mr.project.fullPath)}/merge_requests/${mr.iid}/approve`, { + method: "POST", + }); + await toast(`Approved ${ref}`, { style: "success" }); + return refresh(); + }, { icon: Icons.check, shortcut: "a", confirm: `Approve ${ref}?`, section: "Merge Request" })] + : []), + copy(mr.webUrl, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(ref, "Copy Reference", { icon: Icons.hash, shortcut: "period", section: "Copy" }), + copy(mr.sourceBranch, "Copy Branch Name", { icon: Icons.branch, section: "Copy" }), + copy( + `git fetch origin ${mr.sourceBranch} && git checkout ${mr.sourceBranch}`, + "Copy Checkout Command", + { icon: Icons.terminal, section: "Copy" }, + ), + copy(`[${ref}: ${mr.title}](${mr.webUrl})`, "Copy as Markdown Link", { section: "Copy" }), + ]; +} + +export function mrPage(mr: MergeRequest, ctx: Context): DetailSpec { + return detail({ + title: `!${mr.iid}`, + markdown: `# ${mr.title}\n\n${mr.description?.replace(//g, "").trim() || "_No description._"}`, + stats: mrStats(mr), + metadata: mrMetadata(mr), + status: `${mr.project.fullPath} !${mr.iid}`, + actions: mrActions(mr, ctx, true), + }); +} + +export function mrItem(mr: MergeRequest, ctx: Context): Item { + const ci = pipelineStatus(mr.headPipeline?.status); + const ok = approvals(mr); + const accessories: Accessory[] = []; + if (ci) accessories.push({ icon: { source: ci.icon, tint: ci.color }, tooltip: `Pipeline ${ci.text.toLowerCase()}` }); + if (mr.conflicts && mr.state === "opened") accessories.push({ tag: "Conflicts", color: "red" }); + if (ok) accessories.push({ tag: ok.text, color: ok.color }); + const labels = mr.labels?.nodes ?? []; + accessories.push(...labels.slice(0, 2).map((l) => ({ tag: l.title, color: colorOf(l.color) }))); + if (mr.author) { + accessories.push({ avatar: avatar(mr.author.avatarUrl, mr.author.name), tooltip: mr.author.name }); + } + accessories.push({ text: ago(mr.updatedAt), tooltip: "Updated" }); + return { + id: mr.id, + title: mr.title, + subtitle: `!${mr.iid}`, + icon: mrIcon(mr), + section: mr.project.fullPath, + keywords: [ + mr.project.fullPath, + `!${mr.iid}`, + mr.author?.username ?? "", + mr.sourceBranch, + ...labels.map((l) => l.title), + mr.draft ? "draft" : "", + ].filter(Boolean), + accessories, + detail: { stats: mrStats(mr), markdown: excerpt(mr.description), metadata: mrMetadata(mr) }, + actions: mrActions(mr, ctx), + }; +} + +// --------------------------------------------------------------------------- +// Issues (REST, with_labels_details=true) + +interface RestUser { + id: number; + username: string; + name: string; + avatar_url?: string | null; +} + +export interface Issue { + id: number; + iid: number; + project_id: number; + title: string; + description?: string | null; + state: "opened" | "closed"; + created_at: string; + updated_at: string; + due_date?: string | null; + web_url: string; + labels: ({ name: string; color: string } | string)[]; + milestone?: { title: string; due_date?: string | null } | null; + author: RestUser; + assignees?: RestUser[]; + user_notes_count?: number; + upvotes?: number; + weight?: number | null; + confidential?: boolean; + issue_type?: string; + references?: { short: string; full: string }; + task_completion_status?: { count: number; completed_count: number }; +} + +const labelOf = (l: Issue["labels"][number]) => + typeof l === "string" ? { text: l, color: "gray" as Color } : { text: l.name, color: colorOf(l.color) }; + +/** `group/project` from `group/project#12`. */ +export const issueProject = (issue: Issue) => issue.references?.full.replace(/#\d+$/, "") ?? `#${issue.project_id}`; + +export function isOverdue(due: string | null | undefined, now = new Date()): boolean { + if (!due) return false; + return new Date(`${due}T23:59:59`) < now; +} + +/** GitLab's branch name for an issue: `12-fix-the-login-page`. */ +export function branchName(issue: Pick): string { + const slug = issue.title.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/, ""); + return `${issue.iid}-${slug}`; +} + +export function issueMetadata(issue: Issue): Metadata[] { + const lines: Metadata[] = [ + meta.text("Status", issue.state === "opened" ? "Open" : "Closed", { + icon: issue.state === "opened" ? Icons.issue : Icons.issueClosed, + color: issue.state === "opened" ? "green" : "blue", + }), + meta.person("Author", issue.author.name, avatar(issue.author.avatar_url, issue.author.name)), + ]; + const assignees = issue.assignees ?? []; + if (assignees.length === 1) { + lines.push(meta.person("Assignee", assignees[0].name, avatar(assignees[0].avatar_url, assignees[0].name))); + } else if (assignees.length) { + lines.push(meta.tags("Assignees", assignees.map((a) => a.name))); + } else { + lines.push(meta.text("Assignee", "Unassigned", { icon: Icons.person })); + } + if (issue.labels.length) lines.push(meta.tags("Labels", issue.labels.map(labelOf))); + if (issue.milestone) lines.push(meta.text("Milestone", issue.milestone.title, { icon: Icons.flag })); + if (issue.due_date) { + lines.push(meta.text("Due", issue.due_date, { + icon: Icons.calendar, + color: isOverdue(issue.due_date) && issue.state === "opened" ? "red" : undefined, + })); + } + if (issue.weight != null) lines.push(meta.text("Weight", issue.weight)); + const tasks = issue.task_completion_status; + if (tasks?.count) { + lines.push( + meta.progress("Tasks", tasks.completed_count / tasks.count, `${tasks.completed_count} of ${tasks.count}`), + ); + } + if (issue.confidential) lines.push(meta.text("Visibility", "Confidential", { icon: Icons.lock, color: "orange" })); + lines.push(meta.separator()); + lines.push(meta.text("Project", issueProject(issue), { icon: Icons.repository })); + lines.push(meta.text("Opened", ago(issue.created_at))); + lines.push(meta.text("Updated", ago(issue.updated_at))); + return lines; +} + +export function issueActions(issue: Issue, ctx: Context, page = false): Action[] { + const token = needToken(ctx.preferences); + const ref = issue.references?.full ?? `#${issue.iid}`; + return [ + open(issue.web_url, "Open in Browser"), + ...(page ? [] : [action("Show Details", () => issuePage(issue, ctx), { icon: Icons.info })]), + ...(issue.state === "opened" + ? [action("Close Issue", async () => { + await rest(token, `/projects/${issue.project_id}/issues/${issue.iid}`, { + method: "PUT", + body: { state_event: "close" }, + }); + await toast(`Closed ${ref}`, { style: "success" }); + return refresh(); + }, { icon: Icons.issueClosed, destructive: true, confirm: `Close ${ref}?`, section: "Issue" })] + : []), + copy(issue.web_url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(ref, "Copy Reference", { icon: Icons.hash, shortcut: "period", section: "Copy" }), + copy(branchName(issue), "Copy Branch Name", { icon: Icons.branch, section: "Copy" }), + copy(`[${ref}: ${issue.title}](${issue.web_url})`, "Copy as Markdown Link", { section: "Copy" }), + ]; +} + +export function issuePage(issue: Issue, ctx: Context): DetailSpec { + return detail({ + title: issue.references?.short ?? `#${issue.iid}`, + markdown: `# ${issue.title}\n\n${issue.description?.trim() || "_No description._"}`, + metadata: issueMetadata(issue), + status: `${issueProject(issue)} · updated ${ago(issue.updated_at)}`, + actions: issueActions(issue, ctx, true), + }); +} + +export function issueItem(issue: Issue, ctx: Context): Item { + const tasks = issue.task_completion_status; + const assignee = issue.assignees?.[0]; + const accessories: Accessory[] = issue.labels.slice(0, 2).map((l) => { + const { text, color } = labelOf(l); + return { tag: text, color }; + }); + if (issue.due_date) { + accessories.push({ + icon: Icons.calendar, + text: issue.due_date.slice(5), + color: isOverdue(issue.due_date) ? "red" : undefined, + tooltip: `Due ${issue.due_date}`, + }); + } + if (tasks?.count) accessories.push({ text: `${tasks.completed_count}/${tasks.count}`, tooltip: "Tasks done" }); + if (issue.user_notes_count) { + accessories.push({ icon: Icons.comment, text: String(issue.user_notes_count), tooltip: "Comments" }); + } + if (assignee) accessories.push({ avatar: avatar(assignee.avatar_url, assignee.name), tooltip: assignee.name }); + accessories.push({ text: ago(issue.updated_at), tooltip: "Updated" }); + return { + id: String(issue.id), + title: issue.title, + subtitle: `#${issue.iid}`, + icon: issue.state === "opened" + ? { source: Icons.issue, tint: "green" } + : { source: Icons.issueClosed, tint: "blue" }, + section: issueProject(issue), + keywords: [ + issueProject(issue), + `#${issue.iid}`, + issue.milestone?.title ?? "", + ...issue.labels.map((l) => labelOf(l).text), + ].filter(Boolean), + accessories, + detail: { markdown: excerpt(issue.description), metadata: issueMetadata(issue) }, + actions: issueActions(issue, ctx), + }; +} + +// --------------------------------------------------------------------------- +// Projects (REST) + +export interface Project { + id: number; + name: string; + name_with_namespace: string; + path_with_namespace: string; + description?: string | null; + web_url: string; + star_count: number; + forks_count: number; + open_issues_count?: number; + last_activity_at: string; + default_branch?: string | null; + visibility?: "public" | "internal" | "private"; + topics?: string[]; + archived?: boolean; + ssh_url_to_repo?: string; + http_url_to_repo?: string; + namespace?: { full_path: string; name: string }; +} + +export function projectMetadata(p: Project): Metadata[] { + const lines: Metadata[] = [ + meta.text("Path", p.path_with_namespace, { icon: Icons.repository }), + meta.text("Visibility", (p.visibility ?? "private").replace(/^./, (c) => c.toUpperCase()), { + icon: p.visibility === "public" ? Icons.globe : Icons.lock, + }), + ]; + if (p.default_branch) lines.push(meta.text("Default branch", p.default_branch, { icon: Icons.branch })); + if (p.topics?.length) lines.push(meta.tags("Topics", p.topics.map((t) => ({ text: t, color: "blue" })))); + if (p.archived) lines.push(meta.text("Archived", "Yes", { icon: Icons.archive, color: "orange" })); + lines.push(meta.separator()); + lines.push(meta.text("Last activity", ago(p.last_activity_at))); + return lines; +} + +export function projectItem(p: Project, ctx: Context, options: { section?: string; pipeline?: string } = {}): Item { + const ci = pipelineStatus(options.pipeline); + const accessories: Accessory[] = []; + if (ci) { + accessories.push({ icon: { source: ci.icon, tint: ci.color }, tooltip: `Last pipeline ${ci.text.toLowerCase()}` }); + } + if (p.visibility === "private") accessories.push({ icon: Icons.lock, tooltip: "Private" }); + if (p.star_count) accessories.push({ icon: Icons.star, text: compact(p.star_count), tooltip: "Stars" }); + accessories.push({ text: ago(p.last_activity_at), tooltip: "Last activity" }); + return { + id: String(p.id), + title: p.name, + subtitle: p.namespace?.full_path ?? p.path_with_namespace.split("/").slice(0, -1).join("/"), + icon: { source: Icons.repository, tint: p.archived ? "gray" : "orange" }, + section: options.section, + keywords: [p.path_with_namespace, ...(p.topics ?? [])], + accessories, + detail: { + stats: [ + { label: "stars", value: compact(p.star_count) }, + { label: "forks", value: compact(p.forks_count) }, + ...(p.open_issues_count != null ? [{ label: "open issues", value: p.open_issues_count }] : []), + ], + markdown: p.description?.trim() || undefined, + metadata: projectMetadata(p), + }, + actions: projectActions(p, ctx), + }; +} + +export function projectActions(p: Project, ctx: Context): Action[] { + return [ + open(p.web_url, "Open in Browser"), + action("Show Pipelines", () => pipelinesView(p, ctx), { icon: Icons.activity, shortcut: "p" }), + open(`${p.web_url}/-/merge_requests`, "Open Merge Requests", { icon: Icons.pullRequest, shortcut: "m" }), + open(`${p.web_url}/-/issues`, "Open Issues", { icon: Icons.issue, shortcut: "i" }), + ...(p.ssh_url_to_repo + ? [copy(p.ssh_url_to_repo, "Copy SSH Clone URL", { icon: Icons.terminal, section: "Copy" })] + : []), + ...(p.http_url_to_repo ? [copy(p.http_url_to_repo, "Copy HTTPS Clone URL", { section: "Copy" })] : []), + copy(p.web_url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(p.path_with_namespace, "Copy Path", { section: "Copy" }), + ]; +} + +// --------------------------------------------------------------------------- +// Pipelines of a project (GraphQL) + +export interface Pipeline { + id: string; + iid: string; + status: string; + ref?: string | null; + sha?: string | null; + path?: string | null; + duration?: number | null; + queuedDuration?: number | null; + createdAt: string; + startedAt?: string | null; + finishedAt?: string | null; + source?: string | null; + user?: User | null; + commit?: { title?: string | null; shortId?: string | null } | null; + stages?: { nodes: { name: string; status?: string | null }[] }; +} + +export const PIPELINES_QUERY = `query($path: ID!) { project(fullPath: $path) { pipelines(first: 30) { nodes { + id iid status ref sha path duration queuedDuration createdAt startedAt finishedAt source + user { username name avatarUrl } + commit { title shortId } + stages { nodes { name status } } +} } } }`; + +const SOURCES: Record = { + push: "Push", + web: "Run from the web", + merge_request_event: "Merge request", + schedule: "Schedule", + api: "API", + trigger: "Trigger", + pipeline: "Parent pipeline", + parent_pipeline: "Parent pipeline", + external: "External", + chat: "Chat", +}; + +export function pipelineMetadata(p: Pipeline): Metadata[] { + const s = pipelineStatus(p.status); + const lines: Metadata[] = [ + meta.text("Status", s?.text ?? p.status, { icon: s?.icon, color: s?.color }), + ]; + if (p.ref) lines.push(meta.text("Ref", p.ref, { icon: Icons.branch })); + if (p.commit?.shortId || p.sha) { + lines.push(meta.text("Commit", p.commit?.shortId ?? p.sha!.slice(0, 8), { icon: Icons.commit })); + } + if (p.user) lines.push(meta.person("Triggered by", p.user.name, avatar(p.user.avatarUrl, p.user.name))); + if (p.source) lines.push(meta.text("Source", SOURCES[p.source] ?? p.source)); + const stages = p.stages?.nodes ?? []; + if (stages.length) { + lines.push(meta.tags("Stages", stages.map((st) => ({ text: st.name, color: pipelineStatus(st.status)?.color })))); + } + lines.push(meta.separator()); + lines.push(meta.text("Created", ago(p.createdAt))); + if (p.finishedAt) lines.push(meta.text("Finished", ago(p.finishedAt))); + return lines; +} + +/** Stage by stage, like GitLab's mini graph. */ +export function stagesMarkdown(p: Pipeline): string | undefined { + const stages = p.stages?.nodes ?? []; + if (!stages.length) return undefined; + return stages.map((st) => `- **${st.name}**: ${pipelineStatus(st.status)?.text ?? st.status ?? "?"}`).join("\n"); +} + +export function pipelineItem(p: Pipeline, project: Pick, ctx: Context): Item { + const token = needToken(ctx.preferences); + const s = pipelineStatus(p.status); + const url = p.path ? `${HOST}${p.path}` : `${HOST}/${project.path_with_namespace}/-/pipelines/${gidNumber(p.id)}`; + const status = p.status.toLowerCase(); + const id = gidNumber(p.id); + const base = `/projects/${projectRef(project.path_with_namespace)}/pipelines/${id}`; + const stages = p.stages?.nodes ?? []; + const accessories: Accessory[] = stages.slice(0, 6).map((st) => { + const look = pipelineStatus(st.status); + return { + icon: { source: look?.icon ?? Icons.dot, tint: look?.color ?? "gray" }, + tooltip: `${st.name}: ${look?.text ?? st.status}`, + }; + }); + if (p.duration) accessories.push({ icon: Icons.timer, text: duration(p.duration), tooltip: "Duration" }); + if (p.user) accessories.push({ avatar: avatar(p.user.avatarUrl, p.user.name), tooltip: p.user.name }); + accessories.push({ text: ago(p.createdAt), tooltip: "Created" }); + const actions: Action[] = [open(url, "Open in Browser")]; + if (["failed", "canceled"].includes(status)) { + actions.push(action("Retry Pipeline", async () => { + await rest(token, `${base}/retry`, { method: "POST" }); + await toast(`Pipeline #${p.iid} restarted`, { style: "success" }); + return refresh(); + }, { + icon: Icons.refresh, + shortcut: "r", + confirm: `Retry the failed jobs of #${p.iid}?`, + section: "Pipeline", + })); + } + if (["running", "pending", "created", "preparing", "waiting_for_resource", "scheduled"].includes(status)) { + actions.push(action("Cancel Pipeline", async () => { + await rest(token, `${base}/cancel`, { method: "POST" }); + await toast(`Pipeline #${p.iid} canceled`, { style: "success" }); + return refresh(); + }, { icon: Icons.stop, destructive: true, confirm: `Cancel pipeline #${p.iid}?`, section: "Pipeline" })); + } + actions.push( + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + ...(p.sha ? [copy(p.sha, "Copy Commit SHA", { icon: Icons.commit, section: "Copy" })] : []), + ...(p.ref ? [copy(p.ref, "Copy Ref", { icon: Icons.branch, section: "Copy" })] : []), + ); + return { + id: p.id, + title: p.commit?.title || `Pipeline #${p.iid}`, + subtitle: `#${p.iid}${p.ref ? ` · ${p.ref}` : ""}`, + icon: s ? { source: s.icon, tint: s.color } : Icons.dot, + keywords: [p.ref ?? "", p.sha ?? "", p.user?.username ?? "", s?.text ?? p.status].filter(Boolean), + accessories, + detail: { + stats: [ + { label: "duration", value: duration(p.duration) || "—" }, + { label: "queued", value: duration(p.queuedDuration) || "—" }, + { label: "stages", value: stages.length }, + ], + markdown: stagesMarkdown(p), + metadata: pipelineMetadata(p), + }, + actions, + }; +} + +/** The pipelines of one project, newest first. */ +export function pipelinesView(project: Pick, ctx: Context): ListSpec { + return list({ + title: `${project.name} pipelines`, + placeholder: `Filter pipelines of ${project.path_with_namespace} by ref, status, commit…`, + empty: { title: "No pipelines yet", icon: Icons.activity }, + status: (items) => countStatus(items, "pipeline"), + items: ({ preferences, signal }) => + guarded(async () => { + const token = needToken(preferences); + const data = await graphql<{ project: { pipelines: { nodes: Pipeline[] } } | null }>( + token, + PIPELINES_QUERY, + { path: project.path_with_namespace }, + signal, + ); + if (!data.project) throw new Error(`Project ${project.path_with_namespace} not found`); + return data.project.pipelines.nodes.map((p) => pipelineItem(p, project, ctx)); + }), + }); +} diff --git a/extensions/gitlab/lib_test.ts b/extensions/gitlab/lib_test.ts new file mode 100644 index 0000000..df06eec --- /dev/null +++ b/extensions/gitlab/lib_test.ts @@ -0,0 +1,251 @@ +import { assertEquals } from "@std/assert"; +import type { Context } from "@backslash/api"; +import { + ago, + approvals, + avatar, + branchName, + colorOf, + duration, + gidNumber, + type Issue, + issueItem, + issueMetadata, + type MergeRequest, + mergeStatus, + mrIcon, + mrItem, + mrMetadata, + type Pipeline, + pipelineItem, + pipelineStatus, + type Project, + projectItem, + stagesMarkdown, +} from "./lib.ts"; +import { AuthError, countStatus, guarded, setupItems } from "./api.ts"; + +const ctx: Context = { args: {}, preferences: { token: "glpat-x" }, query: "", signal: new AbortController().signal }; + +// Shaped like GitLab's GraphQL `MergeRequest`. +const mr: MergeRequest = { + id: "gid://gitlab/MergeRequest/301", + iid: "128", + title: "Cache the dependency proxy manifest", + webUrl: "https://gitlab.com/acme/platform/api/-/merge_requests/128", + draft: false, + state: "opened", + createdAt: "2026-10-26T08:00:00Z", + updatedAt: "2026-10-27T10:00:00Z", + sourceBranch: "cache-manifest", + targetBranch: "main", + description: "Avoids a round trip per pull.", + conflicts: false, + detailedMergeStatus: "NOT_APPROVED", + userNotesCount: 4, + approved: false, + approvalsRequired: 2, + approvalsLeft: 1, + author: { username: "ada", name: "Ada Lovelace", avatarUrl: "/uploads/-/system/user/avatar/1/avatar.png" }, + project: { fullPath: "acme/platform/api", name: "api" }, + headPipeline: { iid: "912", status: "SUCCESS", path: "/acme/platform/api/-/pipelines/1203" }, + approvedBy: { + nodes: [{ username: "grace", name: "Grace Hopper", avatarUrl: "https://secure.gravatar.com/avatar/x" }], + }, + reviewers: { nodes: [{ username: "linus", name: "Linus T", avatarUrl: null }] }, + labels: { nodes: [{ title: "backend", color: "#1f75cb" }, { title: "bug", color: "#dc143c" }] }, + diffStatsSummary: { additions: 86, deletions: 12, fileCount: 4 }, +}; + +// Shaped like `GET /issues?with_labels_details=true`. +const issue: Issue = { + id: 9001, + iid: 42, + project_id: 77, + title: "Login page shows a blank screen on Firefox", + description: "Steps:\n1. Open /login", + state: "opened", + created_at: "2026-10-20T09:00:00.000Z", + updated_at: "2026-10-27T09:00:00.000Z", + due_date: "2026-10-01", + web_url: "https://gitlab.com/acme/web/-/issues/42", + labels: [{ name: "frontend", color: "#108548" }, "priority::1"], + milestone: { title: "16.6", due_date: "2026-11-15" }, + author: { id: 1, username: "ada", name: "Ada Lovelace", avatar_url: "https://secure.gravatar.com/avatar/a" }, + assignees: [{ id: 2, username: "grace", name: "Grace Hopper", avatar_url: null }], + user_notes_count: 3, + weight: 2, + references: { short: "#42", full: "acme/web#42" }, + task_completion_status: { count: 4, completed_count: 1 }, +}; + +const project: Project = { + id: 77, + name: "web", + name_with_namespace: "Acme / web", + path_with_namespace: "acme/web", + description: "The website", + web_url: "https://gitlab.com/acme/web", + star_count: 1234, + forks_count: 12, + open_issues_count: 30, + last_activity_at: "2026-10-27T08:00:00Z", + default_branch: "main", + visibility: "private", + topics: ["vue"], + ssh_url_to_repo: "git@gitlab.com:acme/web.git", + http_url_to_repo: "https://gitlab.com/acme/web.git", + namespace: { full_path: "acme", name: "Acme" }, +}; + +const pipeline: Pipeline = { + id: "gid://gitlab/Ci::Pipeline/1203", + iid: "912", + status: "FAILED", + ref: "main", + sha: "1f2e3d4c5b6a79881f2e3d4c5b6a79881f2e3d4c", + path: "/acme/web/-/pipelines/1203", + duration: 754, + queuedDuration: 3.2, + createdAt: "2026-10-27T09:00:00Z", + finishedAt: "2026-10-27T09:13:00Z", + source: "push", + user: { username: "ada", name: "Ada Lovelace", avatarUrl: null }, + commit: { title: "Bump Vue to 3.5", shortId: "1f2e3d4c" }, + stages: { + nodes: [{ name: "build", status: "success" }, { name: "test", status: "failed" }, { + name: "deploy", + status: "skipped", + }], + }, +}; + +Deno.test("formatting", () => { + const now = new Date("2026-10-27T12:00:00Z"); + assertEquals([ago("2026-10-27T09:00:00Z", now), ago("2026-10-26T09:00:00Z", now), ago("2026-10-22T09:00:00Z", now)], [ + "today", + "yesterday", + "5d", + ]); + assertEquals([duration(42), duration(754), duration(3720), duration(null)], ["42s", "12m 34s", "1h 2m", ""]); + assertEquals([colorOf("#1f75cb"), colorOf("#dc143c"), colorOf("#108548"), colorOf("#808080")], [ + "blue", + "red", + "green", + "gray", + ]); + assertEquals(avatar("/uploads/a.png", "Ada"), "https://gitlab.com/uploads/a.png"); + assertEquals(avatar(null, "Ada"), "Ada"); + assertEquals(gidNumber("gid://gitlab/Ci::Pipeline/1203"), 1203); + assertEquals( + branchName({ iid: 42, title: "Login page: blank screen on Firefox!" }), + "42-login-page-blank-screen-on-firefox", + ); +}); + +Deno.test("pipeline statuses", () => { + assertEquals(pipelineStatus("SUCCESS")?.color, "green"); + assertEquals(pipelineStatus("failed")?.icon, "bs-x"); + assertEquals(pipelineStatus("RUNNING")?.text, "Running"); + assertEquals(pipelineStatus("waiting_for_resource")?.text, "Pending"); + assertEquals(pipelineStatus("weird"), undefined); +}); + +Deno.test("merge request states, approvals and merge status", () => { + assertEquals(mrIcon(mr), { source: "bs-pr-open", tint: "green" }); + assertEquals(mrIcon({ ...mr, draft: true }).source, "bs-pr-draft"); + assertEquals(mrIcon({ ...mr, state: "merged" }).source, "bs-pr-merged"); + assertEquals(approvals(mr), { text: "1 of 2 approvals", color: "orange" }); + assertEquals(approvals({ ...mr, approvalsLeft: 0 })?.text, "Approved"); + // Free tier: no rule, approvals still count. + assertEquals(approvals({ ...mr, approvalsRequired: 0, approvalsLeft: null })?.text, "Approved"); + assertEquals(approvals({ ...mr, approvalsRequired: 0, approvedBy: { nodes: [] } }), undefined); + assertEquals(mergeStatus(mr)?.text, "Waiting for approvals"); + assertEquals(mergeStatus({ ...mr, detailedMergeStatus: null, conflicts: true })?.color, "red"); +}); + +Deno.test("merge request item and panel", () => { + const item = mrItem(mr, ctx); + assertEquals(item.subtitle, "!128"); + assertEquals(item.section, "acme/platform/api"); + assertEquals(item.accessories?.[0], { icon: { source: "bs-check", tint: "green" }, tooltip: "Pipeline passed" }); + assertEquals(item.accessories?.[1], { tag: "1 of 2 approvals", color: "orange" }); + assertEquals(item.accessories?.[2], { tag: "backend", color: "blue" }); + assertEquals(item.accessories?.[4], { + avatar: "https://gitlab.com/uploads/-/system/user/avatar/1/avatar.png", + tooltip: "Ada Lovelace", + }); + assertEquals(item.detail?.markdown, "Avoids a round trip per pull."); + assertEquals(item.detail?.stats?.map((s) => s.value), [4, "+86", "−12", 4]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 5), [ + "Open in Browser", + "Show Details", + "Open Changes", + "Open Pipeline", + "Approve", + ]); + assertEquals(mrMetadata(mr).map((m) => m.label).slice(0, 8), [ + "Status", + "Author", + "Branch", + "Pipeline", + "Approvals", + "Approved by", + "Reviewers", + "Merge", + ]); +}); + +Deno.test("issue item and panel", () => { + const item = issueItem(issue, ctx); + assertEquals(item.section, "acme/web"); + assertEquals(item.icon, { source: "bs-issue-open", tint: "green" }); + assertEquals(item.accessories?.slice(0, 2), [{ tag: "frontend", color: "green" }, { + tag: "priority::1", + color: "gray", + }]); + assertEquals(item.accessories?.[2].color, "red"); // overdue + assertEquals(item.accessories?.[3].text, "1/4"); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), ["Open in Browser", "Show Details", "Close Issue"]); + const closeAction = item.actions?.[2]; + assertEquals([closeAction?.destructive, closeAction?.confirm], [true, "Close acme/web#42?"]); + const labels = issueMetadata(issue).map((m) => m.label); + assertEquals(labels.slice(0, 7), ["Status", "Author", "Assignee", "Labels", "Milestone", "Due", "Weight"]); +}); + +Deno.test("project item", () => { + const item = projectItem(project, ctx, { pipeline: "running" }); + assertEquals(item.subtitle, "acme"); + assertEquals(item.accessories?.[0].tooltip, "Last pipeline running"); + assertEquals(item.accessories?.[2], { icon: "bs-star", text: "1.2k", tooltip: "Stars" }); + assertEquals(item.actions?.[1].title, "Show Pipelines"); +}); + +Deno.test("pipeline item: stages, duration, retry", () => { + const item = pipelineItem(pipeline, project, ctx); + assertEquals(item.title, "Bump Vue to 3.5"); + assertEquals(item.subtitle, "#912 · main"); + assertEquals(item.icon, { source: "bs-x", tint: "red" }); + assertEquals(item.accessories?.slice(0, 3).map((a) => a.tooltip), [ + "build: Passed", + "test: Failed", + "deploy: Skipped", + ]); + assertEquals(item.accessories?.[3], { icon: "bs-timer", text: "12m 34s", tooltip: "Duration" }); + assertEquals(item.detail?.stats?.map((s) => s.value), ["12m 34s", "3s", 3]); + assertEquals(stagesMarkdown(pipeline), "- **build**: Passed\n- **test**: Failed\n- **deploy**: Skipped"); + const titles = item.actions?.map((a) => a.title); + assertEquals(titles?.slice(0, 2), ["Open in Browser", "Retry Pipeline"]); + assertEquals(titles?.includes("Cancel Pipeline"), false); + const running = pipelineItem({ ...pipeline, status: "RUNNING" }, project, ctx); + assertEquals(running.actions?.[1].title, "Cancel Pipeline"); +}); + +Deno.test("a rejected token becomes a setup row with a link", async () => { + const items = await guarded(() => Promise.reject(new AuthError("GitLab rejected the access token"))); + assertEquals(items.length, 1); + assertEquals(items[0].id, "setup"); + assertEquals(items[0].actions?.[0].title, "Create Access Token"); + assertEquals(countStatus(items, "pipeline"), "Not connected"); + assertEquals(countStatus(setupItems(new AuthError("x")).map((i) => ({ ...i, id: "mr" })), "pipeline"), "1 pipeline"); +}); diff --git a/extensions/home-assistant/api.ts b/extensions/home-assistant/api.ts new file mode 100644 index 0000000..7ff681a --- /dev/null +++ b/extensions/home-assistant/api.ts @@ -0,0 +1,160 @@ +// Home Assistant REST API with a long-lived access token, on the user's own server. + +import { Icons, type Item, open } from "@backslash/api"; + +/** + * Where Home Assistant usually lives, as declared in `[permissions] net` + * (kept equal by a test). Extensions declare their hosts statically, so + * instead of any website this is the local network (mDNS names, private + * IPv4 ranges), Tailscale, Nabu Casa and DuckDNS. + */ +export const HOSTS = [ + "localhost", + "127.0.0.1", + "homeassistant", + "homeassistant.local", + "*.local", + "*.lan", + "*.home.arpa", + "*.ui.nabu.casa", + "*.duckdns.org", + "*.ts.net", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", +]; + +export class AuthError extends Error { + constructor(message: string, readonly base?: string, readonly kind: "token" | "url" | "host" = "token") { + super(message); + } +} + +function ipv4(host: string): number | undefined { + const parts = host.split("."); + if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p) || Number(p) > 255)) return undefined; + return parts.reduce((n, p) => n * 256 + Number(p), 0); +} + +/** Whether Deno's `--allow-net` with {@link HOSTS} lets us reach `host`. */ +export function isAllowedHost(host: string, patterns = HOSTS): boolean { + const h = host.toLowerCase().replace(/^\[|\]$/g, ""); + return patterns.some((p) => { + if (p.startsWith("*.")) return h.endsWith(p.slice(1)) || h === p.slice(2); + const cidr = /^([\d.]+)\/(\d+)$/.exec(p); + if (cidr) { + const ip = ipv4(h), net = ipv4(cidr[1]); + if (ip === undefined || net === undefined) return false; + const bits = Number(cidr[2]); + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return ((ip & mask) >>> 0) === ((net & mask) >>> 0); + } + return h === p; + }); +} + +/** `homeassistant.local:8123/` → `http://homeassistant.local:8123`. */ +export function normalizeUrl(input: unknown): string { + let url = String(input ?? "").trim(); + if (!url) return ""; + if (!/^https?:\/\//i.test(url)) url = `http://${url}`; + return url.replace(/\/+$/, "").replace(/\/api$/, "").replace(/\/lovelace.*$|\/config.*$|\/profile.*$/, ""); +} + +export interface Server { + base: string; + token: string; +} + +export function server(preferences: Record): Server { + const base = normalizeUrl(preferences.url); + const token = String(preferences.token ?? "").trim(); + if (!base) throw new AuthError("Add the address of your Home Assistant", undefined, "url"); + let host: string; + try { + host = new URL(base).hostname; + } catch { + throw new AuthError(`“${preferences.url}” is not an address`, undefined, "url"); + } + if (!isAllowedHost(host)) { + throw new AuthError(`${host} is outside the networks this extension may reach`, base, "host"); + } + if (!token) throw new AuthError("Add a long-lived access token", base); + return { base, token }; +} + +export const tokenUrl = (base: string) => `${base}/profile/security`; + +export async function ha( + s: Server, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${s.base}/api${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { Authorization: `Bearer ${s.token}`, "Content-Type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (e) { + if (options.signal?.aborted) throw e; + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new AuthError(`${new URL(s.base).host} is outside the networks this extension may reach`, s.base, "host"); + } + throw new Error(`Can't reach Home Assistant at ${s.base}: ${e instanceof Error ? e.message : e}`); + } + if (res.status === 401) throw new AuthError("Home Assistant rejected the access token", s.base); + if (!res.ok) { + const text = (await res.text()).slice(0, 200); + throw new Error(`Home Assistant answered ${res.status}${text ? `: ${text}` : ""}`); + } + return await res.json() as T; +} + +export function setupItems(error: AuthError): Item[] { + const base = error.base; + const steps = error.kind === "host" + ? [ + "## Address not reachable from Backslash", + "", + "Extensions declare the hosts they may reach. This one covers the usual places of a Home Assistant:", + "", + "- local names: `homeassistant.local`, `*.local`, `*.lan`, `*.home.arpa`, `localhost`", + "- private networks: `192.168.x.x`, `10.x.x.x`, `172.16–31.x.x`, Tailscale `100.64.x.x` and `*.ts.net`", + "- remote access: Nabu Casa (`*.ui.nabu.casa`) and DuckDNS (`*.duckdns.org`)", + "", + "Use one of those addresses (the local IP of the server works at home) in **Settings → Extensions → Home Assistant → Configure**.", + ] + : [ + "## Connect Home Assistant", + "", + "1. In **Settings → Extensions → Home Assistant → Configure**, set the address you open Home Assistant with,", + " e.g. `http://homeassistant.local:8123` or `https://xxxx.ui.nabu.casa`.", + "2. In Home Assistant, open your **profile → Security → Long-lived access tokens → Create token**", + ` ${base ? "(Enter opens it)" : ""}, and paste the token in the same settings.`, + ]; + return [{ + id: "setup", + title: error.message, + subtitle: error.kind === "host" + ? "Use a local, Tailscale or Nabu Casa address" + : "Address and long-lived access token", + icon: { source: error.kind === "host" ? Icons.globe : Icons.key, tint: "orange" }, + detail: { markdown: steps.join("\n") }, + actions: base && error.kind === "token" + ? [open(tokenUrl(base), "Create Access Token", { icon: Icons.key })] + : [open("https://www.home-assistant.io/docs/authentication/#your-account-profile", "How to Create a Token")], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} diff --git a/extensions/home-assistant/backslash.toml b/extensions/home-assistant/backslash.toml new file mode 100644 index 0000000..e53875a --- /dev/null +++ b/extensions/home-assistant/backslash.toml @@ -0,0 +1,52 @@ +[extension] +id = "home-assistant" +title = "Home Assistant" +description = "Lights, switches, covers, climate, media players, scenes, scripts and sensors of your Home Assistant: toggle, dim, set the temperature, run scenes" +version = "1.0.0" +author = "Backslash" +icon = "bs-home" +license = "MIT" + +# Home Assistant runs on your own server, and hosts must be declared ahead: +# the local network (mDNS names and private IPv4 ranges, which Deno's +# --allow-net accepts as CIDR), Tailscale, and the usual remote access +# (Nabu Casa, DuckDNS). A custom domain outside these is not reachable. +# Same list as HOSTS in api.ts. +[permissions] +net = [ + "localhost", + "127.0.0.1", + "homeassistant", + "homeassistant.local", + "*.local", + "*.lan", + "*.home.arpa", + "*.ui.nabu.casa", + "*.duckdns.org", + "*.ts.net", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", +] + +[[commands]] +name = "entities" +title = "Home Assistant" +description = "Controls, sensors or all entities, by domain" +icon = "bs-home" +keywords = ["home assistant", "hass", "smart home", "lights", "switch", "thermostat", "scene", "sensor", "domotique", "maison"] + +[[preferences]] +name = "url" +title = "Home Assistant address" +description = "As you open it in the browser: http://homeassistant.local:8123, http://192.168.1.20:8123 or https://xxxx.ui.nabu.casa" +default = "http://homeassistant.local:8123" +required = true + +[[preferences]] +name = "token" +title = "Long-lived access token" +description = "Home Assistant → your profile → Security → Long-lived access tokens → Create token" +type = "secret" +required = true diff --git a/extensions/home-assistant/commands/entities.ts b/extensions/home-assistant/commands/entities.ts new file mode 100644 index 0000000..2f9ae04 --- /dev/null +++ b/extensions/home-assistant/commands/entities.ts @@ -0,0 +1,40 @@ +import { type Context, list, updateCommandMetadata } from "@backslash/api"; +import { guarded, ha, server } from "../api.ts"; +import { type Entity, entityItem, lightsOn, matchesFilter, sortEntities } from "../lib.ts"; + +/** The list follows the house while it is open. */ +const POLL_MS = 10_000; + +export default (ctx: Context) => + list({ + placeholder: "Filter entities by name, room, entity id, state…", + filter: { + options: [ + { value: "controls", title: "Controls" }, + { value: "sensors", title: "Sensors" }, + { value: "all", title: "All" }, + ], + }, + empty: { title: "No entity here", icon: "bs-home" }, + status: (items) => + items[0]?.id === "setup" ? "Not connected" : `${items.length} entit${items.length === 1 ? "y" : "ies"}`, + async *items({ preferences, signal, filter }) { + while (!signal.aborted) { + const items = await guarded(async () => { + const s = server(preferences); + const all = await ha(s, "/states", { signal }); + updateCommandMetadata({ subtitle: lightsOn(all) }).catch(() => {}); + return sortEntities(all.filter((e) => matchesFilter(e, filter))).map((e) => entityItem(e, ctx, s)); + }); + yield items; + if (items[0]?.id === "setup") return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, POLL_MS); + signal.addEventListener("abort", () => { + clearTimeout(timer); + resolve(undefined); + }, { once: true }); + }); + } + }, + }); diff --git a/extensions/home-assistant/lib.ts b/extensions/home-assistant/lib.ts new file mode 100644 index 0000000..4786dc5 --- /dev/null +++ b/extensions/home-assistant/lib.ts @@ -0,0 +1,756 @@ +// Home Assistant entities as launcher items: state, icon, attributes, and the services to call. + +import { + type Accessory, + type Action, + action, + type Color, + type Context, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + type Stat, + submenu, + toast, +} from "@backslash/api"; +import { ha, type Server, server } from "./api.ts"; + +export interface Entity { + entity_id: string; + state: string; + attributes: Record; + last_changed: string; + last_updated: string; +} + +export const domainOf = (e: Pick) => e.entity_id.split(".")[0]; +export const nameOf = (e: Entity) => String(e.attributes.friendly_name ?? e.entity_id); +/** Scenes and buttons are "unknown" until first used: that is not a problem. */ +const STATELESS = ["scene", "button", "input_button"]; +const unavailable = (e: Entity) => + e.state === "unavailable" || (e.state === "unknown" && !STATELESS.includes(domainOf(e))); + +// --------------------------------------------------------------------------- +// Domains + +/** Sections, in this order; `kind` decides the filter they appear in. */ +export const DOMAINS: Record = { + light: { title: "Lights", kind: "control" }, + switch: { title: "Switches", kind: "control" }, + input_boolean: { title: "Toggles", kind: "control" }, + fan: { title: "Fans", kind: "control" }, + cover: { title: "Covers", kind: "control" }, + climate: { title: "Climate", kind: "control" }, + media_player: { title: "Media Players", kind: "control" }, + lock: { title: "Locks", kind: "control" }, + vacuum: { title: "Vacuums", kind: "control" }, + scene: { title: "Scenes", kind: "control" }, + script: { title: "Scripts", kind: "control" }, + automation: { title: "Automations", kind: "control" }, + button: { title: "Buttons", kind: "control" }, + input_button: { title: "Buttons", kind: "control" }, + sensor: { title: "Sensors", kind: "sensor" }, + binary_sensor: { title: "Binary Sensors", kind: "sensor" }, + weather: { title: "Weather", kind: "sensor" }, + person: { title: "People", kind: "other" }, + device_tracker: { title: "Devices", kind: "other" }, +}; + +const ORDER = Object.keys(DOMAINS); + +export function sectionOf(e: Entity): string { + const domain = domainOf(e); + return DOMAINS[domain]?.title ?? domain.split("_").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "); +} + +export function matchesFilter(e: Entity, filter: string | undefined): boolean { + const kind = DOMAINS[domainOf(e)]?.kind ?? "other"; + if (filter === "sensors") return kind === "sensor"; + if (filter === "all") return true; + return kind === "control"; +} + +/** By section, then by name. */ +export function sortEntities(entities: Entity[]): Entity[] { + const rank = (e: Entity) => { + const i = ORDER.indexOf(domainOf(e)); + return i === -1 ? ORDER.length : i; + }; + return [...entities].sort((a, b) => + rank(a) - rank(b) || sectionOf(a).localeCompare(sectionOf(b)) || nameOf(a).localeCompare(nameOf(b)) + ); +} + +// --------------------------------------------------------------------------- +// Looks + +const brightnessPct = (e: Entity) => + typeof e.attributes.brightness === "number" ? Math.round(e.attributes.brightness / 255 * 100) : undefined; + +const unit = (e: Entity) => String(e.attributes.unit_of_measurement ?? ""); + +function withUnit(value: string | number, u: string): string { + if (!u) return String(value); + return u === "%" || u.startsWith("°") ? `${value}${u === "%" ? "%" : ` ${u}`}` : `${value} ${u}`; +} + +const BINARY: Record = { + door: ["Open", "Closed"], + window: ["Open", "Closed"], + garage_door: ["Open", "Closed"], + opening: ["Open", "Closed"], + motion: ["Detected", "Clear"], + occupancy: ["Detected", "Clear"], + presence: ["Home", "Away"], + moisture: ["Wet", "Dry"], + smoke: ["Detected", "Clear"], + gas: ["Detected", "Clear"], + carbon_monoxide: ["Detected", "Clear"], + safety: ["Unsafe", "Safe"], + problem: ["Problem", "OK"], + battery: ["Low", "Normal"], + connectivity: ["Connected", "Disconnected"], + lock: ["Unlocked", "Locked"], + plug: ["Plugged in", "Unplugged"], + running: ["Running", "Not running"], + update: ["Update available", "Up to date"], +}; + +const capitalize = (s: string) => s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); + +/** "On · 70%", "21.5 °C", "Heating · 19.5 → 21 °", "Playing · Daft Punk – One More Time". */ +export function stateText(e: Entity): string { + const a = e.attributes; + if (e.state === "unavailable") return "Unavailable"; + if (e.state === "unknown" && !STATELESS.includes(domainOf(e))) return "Unknown"; + switch (domainOf(e)) { + case "light": { + const pct = brightnessPct(e); + return e.state === "on" && pct !== undefined ? `On · ${pct}%` : capitalize(e.state); + } + case "cover": + return typeof a.current_position === "number" && e.state === "open" + ? `Open · ${a.current_position}%` + : capitalize(e.state); + case "climate": { + const action = a.hvac_action ? capitalize(a.hvac_action) : capitalize(e.state); + if (e.state === "off") return "Off"; + const now = a.current_temperature != null ? `${a.current_temperature}°` : ""; + const target = a.temperature != null + ? `${a.temperature}°` + : a.target_temp_low != null + ? `${a.target_temp_low}–${a.target_temp_high}°` + : ""; + return [action, [now, target].filter(Boolean).join(" → ")].filter(Boolean).join(" · "); + } + case "media_player": { + const what = [a.media_artist, a.media_title].filter(Boolean).join(" – "); + return what && ["playing", "paused"].includes(e.state) ? `${capitalize(e.state)} · ${what}` : capitalize(e.state); + } + case "sensor": { + const n = Number(e.state); + const value = Number.isFinite(n) && e.state.trim() !== "" + ? String(Math.round(n * 100) / 100) + : a.device_class === "timestamp" + ? new Date(e.state).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }) + : e.state; + return withUnit(value, unit(e)); + } + case "binary_sensor": { + const words = BINARY[a.device_class ?? ""] ?? ["On", "Off"]; + return e.state === "on" ? words[0] : words[1]; + } + case "scene": + return "Scene"; + case "script": + return e.state === "on" ? "Running" : "Script"; + case "automation": + return e.state === "on" ? "Enabled" : "Disabled"; + case "button": + case "input_button": + return "Button"; + case "person": + case "device_tracker": + return e.state === "home" ? "Home" : e.state === "not_home" ? "Away" : capitalize(e.state); + case "weather": + return [ + capitalize(e.state.replace("partlycloudy", "partly cloudy").replace("clear-night", "clear")), + a.temperature != null ? `${a.temperature}${a.temperature_unit ?? "°"}` : "", + ].filter(Boolean).join(" · "); + default: + return withUnit(capitalize(e.state), unit(e)); + } +} + +type Look = { source: string; tint: Color }; + +function sensorIcon(e: Entity): Look { + const n = Number(e.state); + switch (e.attributes.device_class) { + case "temperature": + return { source: Icons.thermometer, tint: "orange" }; + case "humidity": + case "moisture": + return { source: Icons.droplet, tint: "blue" }; + case "battery": + return { source: Icons.battery, tint: n <= 20 ? "red" : n <= 50 ? "orange" : "green" }; + case "power": + case "energy": + case "current": + case "voltage": + case "apparent_power": + return { source: Icons.bolt, tint: "yellow" }; + case "illuminance": + return { source: Icons.sun, tint: "yellow" }; + case "carbon_dioxide": + case "pm25": + case "pm10": + case "aqi": + case "volatile_organic_compounds": + return { source: Icons.wind, tint: "teal" }; + case "timestamp": + case "duration": + return { source: Icons.clock, tint: "gray" }; + case "signal_strength": + return { source: Icons.wifi, tint: "gray" }; + default: + return { source: Icons.activity, tint: "gray" }; + } +} + +function binaryIcon(e: Entity): Look { + const on = e.state === "on"; + switch (e.attributes.device_class) { + case "door": + case "window": + case "garage_door": + case "opening": + return { source: Icons.window, tint: on ? "orange" : "gray" }; + case "motion": + case "occupancy": + case "presence": + return { source: on ? Icons.eye : Icons.eyeOff, tint: on ? "orange" : "gray" }; + case "moisture": + return { source: Icons.droplet, tint: on ? "blue" : "gray" }; + case "smoke": + case "gas": + case "carbon_monoxide": + case "safety": + case "problem": + return { source: Icons.alert, tint: on ? "red" : "green" }; + case "connectivity": + return { source: on ? Icons.wifi : Icons.wifiOff, tint: on ? "green" : "red" }; + case "battery": + return { source: Icons.battery, tint: on ? "red" : "green" }; + case "plug": + case "power": + return { source: Icons.plug, tint: on ? "green" : "gray" }; + case "lock": + return { source: Icons.lock, tint: on ? "orange" : "green" }; + default: + return { source: Icons.dot, tint: on ? "green" : "gray" }; + } +} + +const WEATHER: Record = { + sunny: Icons.sun, + "clear-night": Icons.moon, + partlycloudy: Icons.cloudSun, + cloudy: Icons.cloud, + rainy: Icons.cloudRain, + pouring: Icons.cloudRain, + lightning: Icons.bolt, + "lightning-rainy": Icons.bolt, + snowy: Icons.snowflake, + "snowy-rainy": Icons.snowflake, + windy: Icons.wind, + "windy-variant": Icons.wind, + fog: Icons.cloud, +}; + +export function entityIcon(e: Entity): Look { + const on = e.state === "on"; + const look = ((): Look => { + switch (domainOf(e)) { + case "light": + return { source: Icons.lightbulb, tint: on ? "yellow" : "gray" }; + case "switch": + case "input_boolean": + return { source: Icons.power, tint: on ? "green" : "gray" }; + case "fan": + return { source: Icons.wind, tint: on ? "teal" : "gray" }; + case "cover": + return e.state === "opening" + ? { source: Icons.arrowUp, tint: "teal" } + : e.state === "closing" + ? { source: Icons.arrowDown, tint: "teal" } + : { source: Icons.window, tint: e.state === "open" ? "teal" : "gray" }; + case "climate": { + const action = e.attributes.hvac_action ?? e.state; + const tint: Color = /heat/.test(action) + ? "orange" + : /cool/.test(action) + ? "blue" + : action === "off" + ? "gray" + : "teal"; + return { source: Icons.thermometer, tint }; + } + case "media_player": { + const tint: Color = e.state === "playing" ? "green" : e.state === "paused" ? "yellow" : "gray"; + const source = e.attributes.device_class === "tv" + ? Icons.monitor + : e.attributes.device_class === "speaker" + ? Icons.speaker + : Icons.music; + return { source, tint }; + } + case "lock": + return { + source: Icons.lock, + tint: e.state === "locked" ? "green" : e.state === "jammed" ? "red" : "orange", + }; + case "vacuum": + return { source: Icons.cycle, tint: e.state === "cleaning" ? "green" : e.state === "error" ? "red" : "gray" }; + case "scene": + return { source: Icons.sparkle, tint: "purple" }; + case "script": + return { source: Icons.terminal, tint: on ? "orange" : "blue" }; + case "automation": + return { source: Icons.bolt, tint: on ? "yellow" : "gray" }; + case "button": + case "input_button": + return { source: Icons.dottedCircle, tint: "blue" }; + case "sensor": + return sensorIcon(e); + case "binary_sensor": + return binaryIcon(e); + case "weather": + return { source: WEATHER[e.state] ?? Icons.cloudSun, tint: "blue" }; + case "person": + case "device_tracker": + return { source: Icons.person, tint: e.state === "home" ? "green" : "gray" }; + case "sun": + return { source: e.state === "above_horizon" ? Icons.sun : Icons.moon, tint: "yellow" }; + case "update": + return { source: Icons.download, tint: on ? "orange" : "gray" }; + case "camera": + return { source: Icons.camera, tint: "gray" }; + case "zone": + return { source: Icons.pin, tint: "gray" }; + default: + return { source: Icons.dot, tint: on ? "green" : "gray" }; + } + })(); + return unavailable(e) ? { ...look, tint: "gray" } : look; +} + +// --------------------------------------------------------------------------- +// Detail panel + +const SKIP = new Set([ + "friendly_name", + "icon", + "entity_picture", + "supported_features", + "supported_color_modes", + "attribution", + "restored", + "editable", + "id", + "context_id", + "entity_id", + "unit_of_measurement", + "device_class", + "state_class", + "hs_color", + "rgb_color", + "xy_color", + "rgbw_color", + "rgbww_color", + "min_mireds", + "max_mireds", + "color_temp", + "forecast", + "media_content_id", + "media_position_updated_at", + "user_id", + "device_trackers", +]); + +function attributeValue(key: string, v: unknown): string | undefined { + if (v == null || v === "") return undefined; + if (Array.isArray(v)) return v.length && v.length <= 12 ? v.map(String).join(", ") : undefined; + if (typeof v === "object") return undefined; + if (key === "brightness" && typeof v === "number") return `${Math.round(v / 255 * 100)}%`; + if (key === "volume_level" && typeof v === "number") return `${Math.round(v * 100)}%`; + if (key === "color_temp_kelvin") return `${v} K`; + if (typeof v === "boolean") return v ? "Yes" : "No"; + if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v)) { + return new Date(v).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }); + } + return String(v); +} + +/** The attributes worth reading, as metadata lines. */ +export function attributeLines(e: Entity): Metadata[] { + return Object.entries(e.attributes) + .filter(([k]) => !SKIP.has(k)) + .map(([k, v]) => [k, attributeValue(k, v)] as const) + .filter(([, v]) => v !== undefined) + .slice(0, 16) + .map(([k, v]) => meta.text(capitalize(k), v!)); +} + +export function entityStats(e: Entity): Stat[] { + const a = e.attributes; + switch (domainOf(e)) { + case "light": { + const pct = brightnessPct(e); + return [ + { label: "state", value: capitalize(e.state), color: e.state === "on" ? "yellow" : undefined }, + ...(pct !== undefined && e.state === "on" ? [{ label: "brightness", value: `${pct}%` }] : []), + ...(a.color_temp_kelvin ? [{ label: "white", value: `${a.color_temp_kelvin} K` }] : []), + ]; + } + case "climate": + return [ + ...(a.current_temperature != null ? [{ label: "now", value: `${a.current_temperature}°` }] : []), + ...(a.temperature != null ? [{ label: "target", value: `${a.temperature}°`, color: "orange" as Color }] : []), + ...(a.current_humidity != null ? [{ label: "humidity", value: `${a.current_humidity}%` }] : []), + { label: "mode", value: capitalize(e.state) }, + ]; + case "cover": + return [ + { label: "state", value: capitalize(e.state) }, + ...(typeof a.current_position === "number" ? [{ label: "position", value: `${a.current_position}%` }] : []), + ]; + case "media_player": + return [ + { label: "state", value: capitalize(e.state), color: e.state === "playing" ? "green" : undefined }, + ...(typeof a.volume_level === "number" + ? [{ label: "volume", value: `${Math.round(a.volume_level * 100)}%` }] + : []), + ]; + case "sensor": + return [{ label: (a.device_class ? capitalize(a.device_class) : "value").toLowerCase(), value: stateText(e) }]; + default: + return []; + } +} + +/** Since when, in words: "5 min ago", "3 h ago", "2 days ago". */ +export function changedAgo(iso: string, now = new Date()): string { + const s = Math.max(0, (now.getTime() - new Date(iso).getTime()) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86_400) return `${Math.floor(s / 3600)} h ago`; + const days = Math.floor(s / 86_400); + return `${days} day${days > 1 ? "s" : ""} ago`; +} + +export function entityMetadata(e: Entity): Metadata[] { + const look = entityIcon(e); + return [ + meta.text("State", stateText(e), { icon: look.source, color: look.tint }), + meta.text("Entity", e.entity_id, { icon: Icons.hash }), + ...(e.attributes.device_class ? [meta.text("Class", capitalize(e.attributes.device_class))] : []), + ...attributeLines(e).length ? [meta.separator(), ...attributeLines(e)] : [], + meta.separator(), + meta.text("Changed", changedAgo(e.last_changed), { icon: Icons.clock }), + ]; +} + +/** Artwork of what a media player plays (a path on the server, signed by a token in the URL). */ +export function pictureOf(e: Entity, base: string): string | undefined { + const pic = e.attributes.entity_picture; + if (typeof pic !== "string" || domainOf(e) !== "media_player") return undefined; + return pic.startsWith("/") ? `${base}${pic}` : pic; +} + +// --------------------------------------------------------------------------- +// Services + +export async function callService( + s: Server, + domain: string, + service: string, + data: Record, +): Promise { + await ha(s, `/services/${domain}/${service}`, { method: "POST", body: data }); +} + +/** Feature bits of covers and media players. */ +const COVER = { OPEN: 1, CLOSE: 2, SET_POSITION: 4, STOP: 8 }; +const MEDIA = { PAUSE: 1, VOLUME_SET: 4, VOLUME_MUTE: 8, PREVIOUS: 16, NEXT: 32, TURN_ON: 128, TURN_OFF: 256 }; +const has = (e: Entity, bit: number) => ((e.attributes.supported_features ?? 0) & bit) !== 0; + +export const BRIGHTNESS = [10, 25, 50, 75, 100]; +export const WHITES = [{ title: "Warm", kelvin: 2700 }, { title: "Neutral", kelvin: 4000 }, { + title: "Cool", + kelvin: 6500, +}]; + +/** Targets around the current one, within the device's range. */ +export function temperatureSteps(e: Entity): number[] { + const a = e.attributes; + const step = Number(a.target_temp_step ?? 0.5) || 0.5; + const current = Number(a.temperature ?? a.current_temperature ?? 20); + const min = Number(a.min_temp ?? 7), max = Number(a.max_temp ?? 35); + const round = (n: number) => Math.round(n / step) * step; + const values = [-2, -1, -step, step, 1, 2].map((d) => round(current + d)).filter((t) => t >= min && t <= max); + return [...new Set(values)].sort((x, y) => x - y); +} + +export function entityActions(e: Entity, ctx: Context, s: Server = server(ctx.preferences)): Action[] { + const domain = domainOf(e); + const name = nameOf(e); + const id = e.entity_id; + const run = + (title: string, svcDomain: string, service: string, data: Record = {}, message = title) => + async () => { + await callService(s, svcDomain, service, { entity_id: id, ...data }); + await toast(`${name}: ${message}`, { style: "success" }); + return refresh(); + }; + const svc = ( + title: string, + service: string, + o: { + data?: Record; + icon?: string; + shortcut?: string; + confirm?: string; + message?: string; + destructive?: boolean; + } = {}, + ) => + action(title, run(title, domain, service, o.data, o.message), { + icon: o.icon, + shortcut: o.shortcut, + confirm: o.confirm, + destructive: o.destructive, + section: "Control", + }); + const actions: Action[] = []; + const toggles = () => { + actions.push( + svc("Toggle", "toggle", { icon: Icons.power, message: e.state === "on" ? "off" : "on" }), + svc("Turn On", "turn_on", { icon: Icons.power, shortcut: "Up", message: "on" }), + svc("Turn Off", "turn_off", { icon: Icons.power, shortcut: "Down", message: "off" }), + ); + }; + + if (!unavailable(e)) { + switch (domain) { + case "light": + toggles(); + actions.push(submenu( + "Brightness", + BRIGHTNESS.map((pct) => + action(`${pct}%`, run(`${pct}%`, "light", "turn_on", { brightness_pct: pct }, `${pct}%`), { + icon: Icons.lightbulb, + }) + ), + { icon: Icons.sun, shortcut: "b", section: "Control" }, + )); + if ((e.attributes.supported_color_modes ?? []).some((m: string) => /color_temp|hs|rgb|xy/.test(m))) { + actions.push(submenu( + "White", + WHITES.map((w) => + action( + w.title, + run(w.title, "light", "turn_on", { color_temp_kelvin: w.kelvin }, w.title.toLowerCase()), + { + icon: Icons.lightbulb, + }, + ) + ), + { icon: Icons.palette, section: "Control" }, + )); + } + break; + case "switch": + case "input_boolean": + case "fan": + case "humidifier": + case "siren": + toggles(); + break; + case "automation": + actions.push( + svc("Trigger", "trigger", { icon: Icons.play, message: "triggered" }), + svc(e.state === "on" ? "Disable" : "Enable", e.state === "on" ? "turn_off" : "turn_on", { + icon: Icons.power, + message: e.state === "on" ? "disabled" : "enabled", + }), + ); + break; + case "cover": + if (e.state !== "open" && has(e, COVER.OPEN)) actions.push(svc("Open", "open_cover", { icon: Icons.arrowUp })); + if (e.state !== "closed" && has(e, COVER.CLOSE)) { + actions.push(svc("Close", "close_cover", { icon: Icons.arrowDown })); + } + if (e.state === "open" && has(e, COVER.OPEN)) actions.push(svc("Open", "open_cover", { icon: Icons.arrowUp })); + if (has(e, COVER.STOP)) actions.push(svc("Stop", "stop_cover", { icon: Icons.stop })); + if (has(e, COVER.SET_POSITION)) { + actions.push(submenu( + "Position", + [0, 25, 50, 75, 100].map((p) => + action(`${p}%`, run(`${p}%`, "cover", "set_cover_position", { position: p }, `${p}%`), { + icon: Icons.window, + }) + ), + { icon: Icons.window, shortcut: "b", section: "Control" }, + )); + } + break; + case "climate": { + actions.push(submenu( + "Target Temperature", + temperatureSteps(e).map((t) => + action(`${t}°`, run(`${t}°`, "climate", "set_temperature", { temperature: t }, `${t}°`), { + icon: Icons.thermometer, + }) + ), + { icon: Icons.thermometer, shortcut: "t", section: "Control" }, + )); + const modes: string[] = e.attributes.hvac_modes ?? []; + if (modes.length) { + actions.push(submenu( + "Mode", + modes.map((m) => + action(capitalize(m), run(m, "climate", "set_hvac_mode", { hvac_mode: m }, capitalize(m)), { + icon: m === "off" ? Icons.power : Icons.thermometer, + }) + ), + { icon: Icons.settings, shortcut: "m", section: "Control" }, + )); + } + if (e.state !== "off" && modes.includes("off")) { + actions.push( + svc("Turn Off", "set_hvac_mode", { data: { hvac_mode: "off" }, icon: Icons.power, message: "off" }), + ); + } + break; + } + case "media_player": { + const off = e.state === "off" || e.state === "standby"; + if (!off) { + actions.push(svc(e.state === "playing" ? "Pause" : "Play", "media_play_pause", { + icon: e.state === "playing" ? Icons.pause : Icons.play, + message: e.state === "playing" ? "paused" : "playing", + })); + if (has(e, MEDIA.NEXT)) { + actions.push(svc("Next", "media_next_track", { icon: Icons.next, shortcut: "Right" })); + } + if (has(e, MEDIA.PREVIOUS)) { + actions.push(svc("Previous", "media_previous_track", { icon: Icons.previous, shortcut: "Left" })); + } + if (has(e, MEDIA.VOLUME_SET)) { + actions.push(submenu( + "Volume", + [0, 20, 40, 60, 80, 100].map((v) => + action(`${v}%`, run(`${v}%`, "media_player", "volume_set", { volume_level: v / 100 }, `volume ${v}%`), { + icon: Icons.speaker, + }) + ), + { icon: Icons.speaker, shortcut: "v", section: "Control" }, + )); + } + if (has(e, MEDIA.VOLUME_MUTE)) { + const muted = e.attributes.is_volume_muted === true; + actions.push(svc(muted ? "Unmute" : "Mute", "volume_mute", { + data: { is_volume_muted: !muted }, + icon: muted ? Icons.speaker : Icons.speakerOff, + message: muted ? "unmuted" : "muted", + })); + } + } + if (off && has(e, MEDIA.TURN_ON)) actions.push(svc("Turn On", "turn_on", { icon: Icons.power, message: "on" })); + if (!off && has(e, MEDIA.TURN_OFF)) { + actions.push(svc("Turn Off", "turn_off", { icon: Icons.power, message: "off" })); + } + break; + } + case "lock": + if (e.state === "locked") { + actions.push( + svc("Unlock", "unlock", { + icon: Icons.lock, + confirm: `Unlock ${name}?`, + destructive: true, + message: "unlocked", + }), + ); + } else actions.push(svc("Lock", "lock", { icon: Icons.lock, message: "locked" })); + break; + case "vacuum": + actions.push( + e.state === "cleaning" + ? svc("Pause", "pause", { icon: Icons.pause, message: "paused" }) + : svc("Start Cleaning", "start", { icon: Icons.play, message: "cleaning" }), + svc("Return to Dock", "return_to_base", { icon: Icons.home, message: "returning" }), + ); + break; + case "scene": + actions.push(svc("Activate", "turn_on", { icon: Icons.sparkle, message: "activated" })); + break; + case "script": + actions.push(svc("Run", "turn_on", { icon: Icons.play, message: "started" })); + if (e.state === "on") actions.push(svc("Stop", "turn_off", { icon: Icons.stop, message: "stopped" })); + break; + case "button": + case "input_button": + actions.push(svc("Press", "press", { icon: Icons.dottedCircle, message: "pressed" })); + break; + } + } + actions.push( + open(`${s.base}/history?entity_id=${encodeURIComponent(id)}`, "Open History", { + icon: Icons.history, + section: "Home Assistant", + }), + open(`${s.base}/config/entities?search=${encodeURIComponent(id)}`, "Open Entity Settings", { + icon: Icons.settings, + section: "Home Assistant", + }), + copy(id, "Copy Entity ID", { shortcut: "period", section: "Copy" }), + copy(stateText(e), "Copy State", { shortcut: "c", section: "Copy" }), + ); + return actions; +} + +export function entityItem(e: Entity, ctx: Context, s: Server = server(ctx.preferences)): Item { + const accessories: Accessory[] = []; + if (domainOf(e) === "automation" && e.attributes.last_triggered) { + accessories.push({ icon: Icons.bolt, text: changedAgo(e.attributes.last_triggered), tooltip: "Last triggered" }); + } else { + accessories.push({ text: changedAgo(e.last_changed), tooltip: "Last changed" }); + } + return { + id: e.entity_id, + title: nameOf(e), + subtitle: stateText(e), + icon: entityIcon(e), + section: sectionOf(e), + keywords: [e.entity_id, domainOf(e), e.attributes.device_class ?? "", e.state].filter(Boolean), + accessories, + detail: { + image: pictureOf(e, s.base), + stats: entityStats(e), + metadata: entityMetadata(e), + }, + actions: entityActions(e, ctx, s), + }; +} + +/** "3 lights on". */ +export function lightsOn(entities: Entity[]): string | null { + const n = entities.filter((e) => domainOf(e) === "light" && e.state === "on").length; + return n ? `${n} light${n === 1 ? "" : "s"} on` : null; +} diff --git a/extensions/home-assistant/lib_test.ts b/extensions/home-assistant/lib_test.ts new file mode 100644 index 0000000..636212e --- /dev/null +++ b/extensions/home-assistant/lib_test.ts @@ -0,0 +1,210 @@ +import { assertEquals } from "@std/assert"; +import { parse } from "@std/toml"; +import type { Context } from "@backslash/api"; +import { AuthError, guarded, HOSTS, isAllowedHost, normalizeUrl, server } from "./api.ts"; +import { + type Entity, + entityIcon, + entityItem, + entityStats, + lightsOn, + matchesFilter, + pictureOf, + sectionOf, + sortEntities, + stateText, + temperatureSteps, +} from "./lib.ts"; + +const ctx: Context = { + args: {}, + preferences: { url: "http://homeassistant.local:8123", token: "eyJ0" }, + query: "", + signal: new AbortController().signal, +}; + +const T = new Date(Date.now() - 5 * 60_000).toISOString(); +const entity = (entity_id: string, state: string, attributes: Record = {}): Entity => ({ + entity_id, + state, + attributes: { friendly_name: entity_id.split(".")[1].replace(/_/g, " "), ...attributes }, + last_changed: T, + last_updated: T, +}); + +// Shaped like `GET /api/states`. +const kitchen = entity("light.kitchen", "on", { + friendly_name: "Kitchen", + brightness: 179, + color_mode: "color_temp", + color_temp_kelvin: 3000, + supported_color_modes: ["color_temp"], + supported_features: 40, +}); +const thermostat = entity("climate.living_room", "heat", { + friendly_name: "Living Room", + hvac_modes: ["off", "heat", "auto"], + current_temperature: 19.5, + temperature: 21, + min_temp: 7, + max_temp: 30, + target_temp_step: 0.5, + hvac_action: "heating", +}); +const blinds = entity("cover.bedroom_blinds", "open", { + current_position: 40, + supported_features: 15, + device_class: "blind", +}); +const speaker = entity("media_player.living_room", "playing", { + friendly_name: "Living Room Speaker", + media_title: "One More Time", + media_artist: "Daft Punk", + volume_level: 0.35, + is_volume_muted: false, + entity_picture: "/api/media_player_proxy/media_player.living_room?token=abc&cache=1", + supported_features: 152_511, + device_class: "speaker", +}); +const temperature = entity("sensor.outdoor_temperature", "12.345", { + unit_of_measurement: "°C", + device_class: "temperature", + state_class: "measurement", +}); +const door = entity("binary_sensor.front_door", "on", { device_class: "door" }); +const scene = entity("scene.movie_night", "2026-10-27T20:00:00+00:00", { friendly_name: "Movie Night" }); + +Deno.test("hosts match the manifest and Deno's rules", async () => { + const manifest = parse(await Deno.readTextFile(new URL("./backslash.toml", import.meta.url))) as { + permissions: { net: string[] }; + }; + assertEquals(manifest.permissions.net, HOSTS); + const yes = [ + "homeassistant.local", + "localhost", + "192.168.1.20", + "10.0.0.7", + "172.20.3.4", + "100.101.102.103", + "ha.tail1234.ts.net", + "abc.ui.nabu.casa", + "home.duckdns.org", + "pi.lan", + ]; + const no = ["ha.example.com", "172.32.0.1", "8.8.8.8", "100.128.0.1", "evil.com"]; + assertEquals(yes.filter((h) => !isAllowedHost(h)), []); + assertEquals(no.filter((h) => isAllowedHost(h)), []); +}); + +Deno.test("addresses", () => { + assertEquals(normalizeUrl("homeassistant.local:8123/"), "http://homeassistant.local:8123"); + assertEquals(normalizeUrl("https://abc.ui.nabu.casa/lovelace/0"), "https://abc.ui.nabu.casa"); + assertEquals(normalizeUrl("http://192.168.1.20:8123/api"), "http://192.168.1.20:8123"); + assertEquals(server(ctx.preferences).base, "http://homeassistant.local:8123"); + let error: AuthError | undefined; + try { + server({ url: "https://ha.example.com", token: "t" }); + } catch (e) { + error = e as AuthError; + } + assertEquals(error?.kind, "host"); +}); + +Deno.test("states in words", () => { + assertEquals(stateText(kitchen), "On · 70%"); + assertEquals(stateText({ ...kitchen, state: "off" }), "Off"); + assertEquals(stateText(thermostat), "Heating · 19.5° → 21°"); + assertEquals(stateText(blinds), "Open · 40%"); + assertEquals(stateText(speaker), "Playing · Daft Punk – One More Time"); + assertEquals(stateText(temperature), "12.35 °C"); + assertEquals(stateText(entity("sensor.battery", "87", { unit_of_measurement: "%" })), "87%"); + assertEquals(stateText(door), "Open"); + assertEquals(stateText(entity("binary_sensor.hall_motion", "off", { device_class: "motion" })), "Clear"); + assertEquals(stateText(scene), "Scene"); + assertEquals(stateText(entity("switch.fan", "unavailable")), "Unavailable"); +}); + +Deno.test("icons", () => { + assertEquals(entityIcon(kitchen), { source: "bs-lightbulb", tint: "yellow" }); + assertEquals(entityIcon({ ...kitchen, state: "off" }).tint, "gray"); + assertEquals(entityIcon(thermostat), { source: "bs-thermometer", tint: "orange" }); + assertEquals(entityIcon(temperature).source, "bs-thermometer"); + assertEquals(entityIcon(entity("sensor.phone_battery", "15", { device_class: "battery" })), { + source: "bs-battery", + tint: "red", + }); + assertEquals(entityIcon(door), { source: "bs-window", tint: "orange" }); + assertEquals(entityIcon(scene), { source: "bs-sparkle", tint: "purple" }); + assertEquals(entityIcon(entity("light.porch", "unavailable")).tint, "gray"); +}); + +Deno.test("filters, sections and order", () => { + const all = [temperature, scene, door, speaker, blinds, thermostat, kitchen, entity("sun.sun", "above_horizon")]; + assertEquals(sortEntities(all.filter((e) => matchesFilter(e, "controls"))).map(sectionOf), [ + "Lights", + "Covers", + "Climate", + "Media Players", + "Scenes", + ]); + assertEquals(all.filter((e) => matchesFilter(e, "sensors")).map((e) => e.entity_id), [ + "sensor.outdoor_temperature", + "binary_sensor.front_door", + ]); + assertEquals(sortEntities(all).map(sectionOf).at(-1), "Sun"); + assertEquals(lightsOn(all), "1 light on"); + assertEquals(lightsOn([temperature]), null); +}); + +Deno.test("light: toggle first, brightness and white presets", () => { + const item = entityItem(kitchen, ctx); + const titles = item.actions?.map((a) => a.title); + assertEquals(titles?.slice(0, 5), ["Toggle", "Turn On", "Turn Off", "Brightness", "White"]); + const brightness = item.actions?.[3] as { actions: { title: string }[] }; + assertEquals(brightness.actions.map((a) => a.title), ["10%", "25%", "50%", "75%", "100%"]); + assertEquals(item.detail?.stats, [ + { label: "state", value: "On", color: "yellow" }, + { label: "brightness", value: "70%" }, + { label: "white", value: "3000 K" }, + ]); + const meta = item.detail?.metadata?.map((m) => [m.label, m.value]) ?? []; + assertEquals(meta.slice(0, 2), [["State", "On · 70%"], ["Entity", "light.kitchen"]]); + assertEquals(meta.some(([l, v]) => l === "Brightness" && v === "70%"), true); + assertEquals(meta.some(([l]) => l === "Supported color modes" || l === "Friendly name"), false); +}); + +Deno.test("other domains' actions", () => { + const first = (e: Entity) => entityItem(e, ctx).actions?.map((a) => a.title).slice(0, 4); + assertEquals(first(thermostat), ["Target Temperature", "Mode", "Turn Off", "Open History"]); + assertEquals(temperatureSteps(thermostat), [19, 20, 20.5, 21.5, 22, 23]); + assertEquals(first(blinds), ["Close", "Open", "Stop", "Position"]); + assertEquals(first(speaker), ["Pause", "Next", "Previous", "Volume"]); + assertEquals(first(scene)?.[0], "Activate"); + // Never activated yet. + assertEquals(first({ ...scene, state: "unknown" })?.[0], "Activate"); + assertEquals(stateText({ ...scene, state: "unknown" }), "Scene"); + assertEquals(first(entity("script.good_night", "off"))?.[0], "Run"); + const lock = entityItem(entity("lock.front_door", "locked"), ctx).actions?.[0]; + assertEquals([lock?.title, lock?.destructive, lock?.confirm], ["Unlock", true, "Unlock front door?"]); + assertEquals(first(temperature), ["Open History", "Open Entity Settings", "Copy Entity ID", "Copy State"]); + assertEquals(first(entity("light.porch", "unavailable"))?.[0], "Open History"); +}); + +Deno.test("media artwork from the server", () => { + assertEquals( + pictureOf(speaker, "http://homeassistant.local:8123"), + "http://homeassistant.local:8123/api/media_player_proxy/media_player.living_room?token=abc&cache=1", + ); + assertEquals(entityStats(speaker).map((s) => s.value), ["Playing", "35%"]); +}); + +Deno.test("a rejected token becomes a setup row with a link to the profile", async () => { + const items = await guarded(() => + Promise.reject(new AuthError("Home Assistant rejected the access token", "http://homeassistant.local:8123")) + ); + assertEquals(items[0].id, "setup"); + assertEquals( + (items[0].actions?.[0] as { target: string }).target, + "http://homeassistant.local:8123/profile/security", + ); +}); diff --git a/extensions/jira/api.ts b/extensions/jira/api.ts new file mode 100644 index 0000000..ee63811 --- /dev/null +++ b/extensions/jira/api.ts @@ -0,0 +1,138 @@ +// Jira Cloud REST v3: Basic auth with the account email and an API token. + +import { Icons, type Item, open, storage } from "@backslash/api"; + +export const TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"; + +/** Something to fix in the settings: shown as a row with a link instead of an error. */ +export class AuthError extends Error {} + +export interface Account { + /** `acme.atlassian.net` */ + site: string; + email: string; + token: string; +} + +/** + * `acme`, `acme.atlassian.net` or `https://acme.atlassian.net/jira/…` → `acme.atlassian.net`. + * Only Jira Cloud: the extension may reach `*.atlassian.net` and nothing else. + */ +export function normalizeSite(input: string): string { + let host = input.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split(/[/?#]/)[0]; + if (host && !host.includes(".")) host = `${host}.atlassian.net`; + return host; +} + +export function account(preferences: Record): Account { + const site = normalizeSite(String(preferences.site ?? "")); + const email = String(preferences.email ?? "").trim(); + const token = String(preferences.token ?? "").trim(); + if (!site || !email || !token) throw new AuthError("Add your Jira site, email and API token"); + if (!/^[a-z0-9-]+\.atlassian\.net$/.test(site)) { + throw new AuthError(`${site} is not a Jira Cloud site (*.atlassian.net)`); + } + return { site, email, token }; +} + +export const baseUrl = (a: Account) => `https://${a.site}`; +export const issueUrl = (a: Account, key: string) => `${baseUrl(a)}/browse/${key}`; + +/** Jira's errors: `{ errorMessages: [...], errors: { field: message } }`. */ +export function errorText(body: unknown): string { + const b = (body ?? {}) as { errorMessages?: string[]; errors?: Record; message?: string }; + const parts = [...(b.errorMessages ?? []), ...Object.entries(b.errors ?? {}).map(([k, v]) => `${k}: ${v}`)]; + if (b.message) parts.push(b.message); + return parts.join("; "); +} + +export async function jira( + a: Account, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${baseUrl(a)}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Basic ${btoa(`${a.email}:${a.token}`)}`, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (res.status === 401) throw new AuthError("Jira rejected the email or API token"); + if (res.status === 204) return undefined as T; + const body = await res.json().catch(() => undefined); + if (!res.ok) { + const message = errorText(body); + if (res.status === 403 && !message) throw new AuthError("Jira refused the API token (403)"); + throw new JiraError(res.status, message || `Jira answered ${res.status}`); + } + return body as T; +} + +export class JiraError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +/** The id of the Sprint custom field (usually customfield_10020), looked up once per site. */ +export async function sprintField(a: Account, signal?: AbortSignal): Promise { + const key = `sprintField:${a.site}`; + const saved = await storage.get(key).catch(() => undefined); + if (saved !== undefined) return saved || undefined; + const fields = await jira<{ id: string; schema?: { custom?: string } }[]>(a, "/rest/api/3/field", { signal }) + .catch(() => []); + const id = fields.find((f) => f.schema?.custom === "com.pyxis.greenhopper.jira:gh-sprint")?.id ?? ""; + await storage.set(key, id).catch(() => {}); + return id || undefined; +} + +export async function myAccountId(a: Account): Promise { + const key = `me:${a.site}:${a.email}`; + const saved = await storage.get(key).catch(() => undefined); + if (saved) return saved; + const me = await jira<{ accountId: string }>(a, "/rest/api/3/myself"); + await storage.set(key, me.accountId).catch(() => {}); + return me.accountId; +} + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Jira Cloud: site, account email and API token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Jira", + "", + "1. Create an API token at **id.atlassian.com → Security → API tokens** (Enter opens it).", + "2. In **Settings → Extensions → Jira → Configure**, set:", + " - **Site**: `your-team.atlassian.net`", + " - **Email**: the email of your Atlassian account", + " - **API token**: the token you just created", + "", + "Only Jira Cloud (`*.atlassian.net`) is supported, not Jira Server or Data Center.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create API Token", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} + +export function countStatus(items: Item[], one: string, many = `${one}s`): string { + if (items[0]?.id === "setup") return "Not connected"; + if (items[0]?.id === "error") return "Jira could not run this search"; + return `${items.length} ${items.length === 1 ? one : many}`; +} diff --git a/extensions/jira/backslash.toml b/extensions/jira/backslash.toml new file mode 100644 index 0000000..7e49a8e --- /dev/null +++ b/extensions/jira/backslash.toml @@ -0,0 +1,52 @@ +[extension] +id = "jira" +title = "Jira" +description = "Your Jira Cloud issues by status with priority, sprint and labels; change status, assign, search with text or JQL, create issues" +version = "1.0.0" +author = "Backslash" +icon = "bs-ticket" +license = "MIT" + +# Jira Cloud sites are `.atlassian.net`: a subdomain wildcard +# (Deno's --allow-net matches subdomains). Avatars come from Gravatar or +# Atlassian's avatar CDN; pictures need exact hosts. +[permissions] +net = ["*.atlassian.net", "secure.gravatar.com", "avatar-management--avatars.us-west-2.prod.public.atl-paas.net"] + +[[commands]] +name = "my-issues" +title = "My Jira Issues" +description = "Unresolved issues assigned to you, reported or watched, by status" +icon = "bs-ticket" +keywords = ["jira", "issues", "tickets", "tasks", "sprint", "atlassian", "todo"] + +[[commands]] +name = "search-issues" +title = "Search Jira Issues" +description = "Text, issue key or JQL" +icon = "bs-search" +keywords = ["jira", "jql", "find", "issues", "tickets", "atlassian"] + +[[commands]] +name = "create-issue" +title = "Create Jira Issue" +icon = "bs-plus" +keywords = ["jira", "new", "issue", "ticket", "bug", "task", "story", "atlassian"] + +[[preferences]] +name = "site" +title = "Jira site" +description = "Your Jira Cloud site, e.g. acme.atlassian.net" +required = true + +[[preferences]] +name = "email" +title = "Atlassian account email" +required = true + +[[preferences]] +name = "token" +title = "API token" +description = "id.atlassian.com → Security → Create and manage API tokens" +type = "secret" +required = true diff --git a/extensions/jira/commands/create-issue.ts b/extensions/jira/commands/create-issue.ts new file mode 100644 index 0000000..1f37d1a --- /dev/null +++ b/extensions/jira/commands/create-issue.ts @@ -0,0 +1,96 @@ +import { + action, + close, + type Context, + copy, + detail, + field, + form, + Icons, + meta, + open, + storage, + toast, +} from "@backslash/api"; +import { type Account, account, AuthError, issueUrl, jira, myAccountId, setupItems } from "../api.ts"; +import { textToAdf } from "../lib.ts"; + +interface Project { + id: string; + key: string; + name: string; +} + +interface IssueType { + id: string; + name: string; + subtask: boolean; +} + +export default async ({ preferences }: Context) => { + let a: Account; + let projects: Project[]; + let types: IssueType[]; + try { + a = account(preferences); + [projects, types] = await Promise.all([ + jira<{ values: Project[] }>(a, "/rest/api/3/project/search?maxResults=100").then((r) => r.values), + jira(a, "/rest/api/3/issuetype"), + ]); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const [setup] = setupItems(e); + return detail({ title: "Connect Jira", markdown: setup.detail!.markdown!, actions: setup.actions }); + } + if (!projects.length) throw new Error("No Jira project is visible with this account"); + const last = await storage.get("lastProject").catch(() => undefined); + // Issue types of every project, by name (team-managed projects each have their own). + const names = [...new Set(types.filter((t) => !t.subtask).map((t) => t.name))].sort(); + return form({ + title: "New Jira issue", + submitTitle: "Create Issue", + fields: [ + field.dropdown("project", "Project", projects.map((p) => ({ value: p.key, title: `${p.name} (${p.key})` })), { + value: projects.some((p) => p.key === last) ? last : projects[0].key, + }), + field.dropdown("type", "Type", names, { value: names.includes("Task") ? "Task" : names[0] }), + field.text("summary", "Summary", { required: true, placeholder: "What needs to be done?" }), + field.textArea("description", "Description", { placeholder: "Blank lines separate paragraphs" }), + field.checkbox("assignToMe", "Assign to me", { value: true }), + ], + async submit(values) { + const fields: Record = { + project: { key: values.project }, + issuetype: { name: values.type }, + summary: String(values.summary).trim(), + }; + const description = String(values.description ?? "").trim(); + if (description) fields.description = textToAdf(description); + if (values.assignToMe) fields.assignee = { accountId: await myAccountId(a) }; + const created = await jira<{ id: string; key: string }>(a, "/rest/api/3/issue", { + method: "POST", + body: { fields }, + }); + await storage.set("lastProject", String(values.project)).catch(() => {}); + const url = issueUrl(a, created.key); + await toast(`Created ${created.key}`, { style: "success" }); + return detail({ + title: created.key, + markdown: `# ${fields.summary}\n\nCreated in Jira as **${created.key}**.`, + metadata: [ + meta.text("Key", created.key, { icon: Icons.ticket }), + meta.text("Project", projects.find((p) => p.key === values.project)?.name ?? String(values.project)), + meta.text("Type", String(values.type)), + meta.link("Link", "Open in Jira", url), + ], + status: "Issue created", + actions: [ + open(url, "Open in Jira"), + copy(created.key, "Copy Key", { shortcut: "period" }), + copy(url, "Copy URL", { shortcut: "c" }), + action("Done", () => close()), + ], + }); + }, + }); +}; diff --git a/extensions/jira/commands/my-issues.ts b/extensions/jira/commands/my-issues.ts new file mode 100644 index 0000000..85b2a39 --- /dev/null +++ b/extensions/jira/commands/my-issues.ts @@ -0,0 +1,40 @@ +import { type Context, list, updateCommandMetadata } from "@backslash/api"; +import { account, countStatus, guarded, sprintField } from "../api.ts"; +import { categoryName, issueItem, live, searchIssues, sortIssues } from "../lib.ts"; + +const JQL: Record = { + assigned: "assignee = currentUser() AND resolution = Unresolved", + reported: "reporter = currentUser() AND resolution = Unresolved", + watching: "watcher = currentUser() AND resolution = Unresolved", +}; + +export default (ctx: Context) => + list({ + placeholder: "Filter issues by summary, key, status, project, sprint, label…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "reported", title: "Reported by me" }, + { value: "watching", title: "Watching" }, + ], + }, + empty: { title: "No unresolved issues here", icon: "bs-circle-check" }, + status: (items) => countStatus(items, "unresolved issue"), + // Reloads after a change made from this list or an issue page (status, assignee). + items: ({ preferences, signal, filter }) => + live(() => + guarded(async () => { + const a = account(preferences); + const key = filter && JQL[filter] ? filter : "assigned"; + const sprint = await sprintField(a, signal); + const issues = sortIssues( + await searchIssues(a, `${JQL[key]} ORDER BY updated DESC`, { sprintField: sprint, signal }), + ); + if (key === "assigned") { + const started = issues.filter((i) => i.fields.status.statusCategory.key === "indeterminate").length; + const subtitle = started ? `${started} in progress` : issues.length ? `${issues.length} to do` : null; + updateCommandMetadata({ subtitle }).catch(() => {}); + } + return issues.map((issue) => issueItem(issue, ctx, { section: categoryName(issue), sprintField: sprint })); + }), signal), + }); diff --git a/extensions/jira/commands/search-issues.ts b/extensions/jira/commands/search-issues.ts new file mode 100644 index 0000000..414ff89 --- /dev/null +++ b/extensions/jira/commands/search-issues.ts @@ -0,0 +1,44 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { account, countStatus, guarded, jira, JiraError, sprintField } from "../api.ts"; +import { asKey, FIELDS, type Issue, issueItem, jqlFor, searchIssues } from "../lib.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Search issues by text, key (ENG-12) or JQL (project = ENG AND status = Done)…", + empty: { title: "No issue found", icon: "bs-search" }, + status: (items) => countStatus(items, "issue"), + search: (query, { preferences, signal }) => + guarded(async () => { + const a = account(preferences); + const sprint = await sprintField(a, signal); + const key = asKey(query); + const fields = sprint ? [...FIELDS, sprint] : FIELDS; + try { + // "ENG-12": that issue (in text search, "-" would mean NOT). + const exact = key + ? await jira(a, `/rest/api/3/issue/${key}?fields=${fields.join(",")}`, { signal }).catch(() => + undefined + ) + : undefined; + const issues = exact + ? [exact] + : await searchIssues(a, jqlFor(query), { sprintField: sprint, max: 40, signal }); + const section = query.trim() ? undefined : "Recently viewed"; + return issues.map((issue) => issueItem(issue, ctx, { section, sprintField: sprint })); + } catch (e) { + // A JQL mistake is shown in the list, so the user can fix it while typing. + if (e instanceof JiraError && e.status === 400) return [invalidJql(e.message, query)]; + throw e; + } + }), + }); + +function invalidJql(message: string, query: string): Item { + return { + id: "error", + title: "Jira could not run this query", + subtitle: message, + icon: { source: Icons.alert, tint: "orange" }, + detail: { markdown: `## Invalid JQL\n\n\`\`\`\n${query}\n\`\`\`\n\n${message}` }, + }; +} diff --git a/extensions/jira/lib.ts b/extensions/jira/lib.ts new file mode 100644 index 0000000..c7e3412 --- /dev/null +++ b/extensions/jira/lib.ts @@ -0,0 +1,531 @@ +// Jira issues as launcher items, Atlassian Document Format to Markdown, and the actions on an issue. + +import { + type Accessory, + type Action, + action, + type Color, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + submenu, + toast, +} from "@backslash/api"; +import { type Account, account, issueUrl, jira, myAccountId } from "./api.ts"; + +// --------------------------------------------------------------------------- +// Shapes (REST v3) + +interface User { + accountId: string; + displayName: string; + avatarUrls?: Record; +} + +export interface Sprint { + id: number; + name: string; + state: "active" | "closed" | "future"; +} + +export interface Issue { + id: string; + key: string; + fields: { + summary: string; + status: { name: string; statusCategory: { key: "new" | "indeterminate" | "done" | string; name: string } }; + priority?: { id: string; name: string } | null; + issuetype: { name: string; subtask?: boolean; hierarchyLevel?: number }; + assignee?: User | null; + reporter?: User | null; + labels?: string[]; + project: { key: string; name: string }; + created: string; + updated: string; + duedate?: string | null; + description?: AdfNode | null; + parent?: { key: string; fields?: { summary?: string } } | null; + [custom: string]: unknown; + }; +} + +/** Fields asked for in searches; the sprint field id is added per site. */ +export const FIELDS = [ + "summary", + "status", + "priority", + "issuetype", + "assignee", + "reporter", + "labels", + "project", + "created", + "updated", + "duedate", + "description", + "parent", +]; + +// --------------------------------------------------------------------------- +// Formatting + +/** "today", "yesterday", "3d", "25 Oct". */ +export function ago(iso: string | null | undefined, now = new Date()): string { + if (!iso) return ""; + const d = new Date(iso); + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (days < 365) return d.toLocaleDateString("en", { day: "numeric", month: "short" }); + return d.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +export const avatarOf = (u: User) => u.avatarUrls?.["48x48"] ?? u.avatarUrls?.["32x32"] ?? u.displayName; + +const CATEGORY_ORDER = ["indeterminate", "new", "done"]; +const CATEGORY_NAMES: Record = { indeterminate: "In Progress", new: "To Do", done: "Done" }; + +/** The section of an issue in "My Issues": its status category. */ +export const categoryName = (issue: Issue) => + CATEGORY_NAMES[issue.fields.status.statusCategory.key] ?? issue.fields.status.statusCategory.name; + +export function categoryRank(key: string): number { + const i = CATEGORY_ORDER.indexOf(key); + return i === -1 ? CATEGORY_ORDER.length : i; +} + +export function statusIcon(status: Issue["fields"]["status"]): { source: string; tint: Color } { + switch (status.statusCategory.key) { + case "indeterminate": + return /review|qa|test/i.test(status.name) + ? { source: Icons.inReview, tint: "green" } + : { source: Icons.inProgress, tint: "blue" }; + case "done": + return { source: Icons.done, tint: "green" }; + default: + return { source: Icons.todo, tint: "gray" }; + } +} + +export const statusColor = (categoryKey: string): Color => + categoryKey === "done" ? "green" : categoryKey === "indeterminate" ? "blue" : "gray"; + +/** Jira's default schemes: Highest…Lowest, and the older Blocker…Trivial. */ +const PRIORITIES: Record = { + blocker: { rank: 0, icon: Icons.urgent, tint: "red" }, + highest: { rank: 1, icon: Icons.urgent, tint: "red" }, + critical: { rank: 1, icon: Icons.urgent, tint: "red" }, + high: { rank: 2, icon: Icons.high, tint: "red" }, + major: { rank: 2, icon: Icons.high, tint: "red" }, + medium: { rank: 3, icon: Icons.medium, tint: "orange" }, + low: { rank: 4, icon: Icons.low, tint: "blue" }, + minor: { rank: 4, icon: Icons.low, tint: "blue" }, + lowest: { rank: 5, icon: Icons.low, tint: "gray" }, + trivial: { rank: 5, icon: Icons.noPriority, tint: "gray" }, +}; + +export function priorityLook(p: Issue["fields"]["priority"]): { rank: number; icon: string; tint: Color } { + return PRIORITIES[(p?.name ?? "").toLowerCase()] ?? { rank: 6, icon: Icons.noPriority, tint: "gray" }; +} + +export function typeIcon(type: Issue["fields"]["issuetype"]): { source: string; tint: Color } { + const name = type.name.toLowerCase(); + if (type.subtask || /sub-?task/.test(name)) return { source: Icons.layers, tint: "blue" }; + if (name.includes("bug")) return { source: Icons.bug, tint: "red" }; + if (name.includes("epic")) return { source: Icons.bolt, tint: "purple" }; + if (name.includes("story")) return { source: Icons.note, tint: "green" }; + if (name.includes("incident")) return { source: Icons.alert, tint: "red" }; + if (/feature|improvement/.test(name)) return { source: Icons.plus, tint: "green" }; + if (name.includes("task")) return { source: Icons.squareCheck, tint: "blue" }; + return { source: Icons.ticket, tint: "gray" }; +} + +/** In progress first, then to do; by priority, then most recently updated. */ +export function sortIssues(issues: Issue[]): Issue[] { + return [...issues].sort((a, b) => + categoryRank(a.fields.status.statusCategory.key) - categoryRank(b.fields.status.statusCategory.key) || + priorityLook(a.fields.priority).rank - priorityLook(b.fields.priority).rank || + b.fields.updated.localeCompare(a.fields.updated) + ); +} + +/** The active sprint of an issue, else its latest one. */ +export function sprintOf(issue: Issue, field: string | undefined): Sprint | undefined { + if (!field) return undefined; + const sprints = issue.fields[field] as Sprint[] | null | undefined; + if (!Array.isArray(sprints) || !sprints.length) return undefined; + return sprints.find((s) => s.state === "active") ?? sprints[sprints.length - 1]; +} + +export function isOverdue(due: string | null | undefined, now = new Date()): boolean { + return !!due && new Date(`${due}T23:59:59`) < now; +} + +/** `ENG-12-fix-the-login-page`, like Jira's "Create branch". */ +export function branchName(issue: Issue): string { + const slug = issue.fields.summary.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/, ""); + return slug ? `${issue.key}-${slug}` : issue.key; +} + +// --------------------------------------------------------------------------- +// Atlassian Document Format + +export interface AdfNode { + type: string; + version?: number; + text?: string; + content?: AdfNode[]; + attrs?: Record; + marks?: { type: string; attrs?: Record }[]; +} + +function inline(nodes: AdfNode[] = []): string { + return nodes.map((n) => { + switch (n.type) { + case "text": { + let t = n.text ?? ""; + for (const m of n.marks ?? []) { + if (m.type === "strong") t = `**${t}**`; + else if (m.type === "em") t = `_${t}_`; + else if (m.type === "code") t = `\`${t}\``; + else if (m.type === "strike") t = `~~${t}~~`; + else if (m.type === "link") t = `[${t}](${m.attrs?.href})`; + } + return t; + } + case "hardBreak": + return "\n"; + case "mention": + return String(n.attrs?.text ?? "@someone").replace(/^(?!@)/, "@"); + case "emoji": + return String(n.attrs?.text ?? n.attrs?.shortName ?? ""); + case "inlineCard": + return String(n.attrs?.url ?? ""); + case "status": + return `\`${String(n.attrs?.text ?? "").toUpperCase()}\``; + case "date": + return new Date(Number(n.attrs?.timestamp)).toISOString().slice(0, 10); + default: + return inline(n.content); + } + }).join(""); +} + +function block(n: AdfNode, indent = ""): string { + switch (n.type) { + case "paragraph": + return indent + inline(n.content); + case "heading": + return `${"#".repeat(Math.min(6, Number(n.attrs?.level ?? 2)))} ${inline(n.content)}`; + case "bulletList": + case "orderedList": + case "taskList": + return (n.content ?? []).map((item, i) => { + const bullet = n.type === "orderedList" + ? `${i + 1}.` + : n.type === "taskList" + ? (item.attrs?.state === "DONE" ? "- [x]" : "- [ ]") + : "-"; + if (item.type === "taskItem") return `${indent}${bullet} ${inline(item.content)}`; + const [first, ...rest] = item.content ?? []; + const head = first ? block(first).trimStart() : ""; + const tail = rest.map((c) => block(c, `${indent} `)).join("\n"); + return `${indent}${bullet} ${head}${tail ? `\n${tail}` : ""}`; + }).join("\n"); + case "codeBlock": + return `\`\`\`${n.attrs?.language ?? ""}\n${inline(n.content)}\n\`\`\``; + case "blockquote": + case "panel": + return (n.content ?? []).map((c) => block(c)).join("\n\n").split("\n").map((l) => `> ${l}`).join("\n"); + case "rule": + return "---"; + case "mediaSingle": + case "mediaGroup": + case "media": + return "_[attachment]_"; + case "table": + return (n.content ?? []).map((row) => + `| ${(row.content ?? []).map((cell) => (cell.content ?? []).map((c) => block(c)).join(" ")).join(" | ")} |` + ).join("\n"); + default: + return n.content ? (n.content.map((c) => block(c, indent)).join("\n\n")) : inline([n]); + } +} + +export function adfToMarkdown(doc: AdfNode | null | undefined): string { + if (!doc) return ""; + if (typeof doc === "string") return doc; + return (doc.content ?? []).map((n) => block(n)).filter((s) => s.trim()).join("\n\n").trim(); +} + +/** Plain text (blank lines between paragraphs) as an ADF document. */ +export function textToAdf(text: string): AdfNode { + const paragraphs = text.trim().split(/\n\s*\n/).filter(Boolean); + return { + type: "doc", + version: 1, + content: paragraphs.map((p) => ({ + type: "paragraph", + content: p.split("\n").flatMap((line, i): AdfNode[] => [ + ...(i ? [{ type: "hardBreak" }] : []), + ...(line ? [{ type: "text", text: line }] : []), + ]), + })), + }; +} + +function excerpt(markdown: string, max = 400): string | undefined { + const t = markdown.trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +// --------------------------------------------------------------------------- +// Search + +/** `project = ENG`, `assignee in (…)`, `labels is EMPTY`, `ORDER BY created`… */ +const FIELD = String.raw`^\s*(\w+|"[^"]+"|cf\[\d+\])`; +const JQL = [ + new RegExp(String.raw`${FIELD}\s*(!=|!~|>=|<=|=|~|>|<)`), + new RegExp(String.raw`${FIELD}\s+(not\s+)?in\s*\(`, "i"), + new RegExp(String.raw`${FIELD}\s+is\s+(not\s+)?(empty|null)\b`, "i"), + /\border\s+by\s+\w+/i, +]; + +/** What to search when the user types `q`: JQL as is, else a text search on what they typed. */ +export function jqlFor(q: string): string { + const query = q.trim(); + if (!query) return "issuekey in issueHistory() ORDER BY lastViewed DESC"; + if (JQL.some((re) => re.test(query))) return query; + const escaped = query.replace(/["\\]/g, "\\$&"); + // A trailing * matches the word being typed. + const text = /\w$/.test(escaped) ? `${escaped}*` : escaped; + return `text ~ "${text}" ORDER BY updated DESC`; +} + +/** `ENG-123` (any case) as an issue key, if that is what was typed. */ +export function asKey(q: string): string | undefined { + const m = /^\s*([a-z][a-z0-9_]+-\d+)\s*$/i.exec(q); + return m ? m[1].toUpperCase() : undefined; +} + +// --------------------------------------------------------------------------- +// Live lists: a change made from one view reloads the lists still open. + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +// --------------------------------------------------------------------------- +// Items + +export interface Transition { + id: string; + name: string; + to: { name: string; statusCategory: { key: string } }; + isAvailable?: boolean; +} + +export function issueMetadata(issue: Issue, sprintFieldId?: string): Metadata[] { + const f = issue.fields; + const status = statusIcon(f.status); + const prio = priorityLook(f.priority); + const type = typeIcon(f.issuetype); + const lines: Metadata[] = [ + meta.text("Status", f.status.name, { icon: status.source, color: status.tint }), + meta.text("Type", f.issuetype.name, { icon: type.source, color: type.tint }), + meta.text("Priority", f.priority?.name ?? "None", { icon: prio.icon, color: prio.tint }), + f.assignee + ? meta.person("Assignee", f.assignee.displayName, avatarOf(f.assignee)) + : meta.text("Assignee", "Unassigned", { icon: Icons.person }), + ]; + const sprint = sprintOf(issue, sprintFieldId); + if (sprint) { + lines.push(meta.text("Sprint", sprint.name, { + icon: Icons.cycle, + color: sprint.state === "active" ? "blue" : undefined, + })); + } + if (f.labels?.length) lines.push(meta.tags("Labels", f.labels)); + if (f.parent) { + lines.push(meta.text("Parent", `${f.parent.key}${f.parent.fields?.summary ? ` ${f.parent.fields.summary}` : ""}`)); + } + if (f.duedate) { + lines.push(meta.text("Due", f.duedate, { + icon: Icons.calendar, + color: isOverdue(f.duedate) && f.status.statusCategory.key !== "done" ? "red" : undefined, + })); + } + lines.push(meta.separator()); + lines.push(meta.text("Project", `${f.project.name} (${f.project.key})`, { icon: Icons.project })); + if (f.reporter) lines.push(meta.person("Reporter", f.reporter.displayName, avatarOf(f.reporter))); + lines.push(meta.text("Created", ago(f.created))); + lines.push(meta.text("Updated", ago(f.updated))); + return lines; +} + +async function transitionsOf(a: Account, key: string): Promise { + const data = await jira<{ transitions: Transition[] }>(a, `/rest/api/3/issue/${key}/transitions`); + return data.transitions.filter((t) => t.isAvailable !== false); +} + +/** Transitions in workflow order: back to do, in progress, done. */ +const WORKFLOW = ["new", "indeterminate", "done"]; + +export function transitionActions(a: Account, issue: Issue, transitions: Transition[]): Action[] { + return transitions + .sort((x, y) => WORKFLOW.indexOf(x.to.statusCategory.key) - WORKFLOW.indexOf(y.to.statusCategory.key)) + .map((t) => { + const icon = statusIcon({ name: t.to.name, statusCategory: t.to.statusCategory as never }); + const title = t.name === t.to.name ? t.name : `${t.name} → ${t.to.name}`; + return action(title, async () => { + await jira(a, `/rest/api/3/issue/${issue.key}/transitions`, { + method: "POST", + body: { transition: { id: t.id } }, + }); + notifyChanged(); + await toast(`${issue.key}: ${t.to.name}`, { style: "success" }); + }, { icon: icon.source }); + }); +} + +export function issueActions(issue: Issue, ctx: Context, options: { page?: boolean; sprintField?: string } = {}) { + const a = account(ctx.preferences); + const url = issueUrl(a, issue.key); + const actions: Action[] = [ + open(url, "Open in Jira"), + ...(options.page ? [] : [action("Show Details", () => issuePage(issue, ctx, options.sprintField), { + icon: Icons.info, + })]), + submenu("Change Status", async () => transitionActions(a, issue, await transitionsOf(a, issue.key)), { + icon: Icons.inProgress, + shortcut: "s", + section: "Issue", + }), + action("Assign to Me", async () => { + await jira(a, `/rest/api/3/issue/${issue.key}/assignee`, { + method: "PUT", + body: { accountId: await myAccountId(a) }, + }); + notifyChanged(); + await toast(`${issue.key} assigned to you`, { style: "success" }); + }, { icon: Icons.person, shortcut: "a", section: "Issue" }), + ]; + if (issue.fields.assignee) { + actions.push(action("Unassign", async () => { + await jira(a, `/rest/api/3/issue/${issue.key}/assignee`, { method: "PUT", body: { accountId: null } }); + notifyChanged(); + await toast(`${issue.key} unassigned`, { style: "success" }); + }, { icon: Icons.cross, section: "Issue" })); + } + actions.push( + copy(issue.key, "Copy Key", { shortcut: "period", section: "Copy" }), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${issue.key}: ${issue.fields.summary}](${url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(branchName(issue), "Copy Branch Name", { icon: Icons.branch, section: "Copy" }), + ); + return actions; +} + +export function issuePage(issue: Issue, ctx: Context, sprintField?: string): DetailSpec { + const body = adfToMarkdown(issue.fields.description) || "_No description._"; + return detail({ + title: issue.key, + markdown: `# ${issue.fields.summary}\n\n${body}`, + metadata: issueMetadata(issue, sprintField), + status: `${issue.fields.project.name} · updated ${ago(issue.fields.updated)}`, + actions: issueActions(issue, ctx, { page: true, sprintField }), + }); +} + +export function issueItem(issue: Issue, ctx: Context, options: { section?: string; sprintField?: string } = {}): Item { + const f = issue.fields; + const prio = priorityLook(f.priority); + const sprint = sprintOf(issue, options.sprintField); + const accessories: Accessory[] = [ + { tag: f.status.name, color: statusColor(f.status.statusCategory.key) }, + ...(f.labels ?? []).slice(0, 2).map((l) => ({ tag: l })), + ]; + if (sprint?.state === "active") accessories.push({ icon: Icons.cycle, tooltip: sprint.name }); + if (f.duedate) { + accessories.push({ + icon: Icons.calendar, + text: f.duedate.slice(5), + color: isOverdue(f.duedate) ? "red" : undefined, + tooltip: `Due ${f.duedate}`, + }); + } + accessories.push({ icon: { source: prio.icon, tint: prio.tint }, tooltip: f.priority?.name ?? "No priority" }); + if (f.assignee) accessories.push({ avatar: avatarOf(f.assignee), tooltip: f.assignee.displayName }); + accessories.push({ text: ago(f.updated), tooltip: "Updated" }); + return { + id: issue.id, + title: f.summary, + subtitle: issue.key, + icon: typeIcon(f.issuetype), + section: options.section, + keywords: [ + issue.key, + f.status.name, + f.issuetype.name, + f.project.name, + f.assignee?.displayName ?? "", + sprint?.name ?? "", + ...(f.labels ?? []), + ].filter(Boolean), + accessories, + detail: { markdown: excerpt(adfToMarkdown(f.description)), metadata: issueMetadata(issue, options.sprintField) }, + actions: issueActions(issue, ctx, { sprintField: options.sprintField }), + }; +} + +/** `POST /rest/api/3/search/jql` (the paginated search that replaced `/search`). */ +export async function searchIssues( + a: Account, + jql: string, + options: { sprintField?: string; max?: number; signal?: AbortSignal } = {}, +): Promise { + const fields = options.sprintField ? [...FIELDS, options.sprintField] : FIELDS; + const out: Issue[] = []; + let nextPageToken: string | undefined; + const max = options.max ?? 100; + do { + const page = await jira<{ issues: Issue[]; nextPageToken?: string; isLast?: boolean }>( + a, + "/rest/api/3/search/jql", + { + method: "POST", + body: { jql, fields, maxResults: Math.min(50, max - out.length), nextPageToken }, + signal: options.signal, + }, + ); + out.push(...page.issues); + nextPageToken = page.isLast ? undefined : page.nextPageToken; + } while (nextPageToken && out.length < max); + return out; +} diff --git a/extensions/jira/lib_test.ts b/extensions/jira/lib_test.ts new file mode 100644 index 0000000..2a3bd1b --- /dev/null +++ b/extensions/jira/lib_test.ts @@ -0,0 +1,259 @@ +import { assertEquals } from "@std/assert"; +import type { Context } from "@backslash/api"; +import { AuthError, countStatus, errorText, guarded, normalizeSite } from "./api.ts"; +import { + adfToMarkdown, + asKey, + branchName, + categoryName, + type Issue, + issueItem, + issueMetadata, + jqlFor, + priorityLook, + sortIssues, + sprintOf, + statusIcon, + textToAdf, + transitionActions, + typeIcon, +} from "./lib.ts"; + +const ctx: Context = { + args: {}, + preferences: { site: "acme.atlassian.net", email: "ada@acme.dev", token: "t" }, + query: "", + signal: new AbortController().signal, +}; + +const AVATAR = "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/5b10/abc/48"; + +// Shaped like an issue of `POST /rest/api/3/search/jql`. +const sample: Issue = { + id: "10042", + key: "ENG-42", + fields: { + summary: "Login page shows a blank screen", + status: { name: "In Progress", statusCategory: { key: "indeterminate", name: "In Progress" } }, + priority: { id: "2", name: "High" }, + issuetype: { name: "Bug", subtask: false, hierarchyLevel: 0 }, + assignee: { accountId: "5b10", displayName: "Ada Lovelace", avatarUrls: { "48x48": AVATAR } }, + reporter: { accountId: "5b11", displayName: "Grace Hopper" }, + labels: ["frontend", "regression", "p1"], + project: { key: "ENG", name: "Engineering" }, + created: "2026-10-20T09:00:00.000+0200", + updated: "2026-10-27T09:00:00.000+0200", + duedate: "2026-10-01", + description: { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Steps " }, { type: "text", text: "to", marks: [{ type: "strong" }] }, { + type: "text", + text: " reproduce:", + }], + }, + ], + }, + customfield_10020: [ + { id: 1, name: "Sprint 41", state: "closed" }, + { id: 2, name: "Sprint 42", state: "active" }, + ], + }, +}; + +const issue = (key: string, category: string, priority: string, updated: string): Issue => ({ + ...sample, + id: key, + key, + fields: { + ...sample.fields, + status: { name: category, statusCategory: { key: category, name: category } }, + priority: { id: "0", name: priority }, + updated, + }, +}); + +Deno.test("sites: name, host or URL; Jira Cloud only", () => { + assertEquals(normalizeSite("acme"), "acme.atlassian.net"); + assertEquals(normalizeSite(" https://Acme.atlassian.net/jira/software/projects/ENG "), "acme.atlassian.net"); + assertEquals(normalizeSite("jira.acme.com"), "jira.acme.com"); +}); + +Deno.test("Jira error bodies", () => { + assertEquals( + errorText({ errorMessages: ["Field 'x' does not exist"], errors: { summary: "You must specify a summary" } }), + "Field 'x' does not exist; summary: You must specify a summary", + ); +}); + +Deno.test("status categories, priorities and types", () => { + assertEquals(categoryName(sample), "In Progress"); + assertEquals(statusIcon(sample.fields.status), { source: "bs-circle-half", tint: "blue" }); + assertEquals( + statusIcon({ name: "In Review", statusCategory: { key: "indeterminate", name: "" } }).source, + "bs-circle-three-quarters", + ); + assertEquals(statusIcon({ name: "Done", statusCategory: { key: "done", name: "" } }).tint, "green"); + assertEquals([priorityLook({ id: "1", name: "Highest" }).icon, priorityLook({ id: "3", name: "Medium" }).tint], [ + "bs-priority-urgent", + "orange", + ]); + assertEquals(priorityLook(null).rank, 6); + assertEquals(typeIcon({ name: "Bug" }), { source: "bs-bug", tint: "red" }); + assertEquals(typeIcon({ name: "Story" }).source, "bs-note"); + assertEquals(typeIcon({ name: "Sub-task", subtask: true }).source, "bs-layers"); + assertEquals(typeIcon({ name: "Task" }).source, "bs-square-check"); +}); + +Deno.test("in progress first, then by priority and update", () => { + const sorted = sortIssues([ + issue("A-1", "new", "Highest", "2026-10-01"), + issue("A-2", "indeterminate", "Low", "2026-10-01"), + issue("A-3", "indeterminate", "High", "2026-09-01"), + issue("A-4", "indeterminate", "High", "2026-10-05"), + issue("A-5", "new", "Medium", "2026-10-02"), + ]); + assertEquals(sorted.map((i) => i.key), ["A-4", "A-3", "A-2", "A-1", "A-5"]); +}); + +Deno.test("sprint: the active one", () => { + assertEquals(sprintOf(sample, "customfield_10020")?.name, "Sprint 42"); + assertEquals(sprintOf(sample, undefined), undefined); + assertEquals( + sprintOf({ ...sample, fields: { ...sample.fields, customfield_10020: null } }, "customfield_10020"), + undefined, + ); +}); + +Deno.test("ADF to Markdown and back", () => { + const doc = { + type: "doc", + version: 1, + content: [ + { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Context" }] }, + { + type: "paragraph", + content: [ + { type: "text", text: "See " }, + { type: "text", text: "the docs", marks: [{ type: "link", attrs: { href: "https://example.com" } }] }, + { type: "text", text: ", ask " }, + { type: "mention", attrs: { id: "1", text: "@Ada" } }, + { type: "hardBreak" }, + { type: "text", text: "run", marks: [{ type: "code" }] }, + ], + }, + { + type: "bulletList", + content: [ + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "one" }] }] }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "two" }] }, + { + type: "bulletList", + content: [{ + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "nested" }] }], + }], + }, + ], + }, + ], + }, + { type: "codeBlock", attrs: { language: "sh" }, content: [{ type: "text", text: "npm test" }] }, + { + type: "taskList", + content: [{ type: "taskItem", attrs: { state: "DONE" }, content: [{ type: "text", text: "done" }] }], + }, + { type: "rule" }, + ], + }; + assertEquals( + adfToMarkdown(doc), + "## Context\n\nSee [the docs](https://example.com), ask @Ada\n`run`\n\n- one\n- two\n - nested\n\n```sh\nnpm test\n```\n\n- [x] done\n\n---", + ); + assertEquals(adfToMarkdown(null), ""); + assertEquals(textToAdf("First line\nsecond\n\nNext"), { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First line" }, { type: "hardBreak" }, { type: "text", text: "second" }], + }, + { type: "paragraph", content: [{ type: "text", text: "Next" }] }, + ], + }); +}); + +Deno.test("search: text, keys and JQL", () => { + assertEquals(jqlFor(""), "issuekey in issueHistory() ORDER BY lastViewed DESC"); + assertEquals(jqlFor("login pag"), 'text ~ "login pag*" ORDER BY updated DESC'); + assertEquals(jqlFor('say "hi"'), 'text ~ "say \\"hi\\"" ORDER BY updated DESC'); + assertEquals(jqlFor("project = ENG AND status = Done"), "project = ENG AND status = Done"); + assertEquals(jqlFor("assignee in (currentUser())"), "assignee in (currentUser())"); + assertEquals(jqlFor("labels is EMPTY"), "labels is EMPTY"); + assertEquals(jqlFor("login is broken"), 'text ~ "login is broken*" ORDER BY updated DESC'); + assertEquals([asKey(" eng-42 "), asKey("eng 42")], ["ENG-42", undefined]); +}); + +Deno.test("issue item and panel", () => { + const item = issueItem(sample, ctx, { section: "In Progress", sprintField: "customfield_10020" }); + assertEquals(item.subtitle, "ENG-42"); + assertEquals(item.icon, { source: "bs-bug", tint: "red" }); + assertEquals(item.accessories?.[0], { tag: "In Progress", color: "blue" }); + assertEquals(item.accessories?.slice(1, 3), [{ tag: "frontend" }, { tag: "regression" }]); + assertEquals(item.accessories?.[3], { icon: "bs-cycle", tooltip: "Sprint 42" }); + assertEquals(item.accessories?.[4].color, "red"); // overdue + assertEquals(item.accessories?.[6], { avatar: AVATAR, tooltip: "Ada Lovelace" }); + assertEquals(item.detail?.markdown, "Steps **to** reproduce:"); + assertEquals(item.actions?.map((a) => a.title).slice(0, 5), [ + "Open in Jira", + "Show Details", + "Change Status", + "Assign to Me", + "Unassign", + ]); + assertEquals((item.actions?.[0] as { target: string }).target, "https://acme.atlassian.net/browse/ENG-42"); + assertEquals(issueMetadata(sample, "customfield_10020").map((m) => m.label).slice(0, 8), [ + "Status", + "Type", + "Priority", + "Assignee", + "Sprint", + "Labels", + "Due", + "", + ]); + assertEquals(branchName(sample), "ENG-42-login-page-shows-a-blank-screen"); +}); + +Deno.test("transitions: done last, named by their target", () => { + const actions = transitionActions( + { site: "acme.atlassian.net", email: "a", token: "t" }, + sample, + [ + { id: "31", name: "Done", to: { name: "Done", statusCategory: { key: "done" } } }, + { id: "11", name: "Back to do", to: { name: "To Do", statusCategory: { key: "new" } } }, + { id: "21", name: "In Review", to: { name: "In Review", statusCategory: { key: "indeterminate" } } }, + ], + ); + assertEquals(actions.map((a) => [a.title, a.icon]), [ + ["Back to do → To Do", "bs-circle"], + ["In Review", "bs-circle-three-quarters"], + ["Done", "bs-circle-check"], + ]); +}); + +Deno.test("missing settings become a setup row", async () => { + const items = await guarded(() => Promise.reject(new AuthError("Jira rejected the email or API token"))); + assertEquals([items[0].id, items[0].actions?.[0].title, countStatus(items, "issue")], [ + "setup", + "Create API Token", + "Not connected", + ]); +}); diff --git a/extensions/notion/api.ts b/extensions/notion/api.ts new file mode 100644 index 0000000..4692591 --- /dev/null +++ b/extensions/notion/api.ts @@ -0,0 +1,81 @@ +// Notion API with an internal integration secret. + +import { Icons, type Item, open } from "@backslash/api"; + +export const API = "https://api.notion.com/v1"; +/** The stable API version whose shapes this extension reads (databases, not data sources). */ +export const VERSION = "2022-06-28"; +export const TOKEN_URL = "https://www.notion.so/profile/integrations"; + +export class AuthError extends Error {} + +export function needToken(preferences: Record): string { + const token = String(preferences.token ?? "").trim(); + if (!token) throw new AuthError("Add a Notion integration secret"); + return token; +} + +export async function notion( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + "Notion-Version": VERSION, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (res.status === 401) throw new AuthError("Notion rejected the integration secret"); + const body = await res.json().catch(() => ({})) as { code?: string; message?: string }; + if (!res.ok) { + if (body.code === "object_not_found") { + throw new Error("Notion can't see this page: share it with your integration (••• → Connections)"); + } + if (body.code === "restricted_resource") { + throw new Error("The integration lacks a capability: enable “Insert content” in its settings"); + } + throw new Error(`Notion: ${body.message ?? `answered ${res.status}`}`); + } + return body as T; +} + +const SHARE_HINT = + "Notion only shows an integration the pages shared with it: open a page, ••• → Connections → add it."; + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Create an internal integration and share pages with it", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Notion", + "", + "1. Open **Notion → Settings → Connections → Develop or manage integrations** (Enter opens it).", + "2. Create an **internal integration** for your workspace, with the *Read content*, *Insert content*", + " and *Update content* capabilities, and copy its **Internal Integration Secret**.", + "3. Paste it in **Settings → Extensions → Notion → Configure**.", + "4. Share pages with it: in Notion, open a page or database → **••• → Connections → add your integration**.", + " Sub-pages are included.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Integration", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} + +export const EMPTY = { title: "Nothing found", description: SHARE_HINT, icon: Icons.search }; diff --git a/extensions/notion/backslash.toml b/extensions/notion/backslash.toml new file mode 100644 index 0000000..25659de --- /dev/null +++ b/extensions/notion/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "notion" +title = "Notion" +description = "Search the pages and databases shared with your integration, read them, add text to a page, create pages in a database" +version = "1.0.0" +author = "Backslash" +icon = "bs-note" +license = "MIT" + +[permissions] +net = ["api.notion.com"] + +[[commands]] +name = "search" +title = "Search Notion" +description = "Pages and databases, recently edited first" +icon = "bs-search" +keywords = ["notion", "pages", "docs", "wiki", "notes", "database", "find"] + +[[commands]] +name = "add-to-page" +title = "Add to Notion Page" +description = "Append text, a to-do or a list to a page" +icon = "bs-plus" +keywords = ["notion", "append", "add", "note", "todo", "journal", "capture"] + +[[commands]] +name = "create-page" +title = "Create Notion Page" +description = "A new page in one of your databases, with its properties" +icon = "bs-file" +keywords = ["notion", "new", "page", "database", "create", "entry", "row"] + +[[preferences]] +name = "token" +title = "Internal integration secret" +description = "notion.so/profile/integrations → New integration → Internal Integration Secret. Share pages with it: ••• → Connections" +type = "secret" +required = true diff --git a/extensions/notion/commands/add-to-page.ts b/extensions/notion/commands/add-to-page.ts new file mode 100644 index 0000000..948db16 --- /dev/null +++ b/extensions/notion/commands/add-to-page.ts @@ -0,0 +1,39 @@ +import { close, type Context, detail, field, form, storage } from "@backslash/api"; +import { AuthError, needToken, setupItems } from "../api.ts"; +import { appendToPage, BLOCK_TYPES, type Page, search, titleOf } from "../lib.ts"; + +export default async ({ preferences }: Context) => { + let token: string; + let pages: Page[]; + try { + token = needToken(preferences); + pages = await search(token, "", "page", undefined, 50) as Page[]; + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const [setup] = setupItems(e); + return detail({ title: "Connect Notion", markdown: setup.detail!.markdown!, actions: setup.actions }); + } + if (!pages.length) { + throw new Error("No page is shared with the integration: in Notion, ••• → Connections → add it"); + } + const last = await storage.get("lastPage").catch(() => undefined); + const options = pages.map((p) => ({ + value: p.id, + title: `${p.icon?.type === "emoji" ? `${p.icon.emoji} ` : ""}${titleOf(p)}`, + })); + return form({ + title: "Add to Notion page", + submitTitle: "Add", + fields: [ + field.dropdown("page", "Page", options, { value: pages.some((p) => p.id === last) ? last : pages[0].id }), + field.textArea("text", "Text", { required: true, placeholder: "Appended at the end of the page" }), + field.dropdown("type", "As", BLOCK_TYPES, { value: "paragraph" }), + ], + async submit(values) { + const page = pages.find((p) => p.id === values.page) ?? pages[0]; + await appendToPage(token, page.id, String(values.text), String(values.type)); + await storage.set("lastPage", page.id).catch(() => {}); + return close(`Added to ${titleOf(page)}`); + }, + }); +}; diff --git a/extensions/notion/commands/create-page.ts b/extensions/notion/commands/create-page.ts new file mode 100644 index 0000000..93441d5 --- /dev/null +++ b/extensions/notion/commands/create-page.ts @@ -0,0 +1,29 @@ +import { type Context, list, storage } from "@backslash/api"; +import { EMPTY, guarded, needToken } from "../api.ts"; +import { type Database, resultItem, search, titlesOf } from "../lib.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Pick a database for the new page…", + empty: { ...EMPTY, title: "No database shared with the integration" }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : "Enter opens the form", + items: ({ preferences, signal }) => + guarded(async () => { + const [databases, last] = await Promise.all([ + search(needToken(preferences), "", "database", signal, 50) as Promise, + storage.get("lastDatabase").catch(() => undefined), + ]); + const titles = titlesOf(databases); + // The last database used first. + const sorted = [...databases].sort((a, b) => Number(b.id === last) - Number(a.id === last)); + return sorted.map((db) => { + const item = resultItem(db, ctx, titles); + const create = item.actions!.find((a) => a.title === "Create Page in Database")!; + return { + ...item, + section: db.id === last ? "Last used" : "Databases", + actions: [create, ...item.actions!.filter((a) => a !== create)], + }; + }); + }), + }); diff --git a/extensions/notion/commands/search.ts b/extensions/notion/commands/search.ts new file mode 100644 index 0000000..4bbede0 --- /dev/null +++ b/extensions/notion/commands/search.ts @@ -0,0 +1,26 @@ +import { type Context, list } from "@backslash/api"; +import { EMPTY, guarded, needToken } from "../api.ts"; +import { resultItem, search, titlesOf } from "../lib.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Search Notion pages and databases…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "page", title: "Pages" }, + { value: "database", title: "Databases" }, + ], + }, + empty: EMPTY, + status: (items) => + items[0]?.id === "setup" ? "Not connected" : `${items.length} result${items.length === 1 ? "" : "s"}`, + search: (query, { preferences, signal, filter }) => + guarded(async () => { + const kind = filter === "page" || filter === "database" ? filter : undefined; + const results = await search(needToken(preferences), query, kind, signal); + const titles = titlesOf(results); + const section = query.trim() ? undefined : "Recently edited"; + return results.map((r) => ({ ...resultItem(r, ctx, titles), section })); + }), + }); diff --git a/extensions/notion/lib.ts b/extensions/notion/lib.ts new file mode 100644 index 0000000..c780b90 --- /dev/null +++ b/extensions/notion/lib.ts @@ -0,0 +1,596 @@ +// Notion pages, databases and blocks as launcher items, Markdown and forms. + +import { + type Accessory, + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + type Field, + field, + form, + type FormSpec, + type FormValues, + type Icon, + Icons, + type Item, + meta, + type Metadata, + open, + storage, + toast, +} from "@backslash/api"; +import { needToken, notion } from "./api.ts"; + +// --------------------------------------------------------------------------- +// Shapes (API version 2022-06-28) + +export interface RichText { + type?: string; + plain_text: string; + href?: string | null; + annotations?: { bold?: boolean; italic?: boolean; strikethrough?: boolean; code?: boolean }; +} + +export type NotionIcon = + | { type: "emoji"; emoji: string } + | { type: "external"; external: { url: string } } + | { type: "file"; file: { url: string } } + | { type: "custom_emoji"; custom_emoji: { name: string; url: string } } + | null; + +export type Parent = + | { type: "workspace"; workspace: true } + | { type: "page_id"; page_id: string } + | { type: "database_id"; database_id: string } + | { type: "block_id"; block_id: string }; + +export interface PropertyValue { + id: string; + type: string; + [key: string]: any; +} + +export interface Page { + object: "page"; + id: string; + url: string; + created_time: string; + last_edited_time: string; + icon?: NotionIcon; + parent: Parent; + archived?: boolean; + in_trash?: boolean; + properties: Record; +} + +export interface PropertySchema { + id: string; + name: string; + type: string; + select?: { options: { name: string; color?: string }[] }; + multi_select?: { options: { name: string; color?: string }[] }; + status?: { options: { name: string; color?: string }[] }; +} + +export interface Database { + object: "database"; + id: string; + url: string; + created_time: string; + last_edited_time: string; + icon?: NotionIcon; + parent: Parent; + title: RichText[]; + description?: RichText[]; + is_inline?: boolean; + archived?: boolean; + properties: Record; +} + +export type Result = Page | Database; + +export interface Block { + id: string; + type: string; + has_children?: boolean; + [key: string]: any; +} + +// --------------------------------------------------------------------------- +// Formatting + +export const plain = (rich: RichText[] | undefined) => (rich ?? []).map((t) => t.plain_text).join(""); + +export function richToMarkdown(rich: RichText[] | undefined): string { + return (rich ?? []).map((t) => { + let s = t.plain_text; + if (!s) return ""; + const a = t.annotations ?? {}; + if (a.code) s = `\`${s}\``; + if (a.bold) s = `**${s}**`; + if (a.italic) s = `_${s}_`; + if (a.strikethrough) s = `~~${s}~~`; + if (t.href) s = `[${s}](${t.href})`; + return s; + }).join(""); +} + +export function titleOf(r: Result): string { + if (r.object === "database") return plain(r.title) || "Untitled database"; + const prop = Object.values(r.properties).find((p) => p.type === "title"); + return plain(prop?.title) || "Untitled"; +} + +/** The emoji of a page, else Backslash's glyph for pages or databases (pictures come from hosts we can't list). */ +export function iconOf(r: Result): Icon { + if (r.icon?.type === "emoji") return r.icon.emoji; + return r.object === "database" ? { source: Icons.layers, tint: "blue" } : { source: Icons.file, tint: "gray" }; +} + +/** "5m", "3h", "2d", "25 Oct". */ +export function since(iso: string, now = new Date()): string { + const s = Math.max(0, (now.getTime() - new Date(iso).getTime()) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86_400) return `${Math.floor(s / 3600)}h`; + if (s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`; + return new Date(iso).toLocaleDateString("en", { day: "numeric", month: "short", year: "numeric" }); +} + +/** `https://www.notion.so/…` → `notion://www.notion.so/…`, opened by the desktop app. */ +export const appUrl = (url: string) => url.replace(/^https:\/\//, "notion://"); + +/** Where a result lives: the title of its parent when it is in the same results. */ +export function parentName(r: Result, titles: Map): string { + switch (r.parent.type) { + case "workspace": + return "Workspace"; + case "page_id": + return titles.get(r.parent.page_id) ?? "Page"; + case "database_id": + return titles.get(r.parent.database_id) ?? "Database"; + default: + return "Block"; + } +} + +/** A property of a page as one line of text, or nothing when it is empty. */ +export function propertyText(p: PropertyValue): string | undefined { + const v = p[p.type]; + switch (p.type) { + case "title": + case "rich_text": + return plain(v) || undefined; + case "select": + case "status": + return v?.name; + case "multi_select": + return (v as { name: string }[]).map((o) => o.name).join(", ") || undefined; + case "number": + return v == null ? undefined : String(v); + case "checkbox": + return v ? "Yes" : "No"; + case "date": + return v ? (v.end ? `${v.start} → ${v.end}` : v.start) : undefined; + case "people": + return (v as { name?: string }[]).map((u) => u.name ?? "Someone").join(", ") || undefined; + case "url": + case "email": + case "phone_number": + return v || undefined; + case "formula": + return v?.[v.type] == null ? undefined : String(v[v.type]); + case "unique_id": + return v?.number == null ? undefined : `${v.prefix ? `${v.prefix}-` : ""}${v.number}`; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Blocks → Markdown + +export function blocksToMarkdown(blocks: Block[]): string { + const out: string[] = []; + let number = 0; + for (const b of blocks) { + const v = b[b.type] ?? {}; + const text = richToMarkdown(v.rich_text); + number = b.type === "numbered_list_item" ? number + 1 : 0; + switch (b.type) { + case "paragraph": + out.push(text); + break; + case "heading_1": + out.push(`# ${text}`); + break; + case "heading_2": + out.push(`## ${text}`); + break; + case "heading_3": + out.push(`### ${text}`); + break; + case "bulleted_list_item": + out.push(`- ${text}`); + break; + case "numbered_list_item": + out.push(`${number}. ${text}`); + break; + case "to_do": + out.push(`- [${v.checked ? "x" : " "}] ${text}`); + break; + case "toggle": + out.push(`▸ ${text}`); + break; + case "quote": + out.push(`> ${text}`); + break; + case "callout": + out.push(`> ${v.icon?.type === "emoji" ? `${v.icon.emoji} ` : ""}${text}`); + break; + case "code": + out.push(`\`\`\`${v.language && v.language !== "plain text" ? v.language : ""}\n${plain(v.rich_text)}\n\`\`\``); + break; + case "divider": + out.push("---"); + break; + case "child_page": + out.push(`📄 **${v.title}**`); + break; + case "child_database": + out.push(`🗂 **${v.title}**`); + break; + case "bookmark": + case "embed": + case "link_preview": + if (v.url) out.push(`[${plain(v.caption) || v.url}](${v.url})`); + break; + case "image": + case "file": + case "pdf": + case "video": { + const url = v.external?.url ?? v.file?.url; + out.push(url ? `[${plain(v.caption) || b.type}](${url})` : `_[${b.type}]_`); + break; + } + case "equation": + out.push(`\`${v.expression}\``); + break; + default: + if (text) out.push(text); + } + } + // List items stay together; other blocks are separated by a blank line. + return out.reduce((md, line, i) => { + if (!i) return line; + const list = /^(- |\d+\. )/; + return md + (list.test(line) && list.test(out[i - 1]) ? "\n" : "\n\n") + line; + }, ""); +} + +// --------------------------------------------------------------------------- +// Writing + +export const BLOCK_TYPES = [ + { value: "paragraph", title: "Text" }, + { value: "to_do", title: "To-do" }, + { value: "bulleted_list_item", title: "Bulleted list" }, + { value: "numbered_list_item", title: "Numbered list" }, + { value: "heading_3", title: "Heading" }, + { value: "quote", title: "Quote" }, + { value: "callout", title: "Callout" }, + { value: "code", title: "Code" }, +]; + +const richText = (content: string) => + // Notion caps a text object at 2000 characters. + (content.match(/[\s\S]{1,2000}/g) ?? []).map((part) => ({ type: "text", text: { content: part } })); + +/** One block per line for lists and to-dos, one block for the rest. */ +export function textToBlocks(text: string, type = "paragraph"): Block[] { + const lines = ["to_do", "bulleted_list_item", "numbered_list_item"].includes(type) + ? text.split("\n").map((l) => l.trim()).filter(Boolean) + : type === "paragraph" + ? text.split(/\n\s*\n/).map((l) => l.trim()).filter(Boolean) + : [text.trim()]; + return lines.map((line) => + ({ + object: "block", + type, + [type]: { + rich_text: richText(line), + ...(type === "to_do" ? { checked: false } : {}), + ...(type === "code" ? { language: "plain text" } : {}), + }, + }) as unknown as Block + ); +} + +export async function appendToPage(token: string, pageId: string, text: string, type = "paragraph") { + const children = textToBlocks(text, type); + if (!children.length) throw new Error("Nothing to add"); + await notion(token, `/blocks/${pageId}/children`, { method: "PATCH", body: { children } }); +} + +/** "today", "tomorrow", "2026-11-02" or "2026-11-02 14:30" → a Notion date. */ +export function parseDate(input: string, now = new Date()): { start: string } | undefined { + const t = input.trim().toLowerCase(); + if (!t) return undefined; + const day = (offset: number) => { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + }; + if (t === "today") return { start: day(0) }; + if (t === "tomorrow") return { start: day(1) }; + if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return { start: t }; + const m = /^(\d{4}-\d{2}-\d{2})[ t](\d{1,2}):(\d{2})$/.exec(t); + if (m) return { start: new Date(`${m[1]}T${m[2].padStart(2, "0")}:${m[3]}:00`).toISOString() }; + throw new Error(`“${input}” is not a date: use YYYY-MM-DD, “today” or “tomorrow”`); +} + +/** Properties a form can fill, in the database's order (title first). */ +export const EDITABLE = [ + "title", + "rich_text", + "number", + "select", + "status", + "multi_select", + "checkbox", + "date", + "url", + "email", + "phone_number", +]; + +export function editableProperties(db: Database): PropertySchema[] { + return Object.values(db.properties) + .filter((p) => EDITABLE.includes(p.type)) + .sort((a, b) => Number(b.type === "title") - Number(a.type === "title")); +} + +export function databaseFields(db: Database): Field[] { + return editableProperties(db).map((p, i): Field => { + const id = `p${i}`; + switch (p.type) { + case "title": + return field.text(id, p.name, { required: true, placeholder: "Title" }); + case "select": + case "status": { + const options = (p[p.type as "select" | "status"]?.options ?? []).map((o) => o.name); + return field.dropdown( + id, + p.name, + [{ value: "", title: "—" }, ...options.map((o) => ({ value: o, title: o }))], + { + value: "", + }, + ); + } + case "checkbox": + return field.checkbox(id, p.name); + case "multi_select": + return field.text(id, p.name, { + placeholder: (p.multi_select?.options ?? []).slice(0, 6).map((o) => o.name).join(", ") || "Comma separated", + }); + case "date": + return field.text(id, p.name, { placeholder: "YYYY-MM-DD, today, tomorrow" }); + case "number": + return field.text(id, p.name, { placeholder: "0" }); + default: + return field.text(id, p.name); + } + }); +} + +/** The `properties` of a new page from the form's values. */ +export function propertiesFrom(db: Database, values: FormValues, now = new Date()): Record { + const out: Record = {}; + editableProperties(db).forEach((p, i) => { + const raw = values[`p${i}`]; + if (p.type === "checkbox") { + out[p.name] = { checkbox: raw === true }; + return; + } + const text = String(raw ?? "").trim(); + if (!text) return; + switch (p.type) { + case "title": + case "rich_text": + out[p.name] = { [p.type]: richText(text) }; + break; + case "number": { + const n = Number(text.replace(",", ".")); + if (!Number.isFinite(n)) throw new Error(`${p.name}: “${text}” is not a number`); + out[p.name] = { number: n }; + break; + } + case "select": + case "status": + out[p.name] = { [p.type]: { name: text } }; + break; + case "multi_select": + out[p.name] = { multi_select: text.split(",").map((s) => s.trim()).filter(Boolean).map((name) => ({ name })) }; + break; + case "date": + out[p.name] = { date: parseDate(text, now) }; + break; + default: + out[p.name] = { [p.type]: text }; + } + }); + return out; +} + +export function createPageForm(db: Database, ctx: Context): FormSpec { + const name = titleOf(db); + return form({ + title: `New page in ${name}`, + submitTitle: "Create Page", + fields: [...databaseFields(db), field.textArea("content", "Content", { placeholder: "Paragraphs of the page" })], + async submit(values) { + const token = needToken(ctx.preferences); + const content = String(values.content ?? "").trim(); + const page = await notion(token, "/pages", { + method: "POST", + body: { + parent: { database_id: db.id }, + properties: propertiesFrom(db, values), + ...(content ? { children: textToBlocks(content) } : {}), + }, + }); + await storage.set("lastDatabase", db.id).catch(() => {}); + await toast(`Created in ${name}`, { style: "success" }); + return detail({ + title: titleOf(page), + markdown: `# ${titleOf(page)}\n\nCreated in **${name}**.`, + metadata: [meta.text("Database", name, { icon: Icons.layers }), meta.link("Link", "Open in Notion", page.url)], + status: "Page created", + actions: [ + open(page.url, "Open in Notion"), + open(appUrl(page.url), "Open in Notion App", { icon: Icons.apps }), + copy(page.url, "Copy URL", { shortcut: "c" }), + action("Done", () => close()), + ], + }); + }, + }); +} + +export function addToPageForm(page: Page, ctx: Context): FormSpec { + const title = titleOf(page); + return form({ + title: `Add to ${title}`, + submitTitle: "Add", + fields: [ + field.textArea("text", "Text", { required: true, placeholder: "Appended at the end of the page" }), + field.dropdown("type", "As", BLOCK_TYPES, { value: "paragraph" }), + ], + async submit(values) { + await appendToPage(needToken(ctx.preferences), page.id, String(values.text), String(values.type)); + await storage.set("lastPage", page.id).catch(() => {}); + return close(`Added to ${title}`); + }, + }); +} + +// --------------------------------------------------------------------------- +// Items + +export function resultMetadata(r: Result, titles: Map): Metadata[] { + const lines: Metadata[] = [ + meta.text("Type", r.object === "database" ? (r.is_inline ? "Inline database" : "Database") : "Page", { + icon: r.object === "database" ? Icons.layers : Icons.file, + }), + meta.text("In", parentName(r, titles), { icon: r.parent.type === "workspace" ? Icons.home : Icons.folder }), + ]; + if (r.object === "page") { + const props = Object.entries(r.properties) + .filter(([, p]) => p.type !== "title") + .map(([name, p]) => [name, propertyText(p)] as const) + .filter(([, v]) => v) + .slice(0, 8); + if (props.length) lines.push(meta.separator(), ...props.map(([name, v]) => meta.text(name, v!))); + } else { + const names = Object.values(r.properties).map((p) => p.name); + if (names.length) lines.push(meta.tags("Properties", names.slice(0, 10))); + } + lines.push( + meta.separator(), + meta.text("Edited", since(r.last_edited_time), { icon: Icons.clock }), + meta.text("Created", since(r.created_time)), + ); + return lines; +} + +export async function pageContent(page: Page, ctx: Context): Promise { + const token = needToken(ctx.preferences); + const data = await notion<{ results: Block[]; has_more: boolean }>( + token, + `/blocks/${page.id}/children?page_size=100`, + ); + const title = titleOf(page); + const body = blocksToMarkdown(data.results) || "_Empty page._"; + return detail({ + title, + markdown: `# ${page.icon?.type === "emoji" ? `${page.icon.emoji} ` : ""}${title}\n\n${body}${ + data.has_more ? "\n\n_…continued in Notion._" : "" + }`, + metadata: resultMetadata(page, new Map()), + status: `Edited ${since(page.last_edited_time)}`, + actions: resultActions(page, ctx, true), + }); +} + +export function resultActions(r: Result, ctx: Context, page = false): Action[] { + const title = titleOf(r); + const actions: Action[] = [ + open(r.url, "Open in Browser"), + open(appUrl(r.url), "Open in Notion App", { icon: Icons.apps, shortcut: "o" }), + ]; + if (r.object === "page") { + if (!page) { + actions.push(action("Show Content", () => pageContent(r, ctx), { icon: Icons.text, shortcut: "d" })); + } + actions.push(action("Add to Page", () => addToPageForm(r, ctx), { + icon: Icons.plus, + shortcut: "n", + section: "Page", + })); + } else { + actions.push(action("Create Page in Database", () => createPageForm(r, ctx), { + icon: Icons.plus, + shortcut: "n", + section: "Database", + })); + } + actions.push( + copy(r.url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${title}](${r.url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(r.id, "Copy ID", { icon: Icons.hash, section: "Copy" }), + ); + return actions; +} + +export function resultItem(r: Result, ctx: Context, titles: Map): Item { + const accessories: Accessory[] = []; + if (r.object === "database") accessories.push({ tag: "Database", color: "blue" }); + accessories.push({ text: since(r.last_edited_time), tooltip: "Last edited" }); + return { + id: r.id, + title: titleOf(r), + subtitle: parentName(r, titles), + icon: iconOf(r), + keywords: [r.object, parentName(r, titles)], + accessories, + detail: { metadata: resultMetadata(r, titles) }, + actions: resultActions(r, ctx), + }; +} + +/** Titles of the results, to name the parents of the others. */ +export const titlesOf = (results: Result[]) => new Map(results.map((r) => [r.id, titleOf(r)])); + +export async function search( + token: string, + query: string, + kind: "page" | "database" | undefined, + signal?: AbortSignal, + size = 30, +): Promise { + const data = await notion<{ results: Result[] }>(token, "/search", { + method: "POST", + signal, + body: { + ...(query.trim() ? { query: query.trim() } : {}), + ...(kind ? { filter: { property: "object", value: kind } } : {}), + sort: { direction: "descending", timestamp: "last_edited_time" }, + page_size: size, + }, + }); + return data.results.filter((r) => !r.archived && !(r as Page).in_trash); +} diff --git a/extensions/notion/lib_test.ts b/extensions/notion/lib_test.ts new file mode 100644 index 0000000..76d5674 --- /dev/null +++ b/extensions/notion/lib_test.ts @@ -0,0 +1,203 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import type { Context } from "@backslash/api"; +import { AuthError, guarded } from "./api.ts"; +import { + appUrl, + type Block, + blocksToMarkdown, + type Database, + databaseFields, + iconOf, + type Page, + parentName, + parseDate, + propertiesFrom, + propertyText, + resultItem, + richToMarkdown, + textToBlocks, + titleOf, + titlesOf, +} from "./lib.ts"; + +const ctx: Context = { args: {}, preferences: { token: "ntn_x" }, query: "", signal: new AbortController().signal }; + +const text = (s: string, extra = {}) => ({ type: "text", plain_text: s, href: null, ...extra }); + +// Shaped like `POST /v1/search` results (Notion-Version 2022-06-28). +const db: Database = { + object: "database", + id: "668d797c-76fa-4934-9b05-ad288df2d136", + url: "https://www.notion.so/668d797c76fa49349b05ad288df2d136", + created_time: "2026-09-01T10:00:00.000Z", + last_edited_time: "2026-10-27T09:00:00.000Z", + icon: { type: "external", external: { url: "https://www.notion.so/icons/clipping_gray.svg" } }, + parent: { type: "workspace", workspace: true }, + title: [text("Reading List")], + is_inline: false, + properties: { + Status: { id: "a", name: "Status", type: "status", status: { options: [{ name: "To read" }, { name: "Done" }] } }, + Name: { id: "title", name: "Name", type: "title" }, + Tags: { + id: "b", + name: "Tags", + type: "multi_select", + multi_select: { options: [{ name: "Rust" }, { name: "Design" }] }, + }, + Pages: { id: "c", name: "Pages", type: "number" }, + Read: { id: "d", name: "Read", type: "checkbox" }, + Due: { id: "e", name: "Due", type: "date" }, + Author: { id: "f", name: "Author", type: "people" }, + }, +}; + +const page: Page = { + object: "page", + id: "59833787-2cf9-4fdf-8782-e53db20768a5", + url: "https://www.notion.so/Designing-Data-Intensive-Applications-598337872cf94fdf8782e53db20768a5", + created_time: "2026-10-01T10:00:00.000Z", + last_edited_time: "2026-10-27T08:00:00.000Z", + icon: { type: "emoji", emoji: "📘" }, + parent: { type: "database_id", database_id: db.id }, + properties: { + Name: { id: "title", type: "title", title: [text("Designing Data-Intensive Applications")] }, + Status: { id: "a", type: "status", status: { name: "To read", color: "blue" } }, + Tags: { id: "b", type: "multi_select", multi_select: [{ name: "Databases" }, { name: "Systems" }] }, + Pages: { id: "c", type: "number", number: 616 }, + Due: { id: "e", type: "date", date: { start: "2026-11-15", end: null } }, + Author: { id: "f", type: "people", people: [{ object: "user", id: "u", name: "Ada" }] }, + }, +}; + +Deno.test("titles, icons, parents and links", () => { + assertEquals([titleOf(page), titleOf(db)], ["Designing Data-Intensive Applications", "Reading List"]); + assertEquals(iconOf(page), "📘"); + assertEquals(iconOf(db), { source: "bs-layers", tint: "blue" }); + assertEquals(iconOf({ ...page, icon: null }), { source: "bs-file", tint: "gray" }); + const titles = titlesOf([page, db]); + assertEquals([parentName(page, titles), parentName(db, titles), parentName(page, new Map())], [ + "Reading List", + "Workspace", + "Database", + ]); + assertEquals(appUrl(page.url).startsWith("notion://www.notion.so/Designing"), true); +}); + +Deno.test("property values as text", () => { + const p = page.properties; + assertEquals([ + propertyText(p.Status), + propertyText(p.Tags), + propertyText(p.Pages), + propertyText(p.Due), + propertyText(p.Author), + ], [ + "To read", + "Databases, Systems", + "616", + "2026-11-15", + "Ada", + ]); + assertEquals(propertyText({ id: "x", type: "rich_text", rich_text: [] }), undefined); +}); + +Deno.test("rich text and blocks to Markdown", () => { + assertEquals( + richToMarkdown([ + text("Read "), + text("chapter 5", { annotations: { bold: true } }), + text(" on "), + text("replication", { href: "https://example.com", annotations: { italic: true } }), + ]), + "Read **chapter 5** on [_replication_](https://example.com)", + ); + const blocks: Block[] = [ + { id: "1", type: "heading_2", heading_2: { rich_text: [text("Notes")] } }, + { id: "2", type: "paragraph", paragraph: { rich_text: [text("Leaders and followers.")] } }, + { id: "3", type: "bulleted_list_item", bulleted_list_item: { rich_text: [text("Sync")] } }, + { id: "4", type: "bulleted_list_item", bulleted_list_item: { rich_text: [text("Async")] } }, + { id: "5", type: "to_do", to_do: { rich_text: [text("Summarize")], checked: true } }, + { id: "6", type: "numbered_list_item", numbered_list_item: { rich_text: [text("One")] } }, + { id: "7", type: "numbered_list_item", numbered_list_item: { rich_text: [text("Two")] } }, + { id: "8", type: "code", code: { rich_text: [text("SELECT 1;")], language: "sql" } }, + { id: "9", type: "divider", divider: {} }, + { id: "10", type: "child_page", child_page: { title: "Chapter 6" } }, + ]; + assertEquals( + blocksToMarkdown(blocks), + "## Notes\n\nLeaders and followers.\n\n- Sync\n- Async\n- [x] Summarize\n1. One\n2. Two\n\n```sql\nSELECT 1;\n```\n\n---\n\n📄 **Chapter 6**", + ); +}); + +Deno.test("text to blocks", () => { + assertEquals(textToBlocks("Hello\n\nWorld").map((b) => b.paragraph.rich_text[0].text.content), ["Hello", "World"]); + const todos = textToBlocks("Buy milk\n\nCall Bob\n", "to_do"); + assertEquals(todos.map((b) => [b.type, b.to_do.rich_text[0].text.content, b.to_do.checked]), [ + ["to_do", "Buy milk", false], + ["to_do", "Call Bob", false], + ]); + assertEquals(textToBlocks("x".repeat(4500))[0].paragraph.rich_text.length, 3); + assertEquals(textToBlocks(" "), []); +}); + +Deno.test("dates", () => { + const now = new Date(2026, 9, 27, 10, 0); + assertEquals( + [parseDate("today", now), parseDate("Tomorrow", now), parseDate("2026-12-01", now), parseDate("", now)], + [ + { start: "2026-10-27" }, + { start: "2026-10-28" }, + { start: "2026-12-01" }, + undefined, + ], + ); + assertThrows(() => parseDate("next week", now)); +}); + +Deno.test("database form: fields and properties", () => { + const fields = databaseFields(db); + assertEquals(fields.map((f) => [f.title, f.kind]), [ + ["Name", "text"], + ["Status", "dropdown"], + ["Tags", "text"], + ["Pages", "text"], + ["Read", "checkbox"], + ["Due", "text"], + ]); + assertEquals(fields[0].required, true); + const props = propertiesFrom(db, { p0: "SICP", p1: "Done", p2: "Lisp, Classics", p3: "657", p4: true, p5: "" }); + assertEquals(props, { + Name: { title: [{ type: "text", text: { content: "SICP" } }] }, + Status: { status: { name: "Done" } }, + Tags: { multi_select: [{ name: "Lisp" }, { name: "Classics" }] }, + Pages: { number: 657 }, + Read: { checkbox: true }, + }); + assertThrows(() => propertiesFrom(db, { p0: "x", p3: "many" })); +}); + +Deno.test("result items", () => { + const titles = titlesOf([page, db]); + const p = resultItem(page, ctx, titles); + assertEquals([p.title, p.subtitle, p.icon], ["Designing Data-Intensive Applications", "Reading List", "📘"]); + assertEquals(p.actions?.map((a) => a.title).slice(0, 4), [ + "Open in Browser", + "Open in Notion App", + "Show Content", + "Add to Page", + ]); + assertEquals(p.detail?.metadata?.slice(3, 7).map((m) => [m.label, m.value]), [ + ["Status", "To read"], + ["Tags", "Databases, Systems"], + ["Pages", "616"], + ["Due", "2026-11-15"], + ]); + const d = resultItem(db, ctx, titles); + assertEquals(d.accessories?.[0], { tag: "Database", color: "blue" }); + assertEquals(d.actions?.[2].title, "Create Page in Database"); +}); + +Deno.test("a rejected secret becomes a setup row", async () => { + const items = await guarded(() => Promise.reject(new AuthError("Notion rejected the integration secret"))); + assertEquals([items[0].id, items[0].actions?.[0].title], ["setup", "Create Integration"]); +}); diff --git a/extensions/sentry/api.ts b/extensions/sentry/api.ts new file mode 100644 index 0000000..67b81ce --- /dev/null +++ b/extensions/sentry/api.ts @@ -0,0 +1,109 @@ +// Sentry Web API (`/api/0`) with a user auth token, on sentry.io or its US/EU region. + +import { Icons, type Item, open } from "@backslash/api"; + +export const TOKEN_URL = "https://sentry.io/settings/account/api/auth-tokens/"; + +/** The hosts in `[permissions] net`; self-hosted Sentry would need its own. */ +export const REGIONS = ["sentry.io", "us.sentry.io", "de.sentry.io"]; + +export class AuthError extends Error {} + +export interface Account { + host: string; + org: string; + token: string; +} + +/** `https://de.sentry.io/` → `de.sentry.io`; empty → `sentry.io`. */ +export function normalizeRegion(input: unknown): string { + const host = String(input ?? "").trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split(/[/?#]/)[0]; + return host || "sentry.io"; +} + +/** `https://acme.sentry.io/issues/` or `acme` → `acme`. */ +export function normalizeOrg(input: unknown): string { + const text = String(input ?? "").trim(); + const sub = /^(?:https?:\/\/)?([a-z0-9-]+)\.sentry\.io/i.exec(text); + if (sub && !["us", "de", "www"].includes(sub[1].toLowerCase())) return sub[1].toLowerCase(); + const path = /sentry\.io\/organizations\/([^/]+)/i.exec(text); + return (path ? path[1] : text).toLowerCase(); +} + +export function account(preferences: Record): Account { + const org = normalizeOrg(preferences.organization); + const token = String(preferences.token ?? "").trim(); + const host = normalizeRegion(preferences.region); + if (!org || !token) throw new AuthError("Add your Sentry organization and auth token"); + if (!REGIONS.includes(host)) { + throw new AuthError(`${host} is not a Sentry region (use sentry.io, us.sentry.io or de.sentry.io)`); + } + return { host, org, token }; +} + +export const apiBase = (a: Account) => `https://${a.host}/api/0`; + +export async function sentry( + a: Account, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${apiBase(a)}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${a.token}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (res.status === 401) throw new AuthError("Sentry rejected the auth token"); + if (res.status === 204) return undefined as T; + const body = await res.json().catch(() => undefined) as { detail?: unknown } | undefined; + if (!res.ok) { + const detail = typeof body?.detail === "string" ? body.detail : ""; + if (res.status === 403) { + throw new Error( + `Sentry refused it${detail ? `: ${detail}` : ""}. The token needs event:read, event:write, org:read`, + ); + } + if (res.status === 404 && path.startsWith(`/organizations/${a.org}/issues/?`)) { + throw new AuthError(`No Sentry organization “${a.org}” on ${a.host}`); + } + throw new Error(`Sentry answered ${res.status}${detail ? `: ${detail}` : ""}`); + } + return body as T; +} + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Organization slug, user auth token and region", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Sentry", + "", + "1. Create a **user auth token** in **User settings → Personal Tokens** (Enter opens it) with the", + " scopes `event:read`, `event:write`, `project:read`, `org:read` and `member:read`.", + "2. In **Settings → Extensions → Sentry → Configure**, set:", + " - **Organization**: the slug in your Sentry URLs (`acme` in `acme.sentry.io`)", + " - **Auth token**: the token you just created", + " - **Region**: `sentry.io` (US) or `de.sentry.io` (EU data region)", + "", + "Self-hosted Sentry is not supported: extensions declare the hosts they reach.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Auth Token", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} diff --git a/extensions/sentry/backslash.toml b/extensions/sentry/backslash.toml new file mode 100644 index 0000000..f49fe10 --- /dev/null +++ b/extensions/sentry/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "sentry" +title = "Sentry" +description = "Unresolved issues across your projects with events, users and assignee; resolve, ignore, assign; latest event with its stack trace (sentry.io, US and EU regions)" +version = "1.0.0" +author = "Backslash" +icon = "bs-bug" +license = "MIT" + +# sentry.io and its data regions; self-hosted Sentry is not supported. +[permissions] +net = ["sentry.io", "us.sentry.io", "de.sentry.io"] + +[[commands]] +name = "issues" +title = "Sentry Issues" +description = "Unresolved, for review or assigned to you, by project" +icon = "bs-bug" +keywords = ["sentry", "errors", "issues", "exceptions", "crash", "bugs", "monitoring"] + +[[preferences]] +name = "organization" +title = "Organization slug" +description = "The slug in your Sentry URLs: acme in acme.sentry.io" +required = true + +[[preferences]] +name = "token" +title = "User auth token" +description = "Sentry → User settings → Personal Tokens, scopes event:read, event:write, project:read, org:read, member:read" +type = "secret" +required = true + +[[preferences]] +name = "region" +title = "Region" +description = "sentry.io (US) or de.sentry.io (EU data region)" +default = "sentry.io" diff --git a/extensions/sentry/commands/issues.ts b/extensions/sentry/commands/issues.ts new file mode 100644 index 0000000..a315bd5 --- /dev/null +++ b/extensions/sentry/commands/issues.ts @@ -0,0 +1,51 @@ +import { type Context, list, updateCommandMetadata } from "@backslash/api"; +import { account, guarded, sentry } from "../api.ts"; +import { type Issue, issueItem } from "../lib.ts"; + +const QUERIES: Record = { + unresolved: "is:unresolved", + review: "is:unresolved is:for_review", + mine: "is:unresolved assigned:me", +}; + +const LIMIT = 50; + +export default (ctx: Context) => + list({ + placeholder: "Filter issues by title, project, short ID, culprit…", + filter: { + options: [ + { value: "unresolved", title: "Unresolved" }, + { value: "review", title: "For review" }, + { value: "mine", title: "Assigned to me" }, + ], + }, + empty: { title: "No issue here", description: "Nothing matches in the last 14 days.", icon: "bs-circle-check" }, + status: (items) => + items[0]?.id === "setup" + ? "Not connected" + : `${items.length === LIMIT ? `${LIMIT}+` : items.length} issue${items.length === 1 ? "" : "s"} · last 14 days`, + items: ({ preferences, signal, filter }) => + guarded(async () => { + const a = account(preferences); + const key = filter && QUERIES[filter] ? filter : "unresolved"; + const params = new URLSearchParams({ + query: QUERIES[key], + statsPeriod: "14d", + sort: "date", + limit: String(LIMIT), + project: "-1", + }); + const issues = await sentry(a, `/organizations/${a.org}/issues/?${params}`, { signal }); + if (key === "unresolved") { + const n = issues.length === LIMIT ? `${LIMIT}+` : String(issues.length); + updateCommandMetadata({ subtitle: issues.length ? `${n} unresolved` : null }).catch(() => {}); + } + // By project, the one with the most recent event first. + const order = new Map(); + issues.forEach((issue, i) => order.has(issue.project.slug) || order.set(issue.project.slug, i)); + return [...issues] + .sort((x, y) => order.get(x.project.slug)! - order.get(y.project.slug)!) + .map((issue) => issueItem(issue, ctx)); + }), + }); diff --git a/extensions/sentry/lib.ts b/extensions/sentry/lib.ts new file mode 100644 index 0000000..3215c77 --- /dev/null +++ b/extensions/sentry/lib.ts @@ -0,0 +1,423 @@ +// Sentry issues and events as launcher items and pages, and the triage actions. + +import { + type Accessory, + type Action, + action, + type Color, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + type Stat, + submenu, + toast, +} from "@backslash/api"; +import { type Account, account, sentry } from "./api.ts"; + +// --------------------------------------------------------------------------- +// Shapes + +export interface Issue { + id: string; + shortId: string; + title: string; + culprit?: string | null; + permalink: string; + level: "fatal" | "error" | "warning" | "info" | "debug" | string; + status: "unresolved" | "resolved" | "ignored" | string; + substatus?: string | null; + priority?: "high" | "medium" | "low" | string | null; + platform?: string | null; + project: { id: string; name: string; slug: string; platform?: string | null }; + type?: string; + metadata?: { type?: string; value?: string; filename?: string; function?: string; title?: string }; + numComments?: number; + assignedTo?: { type: "user" | "team"; id: string; name: string; email?: string } | null; + isUnhandled?: boolean; + count: string | number; + userCount: number; + firstSeen: string; + lastSeen: string; +} + +export interface Frame { + filename?: string | null; + absPath?: string | null; + module?: string | null; + function?: string | null; + lineNo?: number | null; + colNo?: number | null; + inApp?: boolean; + context?: [number, string][]; +} + +export interface SentryEvent { + id: string; + eventID: string; + title?: string; + message?: string; + dateCreated?: string; + platform?: string; + culprit?: string; + tags?: { key: string; value: string }[]; + user?: { id?: string | null; email?: string | null; username?: string | null; ip_address?: string | null } | null; + release?: { version: string; shortVersion?: string } | null; + entries: { type: string; data: any }[]; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** "just now", "5m", "3h", "2d", "25 Oct": how recent an event is. */ +export function since(iso: string | null | undefined, now = new Date()): string { + if (!iso) return ""; + const s = Math.max(0, (now.getTime() - new Date(iso).getTime()) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86_400) return `${Math.floor(s / 3600)}h`; + if (s < 30 * 86_400) return `${Math.floor(s / 86_400)}d`; + return new Date(iso).toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +/** 1234 → "1.2k". */ +export function compact(value: number | string): string { + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`; + return String(n); +} + +export function levelLook(level: string): { icon: string; tint: Color; text: string } { + switch (level) { + case "fatal": + return { icon: Icons.alert, tint: "red", text: "Fatal" }; + case "error": + return { icon: Icons.bug, tint: "red", text: "Error" }; + case "warning": + return { icon: Icons.alert, tint: "orange", text: "Warning" }; + case "info": + return { icon: Icons.info, tint: "blue", text: "Info" }; + case "debug": + return { icon: Icons.code, tint: "gray", text: "Debug" }; + default: + return { icon: Icons.bug, tint: "gray", text: level }; + } +} + +/** The badges Sentry shows next to the title. */ +export function substatusLook(issue: Issue): { text: string; color: Color } | undefined { + switch (issue.substatus) { + case "new": + return { text: "New", color: "blue" }; + case "regressed": + return { text: "Regressed", color: "purple" }; + case "escalating": + return { text: "Escalating", color: "red" }; + case "archived_until_escalating": + case "archived_until_condition_met": + case "archived_forever": + return { text: "Archived", color: "gray" }; + default: + return undefined; + } +} + +const PRIORITY_COLORS: Record = { high: "red", medium: "orange", low: "gray" }; + +function statusText(issue: Issue): string { + if (issue.status === "ignored") return "Ignored"; + if (issue.status === "resolved") return "Resolved"; + return "Unresolved"; +} + +// --------------------------------------------------------------------------- +// Stack traces + +/** The exceptions of an event, the one that was raised last first. */ +export function exceptions(event: SentryEvent): { type: string; value: string; frames: Frame[] }[] { + const entry = event.entries.find((e) => e.type === "exception"); + const values = (entry?.data?.values ?? []) as { type?: string; value?: string; stacktrace?: { frames?: Frame[] } }[]; + return [...values].reverse().map((v) => ({ + type: v.type ?? "Error", + value: v.value ?? "", + frames: [...(v.stacktrace?.frames ?? [])].reverse(), + })); +} + +export function frameLine(f: Frame): string { + const where = f.filename ?? f.absPath ?? f.module ?? "?"; + const pos = f.lineNo ? `:${f.lineNo}${f.colNo ? `:${f.colNo}` : ""}` : ""; + return `${f.function ?? ""} (${where}${pos})`; +} + +/** The top of the stack: frames of the app first, most recent call first. */ +export function topFrames(frames: Frame[], max = 8): Frame[] { + const app = frames.filter((f) => f.inApp); + return (app.length ? app : frames).slice(0, max); +} + +/** An event as Markdown: the exception, the top frames and the code around the first one. */ +export function eventMarkdown(issue: Issue, event: SentryEvent): string { + const out: string[] = [`# ${issue.title}`]; + if (issue.culprit) out.push(`\`${issue.culprit}\``); + const errors = exceptions(event); + for (const [i, ex] of errors.entries()) { + out.push(`## ${ex.type}${ex.value ? `: ${ex.value}` : ""}`); + const frames = topFrames(ex.frames); + if (!frames.length) continue; + out.push( + ["```", ...frames.map((f) => `${f.inApp ? "▸" : " "} ${frameLine(f)}`), "```"].join("\n"), + ); + const context = i === 0 ? frames[0].context : undefined; + if (context?.length) { + const width = String(context[context.length - 1][0]).length; + out.push(`**${frames[0].filename ?? frames[0].absPath ?? ""}**`); + out.push( + [ + "```", + ...context.map(([n, code]) => `${n === frames[0].lineNo ? "→" : " "} ${String(n).padStart(width)} ${code}`), + "```", + ].join("\n"), + ); + } + } + if (!errors.length) { + const message = event.entries.find((e) => e.type === "message")?.data?.formatted ?? event.message; + out.push(message ? `\`\`\`\n${message}\n\`\`\`` : "_No stack trace in the latest event._"); + } + return out.join("\n\n"); +} + +const TAG_LABELS: Record = { + environment: "Environment", + release: "Release", + browser: "Browser", + os: "OS", + "runtime": "Runtime", + url: "URL", + handled: "Handled", + server_name: "Server", + transaction: "Transaction", +}; + +export function eventMetadata(event: SentryEvent): Metadata[] { + const tags = new Map((event.tags ?? []).map((t) => [t.key, t.value])); + const lines: Metadata[] = [meta.text("Event", event.eventID.slice(0, 12), { icon: Icons.hash })]; + if (event.dateCreated) lines.push(meta.text("When", `${since(event.dateCreated)} ago`, { icon: Icons.clock })); + for (const [key, label] of Object.entries(TAG_LABELS)) { + const value = tags.get(key); + if (!value) continue; + if (key === "url" && /^https?:/.test(value)) lines.push(meta.link(label, value.replace(/^https?:\/\//, ""), value)); + else if (key === "handled") { + lines.push(meta.text(label, value === "yes" ? "Yes" : "No", { color: value === "yes" ? undefined : "red" })); + } else lines.push(meta.text(label, value)); + } + const user = event.user; + const who = user?.email ?? user?.username ?? user?.id ?? user?.ip_address; + if (who) lines.push(meta.text("User", who, { icon: Icons.person })); + return lines; +} + +// --------------------------------------------------------------------------- +// Items and actions + +export function issueStats(issue: Issue): Stat[] { + return [ + { label: "events", value: compact(issue.count) }, + { label: "users", value: compact(issue.userCount) }, + { label: "first seen", value: since(issue.firstSeen) }, + { label: "last seen", value: since(issue.lastSeen) }, + ]; +} + +export function issueMetadata(issue: Issue): Metadata[] { + const level = levelLook(issue.level); + const lines: Metadata[] = [ + meta.text("Level", level.text, { icon: level.icon, color: level.tint }), + meta.text("Status", statusText(issue), { + icon: issue.status === "resolved" ? Icons.done : issue.status === "ignored" ? Icons.eyeOff : Icons.todo, + }), + ]; + const sub = substatusLook(issue); + if (sub) lines.push(meta.tags("State", [sub])); + if (issue.priority) { + lines.push(meta.text("Priority", issue.priority.replace(/^./, (c) => c.toUpperCase()), { + color: PRIORITY_COLORS[issue.priority], + })); + } + lines.push( + issue.assignedTo + ? meta.person("Assignee", issue.assignedTo.type === "team" ? `#${issue.assignedTo.name}` : issue.assignedTo.name) + : meta.text("Assignee", "Unassigned", { icon: Icons.person }), + ); + if (issue.isUnhandled) lines.push(meta.text("Handled", "No", { icon: Icons.alert, color: "red" })); + lines.push(meta.separator()); + lines.push(meta.text("Project", issue.project.slug, { icon: Icons.project })); + if (issue.culprit) lines.push(meta.text("Culprit", issue.culprit, { icon: Icons.code })); + lines.push(meta.text("Short ID", issue.shortId)); + lines.push( + meta.text( + "First seen", + new Date(issue.firstSeen).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }), + ), + ); + lines.push(meta.text("Last seen", `${since(issue.lastSeen)} ago`)); + return lines; +} + +/** `PUT /organizations/{org}/issues/?id=…`, the bulk update, for one issue. */ +export async function updateIssue(a: Account, issue: Issue, body: Record, message: string) { + await sentry(a, `/organizations/${a.org}/issues/?id=${encodeURIComponent(issue.id)}`, { method: "PUT", body }); + await toast(`${issue.shortId}: ${message}`, { style: "success" }); + return refresh(); +} + +interface Member { + id: string; + name?: string; + email?: string; + user?: { id: string; name?: string; email?: string } | null; +} + +export const IGNORE_OPTIONS: { title: string; details: Record }[] = [ + { title: "Forever", details: {} }, + { title: "For 1 Hour", details: { ignoreDuration: 60 } }, + { title: "For 24 Hours", details: { ignoreDuration: 1440 } }, + { title: "For a Week", details: { ignoreDuration: 10080 } }, + { title: "Until 100 More Events", details: { ignoreCount: 100 } }, + { title: "Until 10 More Users", details: { ignoreUserCount: 10 } }, +]; + +export function issueActions(issue: Issue, ctx: Context, page = false): Action[] { + const a = account(ctx.preferences); + const unresolved = issue.status === "unresolved"; + const actions: Action[] = [ + open(issue.permalink, "Open in Sentry"), + ...(page ? [] : [action("Show Latest Event", () => eventPage(issue, ctx), { icon: Icons.bug })]), + ]; + if (unresolved) { + actions.push( + action("Resolve", () => updateIssue(a, issue, { status: "resolved" }, "resolved"), { + icon: Icons.done, + shortcut: "r", + section: "Triage", + }), + action( + "Resolve in Next Release", + () => updateIssue(a, issue, { status: "resolved", statusDetails: { inNextRelease: true } }, "resolved"), + { icon: Icons.done, section: "Triage" }, + ), + submenu( + "Ignore", + IGNORE_OPTIONS.map((o) => + action( + o.title, + () => + updateIssue( + a, + issue, + { status: "ignored", statusDetails: o.details }, + `ignored ${o.title.toLowerCase()}`, + ), + { icon: Icons.eyeOff }, + ) + ), + { icon: Icons.eyeOff, shortcut: "i", section: "Triage" }, + ), + ); + } else { + actions.push(action("Unresolve", () => updateIssue(a, issue, { status: "unresolved" }, "unresolved"), { + icon: Icons.todo, + section: "Triage", + })); + } + actions.push( + submenu("Assign To", async () => { + const [me, members] = await Promise.all([ + sentry(a, `/organizations/${a.org}/members/me/`).catch(() => undefined), + sentry(a, `/organizations/${a.org}/users/?project=${issue.project.id}`), + ]); + const myId = me?.user?.id; + const people = members + .map((m) => ({ id: m.user?.id ?? m.id, name: m.user?.name || m.name || m.email || m.user?.email || "?" })) + .sort((x, y) => (x.id === myId ? -1 : y.id === myId ? 1 : x.name.localeCompare(y.name))); + return [ + ...people.map((p) => + action( + p.id === myId ? `${p.name} (me)` : p.name, + () => updateIssue(a, issue, { assignedTo: `user:${p.id}` }, `assigned to ${p.name}`), + { icon: Icons.person }, + ) + ), + ...(issue.assignedTo + ? [action("Unassign", () => updateIssue(a, issue, { assignedTo: "" }, "unassigned"), { icon: Icons.cross })] + : []), + ]; + }, { icon: Icons.person, shortcut: "a", section: "Triage" }), + action("Assign to Me", async () => { + const me = await sentry(a, `/organizations/${a.org}/members/me/`); + return updateIssue(a, issue, { assignedTo: `user:${me.user?.id ?? me.id}` }, "assigned to you"); + }, { icon: Icons.person, section: "Triage" }), + copy(issue.shortId, "Copy Short ID", { shortcut: "period", section: "Copy" }), + copy(issue.permalink, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${issue.shortId}: ${issue.title}](${issue.permalink})`, "Copy as Markdown Link", { section: "Copy" }), + ); + return actions; +} + +/** The latest event of an issue: stack trace and tags. */ +export async function eventPage(issue: Issue, ctx: Context): Promise { + const a = account(ctx.preferences); + const event = await sentry(a, `/organizations/${a.org}/issues/${issue.id}/events/latest/`); + return detail({ + title: issue.shortId, + markdown: eventMarkdown(issue, event), + stats: issueStats(issue), + metadata: [...eventMetadata(event), meta.separator(), ...issueMetadata(issue)], + status: `${issue.project.slug} · latest event ${since(event.dateCreated ?? issue.lastSeen)} ago`, + actions: [ + ...issueActions(issue, ctx, true), + copy(event.eventID, "Copy Event ID", { section: "Copy" }), + ], + }); +} + +export function issueItem(issue: Issue, ctx: Context): Item { + const level = levelLook(issue.level); + const sub = substatusLook(issue); + const accessories: Accessory[] = []; + if (sub) accessories.push({ tag: sub.text, color: sub.color }); + if (issue.isUnhandled) accessories.push({ tag: "Unhandled", color: "red" }); + accessories.push( + { icon: Icons.activity, text: compact(issue.count), tooltip: `${issue.count} events` }, + { icon: Icons.person, text: compact(issue.userCount), tooltip: `${issue.userCount} users` }, + ); + if (issue.assignedTo) { + accessories.push({ avatar: issue.assignedTo.name, tooltip: `Assigned to ${issue.assignedTo.name}` }); + } + accessories.push({ text: since(issue.lastSeen), tooltip: "Last seen" }); + return { + id: issue.id, + title: issue.title, + subtitle: issue.culprit || issue.shortId, + icon: { source: level.icon, tint: level.tint }, + section: issue.project.slug, + keywords: [issue.shortId, issue.project.slug, issue.level, issue.metadata?.type ?? "", issue.assignedTo?.name ?? ""] + .filter(Boolean), + accessories, + detail: { + stats: issueStats(issue), + markdown: issue.metadata?.value ? `**${issue.metadata.type ?? "Error"}**\n\n${issue.metadata.value}` : undefined, + metadata: issueMetadata(issue), + }, + actions: issueActions(issue, ctx), + }; +} diff --git a/extensions/sentry/lib_test.ts b/extensions/sentry/lib_test.ts new file mode 100644 index 0000000..b1bd180 --- /dev/null +++ b/extensions/sentry/lib_test.ts @@ -0,0 +1,213 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import type { Context } from "@backslash/api"; +import { account, AuthError, guarded, normalizeOrg, normalizeRegion } from "./api.ts"; +import { + compact, + eventMarkdown, + eventMetadata, + exceptions, + frameLine, + type Issue, + issueItem, + issueMetadata, + levelLook, + type SentryEvent, + since, + substatusLook, + topFrames, +} from "./lib.ts"; + +const ctx: Context = { + args: {}, + preferences: { organization: "acme", token: "sntryu_x", region: "de.sentry.io" }, + query: "", + signal: new AbortController().signal, +}; + +const NOW = new Date(); +const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); + +// Shaped like `GET /api/0/organizations/{org}/issues/`. +const issue: Issue = { + id: "4505897812", + shortId: "WEB-3F", + title: "TypeError: Cannot read properties of undefined (reading 'total')", + culprit: "renderTotal(app/components/Cart)", + permalink: "https://acme.sentry.io/issues/4505897812/", + level: "error", + status: "unresolved", + substatus: "regressed", + priority: "high", + platform: "javascript", + project: { id: "11", name: "web", slug: "web", platform: "javascript-react" }, + type: "error", + metadata: { + type: "TypeError", + value: "Cannot read properties of undefined (reading 'total')", + filename: "app/components/Cart.tsx", + }, + numComments: 0, + assignedTo: { type: "user", id: "7", name: "Ada Lovelace", email: "ada@acme.dev" }, + isUnhandled: true, + count: "1834", + userCount: 312, + firstSeen: hoursAgo(24 * 9), + lastSeen: hoursAgo(2), +}; + +// Shaped like `GET /api/0/organizations/{org}/issues/{id}/events/latest/`. +const event: SentryEvent = { + id: "9fac2ceed9344f2bbfdd1fdacb0ed9b1", + eventID: "9fac2ceed9344f2bbfdd1fdacb0ed9b1", + dateCreated: hoursAgo(2), + platform: "javascript", + tags: [ + { key: "environment", value: "production" }, + { key: "release", value: "web@2.14.0" }, + { key: "browser", value: "Firefox 131.0" }, + { key: "handled", value: "no" }, + { key: "url", value: "https://shop.acme.dev/cart" }, + ], + user: { id: "u-91", email: "buyer@example.com", ip_address: "203.0.113.4" }, + entries: [ + { + type: "exception", + data: { + values: [{ + type: "TypeError", + value: "Cannot read properties of undefined (reading 'total')", + stacktrace: { + frames: [ + { + filename: "node_modules/react-dom/cjs/react-dom.development.js", + function: "renderWithHooks", + lineNo: 16305, + colNo: 18, + inApp: false, + }, + { filename: "app/pages/Checkout.tsx", function: "Checkout", lineNo: 12, colNo: 5, inApp: true }, + { + filename: "app/components/Cart.tsx", + function: "renderTotal", + lineNo: 42, + colNo: 13, + inApp: true, + context: [[41, " const cart = useCart();"], [42, " return format(cart.summary.total);"], [43, "}"]], + }, + ], + }, + }], + }, + }, + { type: "breadcrumbs", data: { values: [] } }, + ], +}; + +Deno.test("settings: org slug, region", () => { + assertEquals([ + normalizeOrg("acme"), + normalizeOrg("https://acme.sentry.io/issues/"), + normalizeOrg("https://sentry.io/organizations/acme/"), + ], [ + "acme", + "acme", + "acme", + ]); + assertEquals([normalizeRegion(""), normalizeRegion("https://de.sentry.io/")], ["sentry.io", "de.sentry.io"]); + assertEquals(account(ctx.preferences).host, "de.sentry.io"); + let error: unknown; + try { + account({ organization: "acme", token: "t", region: "sentry.acme.internal" }); + } catch (e) { + error = e; + } + assertEquals(error instanceof AuthError, true); +}); + +Deno.test("formatting", () => { + const now = new Date("2026-10-27T12:00:00Z"); + assertEquals( + [ + since("2026-10-27T11:59:30Z", now), + since("2026-10-27T11:15:00Z", now), + since("2026-10-27T07:00:00Z", now), + since("2026-10-24T07:00:00Z", now), + ], + ["just now", "45m", "5h", "3d"], + ); + assertEquals([compact("1834"), compact(312), compact(45_600)], ["1.8k", "312", "46k"]); + assertEquals(levelLook("warning"), { icon: "bs-alert", tint: "orange", text: "Warning" }); + assertEquals(levelLook("error").icon, "bs-bug"); + assertEquals(substatusLook(issue), { text: "Regressed", color: "purple" }); +}); + +Deno.test("issue item and panel", () => { + const item = issueItem(issue, ctx); + assertEquals(item.section, "web"); + assertEquals(item.subtitle, "renderTotal(app/components/Cart)"); + assertEquals(item.icon, { source: "bs-bug", tint: "red" }); + assertEquals(item.accessories?.slice(0, 4), [ + { tag: "Regressed", color: "purple" }, + { tag: "Unhandled", color: "red" }, + { icon: "bs-activity", text: "1.8k", tooltip: "1834 events" }, + { icon: "bs-user", text: "312", tooltip: "312 users" }, + ]); + assertEquals(item.accessories?.[5].text, "2h"); + assertEquals(item.detail?.stats?.map((s) => s.value), ["1.8k", "312", "9d", "2h"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 7), [ + "Open in Sentry", + "Show Latest Event", + "Resolve", + "Resolve in Next Release", + "Ignore", + "Assign To", + "Assign to Me", + ]); + assertEquals(issueMetadata(issue).map((m) => m.label).slice(0, 6), [ + "Level", + "Status", + "State", + "Priority", + "Assignee", + "Handled", + ]); + const resolved = issueItem({ ...issue, status: "resolved" }, ctx); + assertEquals(resolved.actions?.[2].title, "Unresolve"); +}); + +Deno.test("stack trace: app frames, most recent first, with context", () => { + const [ex] = exceptions(event); + assertEquals(ex.type, "TypeError"); + assertEquals(topFrames(ex.frames).map((f) => f.function), ["renderTotal", "Checkout"]); + assertEquals(frameLine(ex.frames[0]), "renderTotal (app/components/Cart.tsx:42:13)"); + const md = eventMarkdown(issue, event); + assertStringIncludes(md, "## TypeError: Cannot read properties of undefined (reading 'total')"); + assertStringIncludes(md, "▸ renderTotal (app/components/Cart.tsx:42:13)\n▸ Checkout (app/pages/Checkout.tsx:12:5)"); + assertStringIncludes(md, "→ 42 return format(cart.summary.total);"); + const noTrace = eventMarkdown(issue, { + ...event, + entries: [{ type: "message", data: { formatted: "Payment timed out" } }], + }); + assertStringIncludes(noTrace, "```\nPayment timed out\n```"); +}); + +Deno.test("event tags", () => { + const lines = eventMetadata(event); + assertEquals(lines.map((m) => m.label), [ + "Event", + "When", + "Environment", + "Release", + "Browser", + "URL", + "Handled", + "User", + ]); + assertEquals(lines.find((m) => m.label === "Handled")?.color, "red"); + assertEquals(lines.find((m) => m.label === "URL")?.link, "https://shop.acme.dev/cart"); +}); + +Deno.test("a missing token becomes a setup row", async () => { + const items = await guarded(() => Promise.reject(new AuthError("Sentry rejected the auth token"))); + assertEquals([items[0].id, items[0].actions?.[0].title], ["setup", "Create Auth Token"]); +}); From aea647c7829e2275570a2357cd8c894fdc1bf0f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:00:09 +0000 Subject: [PATCH 04/10] Weather, Dictionary, Hacker News & RSS, Spotify and Games Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/dictionary/api.ts | 121 ++++ extensions/dictionary/backslash.toml | 33 + extensions/dictionary/commands/define.ts | 58 ++ extensions/dictionary/commands/translate.ts | 64 ++ extensions/dictionary/lib.ts | 610 ++++++++++++++++ extensions/dictionary/lib_test.ts | 257 +++++++ extensions/dictionary/views.ts | 193 ++++++ extensions/games/backslash.toml | 44 ++ extensions/games/commands/games.ts | 97 +++ extensions/games/items.ts | 115 ++++ extensions/games/lib.ts | 510 ++++++++++++++ extensions/games/lib_test.ts | 280 ++++++++ extensions/games/sources.ts | 272 ++++++++ extensions/hacker-news/api.ts | 179 +++++ extensions/hacker-news/backslash.toml | 47 ++ extensions/hacker-news/commands/add-feed.ts | 4 + .../hacker-news/commands/hacker-news.ts | 135 ++++ extensions/hacker-news/commands/rss.ts | 298 ++++++++ extensions/hacker-news/feeds.ts | 68 ++ extensions/hacker-news/lib.ts | 650 ++++++++++++++++++ extensions/hacker-news/lib_test.ts | 262 +++++++ extensions/spotify/api.ts | 90 +++ extensions/spotify/backslash.toml | 54 ++ extensions/spotify/commands/now-playing.ts | 194 ++++++ extensions/spotify/commands/playlists.ts | 42 ++ extensions/spotify/commands/search.ts | 38 + extensions/spotify/lib.ts | 289 ++++++++ extensions/spotify/lib_test.ts | 146 ++++ extensions/spotify/views.ts | 368 ++++++++++ extensions/weather/api.ts | 88 +++ extensions/weather/backslash.toml | 30 + extensions/weather/commands/weather.ts | 202 ++++++ extensions/weather/lib.ts | 546 +++++++++++++++ extensions/weather/lib_test.ts | 193 ++++++ 34 files changed, 6577 insertions(+) create mode 100644 extensions/dictionary/api.ts create mode 100644 extensions/dictionary/backslash.toml create mode 100644 extensions/dictionary/commands/define.ts create mode 100644 extensions/dictionary/commands/translate.ts create mode 100644 extensions/dictionary/lib.ts create mode 100644 extensions/dictionary/lib_test.ts create mode 100644 extensions/dictionary/views.ts create mode 100644 extensions/games/backslash.toml create mode 100644 extensions/games/commands/games.ts create mode 100644 extensions/games/items.ts create mode 100644 extensions/games/lib.ts create mode 100644 extensions/games/lib_test.ts create mode 100644 extensions/games/sources.ts create mode 100644 extensions/hacker-news/api.ts create mode 100644 extensions/hacker-news/backslash.toml create mode 100644 extensions/hacker-news/commands/add-feed.ts create mode 100644 extensions/hacker-news/commands/hacker-news.ts create mode 100644 extensions/hacker-news/commands/rss.ts create mode 100644 extensions/hacker-news/feeds.ts create mode 100644 extensions/hacker-news/lib.ts create mode 100644 extensions/hacker-news/lib_test.ts create mode 100644 extensions/spotify/api.ts create mode 100644 extensions/spotify/backslash.toml create mode 100644 extensions/spotify/commands/now-playing.ts create mode 100644 extensions/spotify/commands/playlists.ts create mode 100644 extensions/spotify/commands/search.ts create mode 100644 extensions/spotify/lib.ts create mode 100644 extensions/spotify/lib_test.ts create mode 100644 extensions/spotify/views.ts create mode 100644 extensions/weather/api.ts create mode 100644 extensions/weather/backslash.toml create mode 100644 extensions/weather/commands/weather.ts create mode 100644 extensions/weather/lib.ts create mode 100644 extensions/weather/lib_test.ts diff --git a/extensions/dictionary/api.ts b/extensions/dictionary/api.ts new file mode 100644 index 0000000..84f348e --- /dev/null +++ b/extensions/dictionary/api.ts @@ -0,0 +1,121 @@ +// Fetching definitions and translations. + +import { storage } from "@backslash/api"; +import { + enrich, + type Entry, + type FreeDictEntry, + fromFreeDictionary, + fromFrWikitext, + fromRest, + type Lang, + type MyMemoryResponse, + myMemoryUrl, + parseTranslation, + type RestResponse, + type Translation, +} from "./lib.ts"; + +// Wikimedia asks API clients to identify themselves. +const HEADERS = { "Api-User-Agent": "Backslash-Dictionary/1.0 (https://github.com/backslash-app/plugins)" }; + +async function getJson(url: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(url, { signal, headers: HEADERS }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`${new URL(url).host} is unreachable: ${(e as Error).message}`); + } + if (res.status === 404) { + await res.body?.cancel(); + return undefined; + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`${new URL(url).host} answered ${res.status}`); + } + return await res.json() as T; +} + +/** The word as typed, then in lower case ("House" → "house"). */ +function variants(word: string): string[] { + return [...new Set([word, word.toLowerCase()])]; +} + +async function english(word: string, signal?: AbortSignal): Promise { + for (const w of variants(word)) { + const title = encodeURIComponent(w.replaceAll(" ", "_")); + const [rest, free] = await Promise.all([ + getJson(`https://en.wiktionary.org/api/rest_v1/page/definition/${title}`, signal), + // Optional: pronunciation and synonyms. + getJson(`https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(w)}`, signal) + .catch((e) => { + if ((e as Error).name === "AbortError") throw e; + return undefined; + }), + ]); + const extra = free ? fromFreeDictionary(w, free) : undefined; + const main = rest ? fromRest(w, rest, "en") : undefined; + const entry = main ? enrich(main, extra) : extra; + if (entry) return entry; + } + return undefined; +} + +async function french(word: string, signal?: AbortSignal): Promise { + for (const w of variants(word)) { + const q = new URLSearchParams({ + action: "parse", + page: w, + prop: "wikitext", + format: "json", + formatversion: "2", + redirects: "1", + origin: "*", + }); + const res = await getJson<{ parse?: { title: string; wikitext: string }; error?: { code: string } }>( + `https://fr.wiktionary.org/w/api.php?${q}`, + signal, + ); + if (res?.parse) { + const entry = fromFrWikitext(res.parse.title, res.parse.wikitext); + if (entry) return entry; + } + } + return undefined; +} + +export function lookup(word: string, lang: Lang, signal?: AbortSignal): Promise { + return lang === "fr" ? french(word, signal) : english(word, signal); +} + +export async function translate(text: string, from: string, to: string, signal?: AbortSignal): Promise { + const res = await getJson(myMemoryUrl(text, from, to), signal); + if (!res) throw new Error("MyMemory: no answer"); + return parseTranslation(res); +} + +// Recent lookups + +export interface Recent { + word: string; + lang: Lang; + at: number; +} + +type Json = Parameters[1]; + +export async function recents(): Promise { + return (await storage.get("recent").catch(() => undefined) as Recent[] | undefined) ?? []; +} + +export async function remember(word: string, lang: Lang): Promise { + const list = (await recents()).filter((r) => !(r.word.toLowerCase() === word.toLowerCase() && r.lang === lang)); + await storage.set("recent", [{ word, lang, at: Date.now() }, ...list].slice(0, 30) as unknown as Json); +} + +export async function forget(word?: string, lang?: Lang): Promise { + const list = word ? (await recents()).filter((r) => !(r.word === word && r.lang === lang)) : []; + await storage.set("recent", list as unknown as Json); +} diff --git a/extensions/dictionary/backslash.toml b/extensions/dictionary/backslash.toml new file mode 100644 index 0000000..21175f0 --- /dev/null +++ b/extensions/dictionary/backslash.toml @@ -0,0 +1,33 @@ +[extension] +id = "dictionary" +title = "Dictionary" +description = "Definitions, examples, pronunciation and synonyms in English and French from Wiktionary, and quick translations" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# English: Wiktionary's REST API, with pronunciation and synonyms from dictionaryapi.dev. +# French: the French Wiktionary (MediaWiki API). Translations: MyMemory (free, no key). +[permissions] +net = ["en.wiktionary.org", "fr.wiktionary.org", "api.dictionaryapi.dev", "api.mymemory.translated.net"] + +[[commands]] +name = "define" +title = "Define Word" +description = "Definitions by part of speech, with examples; prefix with fr: or en: to switch language" +icon = "bs-book" +keywords = ["dictionary", "define", "definition", "meaning", "synonym", "wiktionary", "dictionnaire", "définition", "définir", "sens", "synonyme", "mot"] + +[[commands]] +name = "translate" +title = "Translate" +description = "English ↔ French and other languages, with MyMemory" +icon = "bs-globe" +keywords = ["translate", "translation", "traduire", "traduction", "traducteur", "english", "french", "anglais", "français"] + +[[preferences]] +name = "language" +title = "Language" +description = "\"en\" (English) or \"fr\" (French): the language of the words you look up, and the source language of translations" +default = "en" diff --git a/extensions/dictionary/commands/define.ts b/extensions/dictionary/commands/define.ts new file mode 100644 index 0000000..a356f4a --- /dev/null +++ b/extensions/dictionary/commands/define.ts @@ -0,0 +1,58 @@ +import { action, type Context, Icons, type Item, list, refresh } from "@backslash/api"; +import { forget, lookup, recents, remember } from "../api.ts"; +import { langOf, LANGS, parseQuery } from "../lib.ts"; +import { entryItems, notFound, status, wordView } from "../views.ts"; + +let lastStatus = ""; + +/** Recent lookups, when nothing is typed. */ +async function recentItems(): Promise { + return (await recents()).map((r) => ({ + id: `recent:${r.lang}:${r.word}`, + title: r.word, + icon: { source: Icons.history, tint: "gray" as const }, + section: "Recent", + accessories: [{ tag: r.lang.toUpperCase() }], + actions: [ + action("Look Up", () => wordView(r.word, r.lang), { icon: Icons.book }), + action("Remove from Recent", async () => { + await forget(r.word, r.lang); + return refresh(); + }, { icon: Icons.trash, destructive: true, shortcut: "x", section: "Recent" }), + action("Clear Recent", async () => { + await forget(); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: "Clear all recent lookups?", section: "Recent" }), + ], + })); +} + +async function search(query: string, ctx: Context): Promise { + const { lang, word } = parseQuery(query, langOf(ctx.preferences.language)); + if (!word) { + lastStatus = ""; + return recentItems(); + } + const entry = await lookup(word, lang, ctx.signal); + if (!entry) { + lastStatus = `${LANGS[lang]} · nothing found`; + return [notFound(word, lang)]; + } + lastStatus = status(entry); + // Remembered once the user stops typing on it. + setTimeout(() => { + if (!ctx.signal.aborted) remember(entry.word, lang).catch(() => {}); + }, 1500); + return entryItems(entry); +} + +export default list({ + placeholder: "Look up a word… (fr:maison, en:house)", + empty: { + title: "Type a word to look up", + description: "Prefix it with fr: or en: to switch language; the default is set in the extension settings", + icon: Icons.book, + }, + status: (items) => items.some((i) => i.section === "Recent") ? `${items.length} recent lookups` : lastStatus, + search, +}); diff --git a/extensions/dictionary/commands/translate.ts b/extensions/dictionary/commands/translate.ts new file mode 100644 index 0000000..5a356f8 --- /dev/null +++ b/extensions/dictionary/commands/translate.ts @@ -0,0 +1,64 @@ +import { action, type Context, copy, Icons, type Item, list, meta, paste } from "@backslash/api"; +import { translate } from "../api.ts"; +import { type Lang, langOf, LANGS, parseTranslateQuery } from "../lib.ts"; +import { translationView, wordView } from "../views.ts"; + +const name = (code: string) => LANGS[code as Lang] ?? code.toUpperCase(); + +async function search(query: string, ctx: Context): Promise { + const base = langOf(ctx.preferences.language); + const { from, to, text } = parseTranslateQuery(query, base, base === "fr" ? "en" : "fr"); + if (!text) return []; + const t = await translate(text, from, to, ctx.signal); + const direction = `${name(from)} → ${name(to)}`; + const single = !/\s/.test(t.text) && (to === "en" || to === "fr"); + const common = (result: string) => [ + copy(result, "Copy Translation"), + paste(result, "Paste Translation"), + action("Translate Back", () => translationView(result, to, from), { icon: Icons.refresh, shortcut: "b" }), + ...(single + ? [action("Look Up in Dictionary", () => wordView(result, to as Lang), { icon: Icons.book, shortcut: "d" })] + : []), + copy(text, "Copy Original", { section: "Copy" }), + ]; + return [ + { + id: "main", + title: t.text, + subtitle: text, + icon: { source: Icons.globe, tint: "blue" }, + section: direction, + accessories: [{ tag: `${from.toUpperCase()} → ${to.toUpperCase()}` }], + detail: { + markdown: `# ${t.text}\n\n> ${text}`, + metadata: [ + meta.text("From", name(from), { icon: Icons.globe }), + meta.text("To", name(to), { icon: Icons.globe }), + ...(t.match ? [meta.progress("Match", t.match)] : []), + meta.text("Source", "MyMemory"), + ], + }, + actions: common(t.text), + }, + ...t.alternatives.map((a, i) => ({ + id: `alt:${i}`, + title: a.text, + subtitle: a.source || undefined, + icon: { source: Icons.globe, tint: "gray" as const }, + section: "Other Translations", + accessories: a.quality ? [{ text: `${a.quality} %`, tooltip: "Quality" }] : [], + actions: common(a.text), + })), + ]; +} + +export default list({ + placeholder: "Translate… (en>fr hello, >de bonjour)", + empty: { + title: "Type something to translate", + description: "English ↔ French by default; prefix with en>fr, fr>en or >de to pick languages", + icon: Icons.globe, + }, + status: "MyMemory · free, no account", + search, +}); diff --git a/extensions/dictionary/lib.ts b/extensions/dictionary/lib.ts new file mode 100644 index 0000000..4b7adde --- /dev/null +++ b/extensions/dictionary/lib.ts @@ -0,0 +1,610 @@ +// Dictionary sources and their mapping to one model. Pure, tested in lib_test.ts. +// +// - English: the Wiktionary REST definition endpoint (en.wiktionary.org only; +// other Wiktionaries don't have it), with pronunciation and synonyms from +// dictionaryapi.dev, which also serves as a fallback. +// - French: fr.wiktionary.org's wikitext (MediaWiki parse API), parsed here: +// French definitions, examples, gender, IPA and synonyms. + +import { type Color, Icons } from "@backslash/api"; + +export type Lang = "en" | "fr"; + +export const LANGS: Record = { en: "English", fr: "Français" }; + +export function langOf(pref: unknown): Lang { + return String(pref ?? "").trim().toLowerCase().startsWith("fr") ? "fr" : "en"; +} + +/** "fr:maison" → French "maison"; "house" → the default language. */ +export function parseQuery(query: string, fallback: Lang): { lang: Lang; word: string } { + const m = /^\s*(en|fr)\s*:\s*(.*)$/i.exec(query); + if (m) return { lang: m[1].toLowerCase() as Lang, word: m[2].trim() }; + return { lang: fallback, word: query.trim() }; +} + +export interface Sense { + definition: string; + examples: string[]; +} + +export interface Group { + /** Part of speech as the source names it: "Noun", "Nom commun". */ + pos: string; + gender?: string; + senses: Sense[]; + synonyms: string[]; + antonyms: string[]; +} + +export interface Entry { + word: string; + lang: Lang; + ipa: string[]; + groups: Group[]; + etymology?: string; + /** The page on Wiktionary. */ + url: string; + source: string; +} + +export function wiktionaryUrl(word: string, lang: Lang): string { + const title = encodeURIComponent(word.replaceAll(" ", "_")); + return lang === "fr" + ? `https://fr.wiktionary.org/wiki/${title}#fr` + : `https://en.wiktionary.org/wiki/${title}#English`; +} + +// --------------------------------------------------------------------------- +// Text + +const ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + ndash: "–", + mdash: "—", + hellip: "…", + laquo: "«", + raquo: "»", + rsquo: "’", + lsquo: "‘", + rdquo: "”", + ldquo: "“", + eacute: "é", + egrave: "è", + agrave: "à", + ccedil: "ç", + middot: "·", + thinsp: " ", + "#039": "'", +}; + +export function decodeEntities(s: string): string { + return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : all; + } + return ENTITIES[e.toLowerCase()] ?? all; + }); +} + +/** HTML fragment → one line of text. */ +export function htmlToText(html: string): string { + return tidy(decodeEntities(html.replace(//gi, "").replace(/<[^>]+>/g, ""))); +} + +function tidy(s: string): string { + return s.replace(/\s+/g, " ").replace(/\s+([,.;:!?)])/g, "$1").replace(/\(\s+/g, "(").replace(/\(\)/g, "").trim(); +} + +const unique = (xs: string[]) => [...new Set(xs.map((x) => x.trim()).filter(Boolean))]; + +// --------------------------------------------------------------------------- +// English: Wiktionary REST API + +interface RestDefinition { + definition?: string; + examples?: string[]; + parsedExamples?: { example: string }[]; +} +export type RestResponse = Record; + +/** Merges the groups of a part of speech (several etymologies make several). */ +function mergeGroups(groups: Group[]): Group[] { + const out: Group[] = []; + for (const g of groups) { + const same = out.find((o) => o.pos === g.pos && o.gender === g.gender); + if (same) { + same.senses.push(...g.senses); + same.synonyms = unique([...same.synonyms, ...g.synonyms]); + same.antonyms = unique([...same.antonyms, ...g.antonyms]); + } else out.push({ ...g, senses: [...g.senses] }); + } + return out.filter((g) => g.senses.length); +} + +export function fromRest(word: string, res: RestResponse, lang: Lang = "en"): Entry | undefined { + const groups = (res[lang] ?? []).map((g): Group => ({ + pos: g.partOfSpeech, + senses: (g.definitions ?? []).map((d) => ({ + definition: htmlToText(d.definition ?? ""), + examples: unique( + (d.parsedExamples?.length ? d.parsedExamples.map((e) => e.example) : d.examples ?? []).map(htmlToText), + ).slice(0, 3), + })).filter((s) => s.definition), + synonyms: [], + antonyms: [], + })); + const merged = mergeGroups(groups); + if (!merged.length) return undefined; + return { word, lang, ipa: [], groups: merged, url: wiktionaryUrl(word, lang), source: "Wiktionary" }; +} + +// --------------------------------------------------------------------------- +// English: dictionaryapi.dev (IPA, synonyms; definitions as a fallback) + +export interface FreeDictEntry { + word: string; + phonetic?: string; + phonetics?: { text?: string; audio?: string }[]; + meanings?: { + partOfSpeech: string; + definitions: { definition: string; example?: string; synonyms?: string[]; antonyms?: string[] }[]; + synonyms?: string[]; + antonyms?: string[]; + }[]; +} + +const capitalize = (s: string) => s ? s[0].toUpperCase() + s.slice(1) : s; + +export function fromFreeDictionary(word: string, res: FreeDictEntry[]): Entry | undefined { + if (!Array.isArray(res) || !res.length) return undefined; + const ipa = unique(res.flatMap((e) => [e.phonetic ?? "", ...(e.phonetics ?? []).map((p) => p.text ?? "")])); + const groups = mergeGroups(res.flatMap((e) => + (e.meanings ?? []).map((m): Group => ({ + pos: capitalize(m.partOfSpeech), + senses: m.definitions.map((d) => ({ definition: d.definition.trim(), examples: d.example ? [d.example] : [] })), + synonyms: unique([...(m.synonyms ?? []), ...m.definitions.flatMap((d) => d.synonyms ?? [])]), + antonyms: unique([...(m.antonyms ?? []), ...m.definitions.flatMap((d) => d.antonyms ?? [])]), + })) + )); + if (!groups.length) return undefined; + return { + word: res[0].word || word, + lang: "en", + ipa, + groups, + url: wiktionaryUrl(word, "en"), + source: "Free Dictionary", + }; +} + +/** Wiktionary's definitions with dictionaryapi.dev's pronunciation and synonyms. */ +export function enrich(entry: Entry, extra: Entry | undefined): Entry { + if (!extra) return entry; + return { + ...entry, + ipa: unique([...entry.ipa, ...extra.ipa]), + groups: entry.groups.map((g) => { + const match = extra.groups.find((x) => x.pos.toLowerCase() === g.pos.toLowerCase()); + return match + ? { + ...g, + synonyms: unique([...g.synonyms, ...match.synonyms]), + antonyms: unique([...g.antonyms, ...match.antonyms]), + } + : g; + }), + }; +} + +// --------------------------------------------------------------------------- +// French: fr.wiktionary.org wikitext + +/** `[[cible|texte]]` → texte, `[[mot]]` → mot; files and categories removed. */ +export function stripLinks(s: string): string { + return s + .replace(/\[\[(?:Fichier|File|Image|Catégorie|Category):[^\]]*\]\]/gi, "") + .replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g, "$2") + .replace(/\[\[([^\]]*)\]\]/g, (_, t: string) => t.replace(/#.*$/, "")) + .replace(/\[https?:\/\/\S+\s+([^\]]+)\]/g, "$1") + .replace(/\[https?:\/\/[^\]]+\]/g, ""); +} + +const DROP = new Set([ + "réf", + "ref", + "réf ?", + "source", + "siècle", + "siècle2", + "date", + "ancre", + "?", + "m", + "f", + "mf", + "n", + "c", + "msing", + "fsing", + "mplur", + "fplur", + "invar", + "sp", + "p", + "s", + "réf-souhaitée", + "rare-réf", + "lien-ancre-étym", +]); +const FIRST = new Set([ + "lien", + "l", + "w", + "smcp", + "petites capitales", + "pc", + "nobr", + "exemple", + "formatnum", + "lien-ancre", +]); +const SUPERSCRIPT = new Set(["e", "er", "re", "ère", "es", "ers", "res", "ème", "nd", "nde"]); +const LABELS = new Set(["term", "info lex", "lexique", "contexte", "région", "registre", "sens"]); +const ETYMOLOGY_LANGS: Record = { + la: "latin", + grc: "grec ancien", + fro: "ancien français", + frm: "moyen français", + frk: "francique", + en: "anglais", + de: "allemand", + it: "italien", + es: "espagnol", + pt: "portugais", + nl: "néerlandais", + ar: "arabe", + ja: "japonais", + gaul: "gaulois", +}; +const isLangCode = (s: string) => /^[a-z]{2,3}(-[a-z]+)?$/.test(s.trim()); + +function renderTemplate(body: string): string { + const parts = body.split("|"); + const name = parts[0].trim().toLowerCase(); + const positional = parts.slice(1).filter((p) => !/^\s*[\w-]+\s*=/.test(p)).map((p) => p.trim()); + const named = Object.fromEntries( + parts.slice(1).map((p) => /^\s*([\w-]+)\s*=(.*)$/s.exec(p)).filter((m) => m).map((m) => [m![1], m![2].trim()]), + ); + if (DROP.has(name) || name.startsWith("ébauche")) return ""; + if (SUPERSCRIPT.has(name)) return name; + if (FIRST.has(name)) return positional[0] ?? ""; + if (name === "lang") return positional[1] ?? positional[0] ?? ""; + if (name === "étyl" || name === "étylp") { + const from = ETYMOLOGY_LANGS[positional[0]] ?? positional[0] ?? ""; + const word = named.mot ?? positional[2] ?? ""; + return [from, word ? `${word}` : ""].filter(Boolean).join(" "); + } + if (name === "lien web") return named.titre ?? ""; + if (name === "pron" || name === "phon") return positional[0] ? `\\${positional[0]}\\` : ""; + if (name === "variante de" || name === "variante ortho de") return `Variante de ${positional[0] ?? ""}`; + if (LABELS.has(name)) { + const labels = positional.filter((p) => p && !isLangCode(p)); + return labels.length ? `(${labels.join(", ")})` : ""; + } + // Domain and register labels: {{familier|fr}}, {{figuré|fr}}, {{par extension}}. + if (/^[\p{L}][\p{L} '-]+$/u.test(name) && positional.every(isLangCode) && name.length >= 4) { + return `(${capitalize(name)})`; + } + return ""; +} + +/** Expands templates from the innermost out. */ +export function expandTemplates(s: string): string { + let out = s; + for (let i = 0; i < 20 && out.includes("{{"); i++) { + const next = out.replace(/\{\{([^{}]*)\}\}/g, (_, body: string) => renderTemplate(body)); + if (next === out) break; + out = next; + } + return out.replace(/\{\{|\}\}/g, ""); +} + +/** One line of wikitext → plain text. */ +export function cleanWikitext(s: string): string { + const noRefs = s + .replace(//g, "") + .replace(/]*\/>/gi, "") + .replace(/]*>[\s\S]*?<\/ref>/gi, ""); + const text = expandTemplates(stripLinks(noRefs)) + .replace(/'''?/g, "") + .replace(/<[^>]+>/g, ""); + return tidy(decodeEntities(text)).replace(/^[,;:.\s]+/, "").replace(/\(\s*\)/g, "").trim(); +} + +const POS: Record = { + nom: "Nom commun", + "nom commun": "Nom commun", + "nom propre": "Nom propre", + "nom-pr": "Nom propre", + prénom: "Prénom", + verbe: "Verbe", + adjectif: "Adjectif", + adj: "Adjectif", + adverbe: "Adverbe", + adv: "Adverbe", + pronom: "Pronom", + préposition: "Préposition", + prép: "Préposition", + conjonction: "Conjonction", + conj: "Conjonction", + interjection: "Interjection", + interj: "Interjection", + article: "Article", + "article défini": "Article défini", + "article indéfini": "Article indéfini", + "adjectif possessif": "Adjectif possessif", + "adjectif démonstratif": "Adjectif démonstratif", + "pronom personnel": "Pronom personnel", + "pronom relatif": "Pronom relatif", + "locution-phrase": "Locution-phrase", + "locution nominale": "Locution nominale", + onomatopée: "Onomatopée", + préfixe: "Préfixe", + suffixe: "Suffixe", + symbole: "Symbole", + "nom scientifique": "Nom scientifique", + numéral: "Adjectif numéral", +}; + +const GENDERS: Record = { + m: "masculin", + f: "féminin", + mf: "masculin et féminin", + "mf ?": "masculin ou féminin", + n: "neutre", +}; + +const SECTION = /^(={2,6})\s*\{\{\s*S\s*\|([^}]*)\}\}\s*\1\s*$/; +const LANGUAGE = /^==\s*\{\{\s*langue\s*\|\s*([^}|]+?)\s*\}\}\s*==\s*$/; + +/** Words of a list line: `* [[demeure]], [[logis]]` → demeure, logis. */ +function listWords(line: string): string[] { + const links = [...line.matchAll(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]|\{\{\s*lien\s*\|([^|}]+)/g)] + .map((m) => (m[1] ?? m[2]).trim()) + .filter((w) => !/^(Fichier|File|Catégorie|Category|Annexe|Thésaurus|Wiktionnaire|w):/i.test(w)); + if (links.length) return links; + const text = cleanWikitext(line.replace(/^\*+:?\s*/, "")); + return text ? [text] : []; +} + +export function fromFrWikitext(title: string, wikitext: string, lang = "fr"): Entry | undefined { + const lines = wikitext.split("\n"); + // The lines of the language section. + let inLang = false; + const body: string[] = []; + for (const line of lines) { + const l = LANGUAGE.exec(line.trim()); + if (l) { + inLang = l[1] === lang; + continue; + } + if (inLang) body.push(line); + } + if (!body.length) return undefined; + + const groups: Group[] = []; + const ipa: string[] = []; + let etymology: string | undefined; + let current: Group | undefined; + /** What the lines under the last heading are: a part of speech, a list of words, or something else. */ + let mode: "pos" | "synonyms" | "antonyms" | "etymology" | "other" = "other"; + let headwordSeen = false; + + for (const raw of body) { + const line = raw.trimEnd(); + for (const m of line.matchAll(/\{\{\s*pron\s*\|([^|}]+)\|\s*(?:lang=)?fr\s*\}\}/g)) { + if (m[1].trim()) ipa.push(m[1].trim()); + } + const s = SECTION.exec(line.trim()); + if (s) { + const params = s[2].split("|").map((p) => p.trim()); + const name = params[0].toLowerCase(); + const positional = params.slice(1).filter((p) => !p.includes("=")); + const flags = params.slice(1).filter((p) => p.includes("=")); + if (positional[0] === lang) { + const base = POS[name] ?? capitalize(name); + const locution = flags.some((f) => /^locution\s*=\s*oui/.test(f)); + const flexion = params.slice(1).some((f) => /^flexion\b/.test(f)); + const pos = flexion ? `Forme de ${base.toLowerCase()}` : locution ? `Locution (${base.toLowerCase()})` : base; + current = { pos, senses: [], synonyms: [], antonyms: [] }; + groups.push(current); + mode = "pos"; + headwordSeen = false; + } else if (name === "synonymes" || name === "quasi-synonymes") mode = current ? "synonyms" : "other"; + else if (name === "antonymes") mode = current ? "antonyms" : "other"; + else if (name === "étymologie") mode = "etymology"; + else { + mode = "other"; + // A new level-3 section ends the part of speech. + if (s[1].length <= 3) current = undefined; + } + continue; + } + if (mode === "etymology" && !etymology && /^:/.test(line)) { + etymology = cleanWikitext(line.replace(/^:+\s*/, "")) || undefined; + continue; + } + if (!current) continue; + if (mode === "pos") { + if (!headwordSeen && /^'''/.test(line)) { + headwordSeen = true; + const g = [...line.matchAll(/\{\{\s*(mf \?|mf|m|f|n)\s*(?:\|[^}]*)?\}\}/g)].map((m) => GENDERS[m[1]]); + if (g.length) current.gender = g[0]; + continue; + } + const def = /^#+(?![*:])\s*(.*)$/.exec(line); + if (def) { + const text = cleanWikitext(def[1]); + if (text) current.senses.push({ definition: text, examples: [] }); + continue; + } + const ex = /^#+\*(?!:)\s*(.*)$/.exec(line); + if (ex && current.senses.length) { + const exemple = /\{\{\s*exemple\s*\|/.test(ex[1]); + const text = cleanWikitext(exemple ? ex[1] : ex[1].replace(/\{\{\s*source\s*\|[\s\S]*\}\}\s*$/, "")); + const sense = current.senses[current.senses.length - 1]; + if (text && sense.examples.length < 3) sense.examples.push(text); + } + continue; + } + if ((mode === "synonyms" || mode === "antonyms") && /^\*/.test(line)) { + const words = listWords(line); + if (mode === "synonyms") current.synonyms = unique([...current.synonyms, ...words]); + else current.antonyms = unique([...current.antonyms, ...words]); + } + } + const kept = groups.filter((g) => g.senses.length); + if (!kept.length) return undefined; + return { + word: title, + lang: "fr", + ipa: unique(ipa), + groups: kept, + etymology, + url: wiktionaryUrl(title, "fr"), + source: "Wiktionnaire", + }; +} + +// --------------------------------------------------------------------------- +// Presentation + +/** A color per part of speech, the same in both languages. */ +export function posColor(pos: string): Color { + const p = pos.toLowerCase(); + if (/^(forme|locution)/.test(p)) return "gray"; + if (/noun|nom|prénom/.test(p)) return "blue"; + if (/verb/.test(p)) return "green"; + if (/adj/.test(p)) return "purple"; + if (/adv/.test(p)) return "orange"; + if (/pronoun|pronom/.test(p)) return "teal"; + if (/interj|onomat/.test(p)) return "pink"; + return "gray"; +} + +export function posIcon(pos: string): { source: string; tint: Color } { + return { source: Icons.book, tint: posColor(pos) }; +} + +/** "/haʊs/" or "\mɛ.zɔ̃\" as the source writes it. */ +export function ipaLine(entry: Entry): string | undefined { + if (!entry.ipa.length) return undefined; + return entry.ipa.slice(0, 2).map((p) => entry.lang === "fr" && !/^[\\/[]/.test(p) ? `\\${p}\\` : p).join(" · "); +} + +/** Text to copy for one sense. */ +export function senseText(entry: Entry, group: Group, sense: Sense): string { + return `${entry.word} (${group.pos.toLowerCase()}): ${sense.definition}`; +} + +/** The whole entry as markdown, for "Show Full Entry". */ +export function entryMarkdown(entry: Entry): string { + const ipa = ipaLine(entry); + const out = [`# ${entry.word}`, ""]; + if (ipa) out.push(`*${ipa}*`, ""); + for (const g of entry.groups) { + out.push(`## ${g.pos}${g.gender ? ` · ${g.gender}` : ""}`, ""); + g.senses.forEach((s, i) => { + out.push(`${i + 1}. ${s.definition}`); + for (const e of s.examples) out.push(`> ${e}`); + }); + out.push(""); + if (g.synonyms.length) { + out.push(`**${entry.lang === "fr" ? "Synonymes" : "Synonyms"}:** ${g.synonyms.join(", ")}`, ""); + } + if (g.antonyms.length) { + out.push(`**${entry.lang === "fr" ? "Antonymes" : "Antonyms"}:** ${g.antonyms.join(", ")}`, ""); + } + } + if (entry.etymology) out.push(`## ${entry.lang === "fr" ? "Étymologie" : "Etymology"}`, "", entry.etymology, ""); + out.push("---", `Source: [${entry.source}](${entry.url})`); + return out.join("\n"); +} + +/** The whole entry as plain text, to copy. */ +export function entryText(entry: Entry): string { + const lines = [entry.word + (entry.ipa[0] ? ` ${ipaLine(entry)}` : "")]; + for (const g of entry.groups) { + lines.push("", g.pos + (g.gender ? ` (${g.gender})` : "")); + g.senses.forEach((s, i) => lines.push(`${i + 1}. ${s.definition}`)); + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Translation: MyMemory + +export interface Translation { + text: string; + match: number; + alternatives: { text: string; quality: number; source: string }[]; +} + +export interface MyMemoryResponse { + responseData?: { translatedText?: string; match?: number }; + responseStatus?: number | string; + responseDetails?: string; + quotaFinished?: boolean; + matches?: { + translation: string; + quality?: number | string; + match?: number; + "created-by"?: string; + reference?: string; + }[]; +} + +export function parseTranslation(res: MyMemoryResponse): Translation { + const text = decodeEntities(res.responseData?.translatedText ?? "").trim(); + if (Number(res.responseStatus) !== 200 || /^MYMEMORY WARNING/i.test(text) || res.quotaFinished) { + throw new Error(`MyMemory: ${res.responseDetails || text || "translation failed"}`); + } + const seen = new Set([text.toLowerCase()]); + const alternatives = (res.matches ?? []) + .map((m) => ({ + text: decodeEntities(m.translation ?? "").trim(), + quality: Number(m.quality ?? 0), + source: m["created-by"] ?? "", + })) + .filter((m) => m.text && !seen.has(m.text.toLowerCase()) && seen.add(m.text.toLowerCase())); + return { text, match: Number(res.responseData?.match ?? 0), alternatives: alternatives.slice(0, 6) }; +} + +/** + * "en>fr hello", ">de hello", "hello" (default direction). Two-letter codes, + * any language MyMemory knows. + */ +export function parseTranslateQuery( + query: string, + from: string, + to: string, +): { from: string; to: string; text: string } { + const m = /^\s*([a-z]{2})?\s*>\s*([a-z]{2})\s+(.*)$/is.exec(query); + if (m) { + const target = m[2].toLowerCase(); + const source = m[1]?.toLowerCase() ?? (target === from ? to : from); + return { from: source, to: target, text: m[3].trim() }; + } + return { from, to, text: query.trim() }; +} + +export function myMemoryUrl(text: string, from: string, to: string): string { + return `https://api.mymemory.translated.net/get?${new URLSearchParams({ q: text, langpair: `${from}|${to}` })}`; +} diff --git a/extensions/dictionary/lib_test.ts b/extensions/dictionary/lib_test.ts new file mode 100644 index 0000000..4f92b45 --- /dev/null +++ b/extensions/dictionary/lib_test.ts @@ -0,0 +1,257 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { + cleanWikitext, + decodeEntities, + enrich, + entryMarkdown, + fromFreeDictionary, + fromFrWikitext, + fromRest, + htmlToText, + ipaLine, + langOf, + myMemoryUrl, + parseQuery, + parseTranslateQuery, + parseTranslation, + posColor, + type RestResponse, + wiktionaryUrl, +} from "./lib.ts"; + +// Trimmed from en.wiktionary.org/api/rest_v1/page/definition/house +const REST: RestResponse = { + en: [ + { + partOfSpeech: "Noun", + language: "English", + definitions: [ + { + definition: + 'A
    structure built or serving as an abode of human beings.', + parsedExamples: [{ example: "This is my house and my family's ancestral home." }], + examples: ["This is my house and my family's ancestral home."], + }, + { definition: "" }, + { + definition: + '(astrology) One of the twelve divisions.', + }, + ], + }, + { + partOfSpeech: "Verb", + language: "English", + definitions: [{ + definition: "To keep within a structure or container.", + examples: ["The car is housed in the garage."], + }], + }, + { + partOfSpeech: "Noun", + language: "English", + definitions: [{ definition: "(slang) House music." }], + }, + ], + fr: [{ partOfSpeech: "Noun", language: "French", definitions: [{ definition: "house music" }] }], +}; + +const FREE = [ + { + word: "house", + phonetic: "/haʊs/", + phonetics: [{ text: "/haʊs/", audio: "" }, { text: "/haʊz/" }], + meanings: [ + { + partOfSpeech: "noun", + definitions: [{ definition: "A structure serving as an abode.", synonyms: ["home"] }], + synonyms: ["dwelling", "home"], + antonyms: [], + }, + { partOfSpeech: "verb", definitions: [{ definition: "To keep within a structure.", example: "to house a car" }] }, + ], + }, +]; + +// Shaped like fr.wiktionary.org's wikitext of "maison". +const MAISON = `{{voir|Maison}} +== {{langue|fr}} == +=== {{S|étymologie}} === +: Du {{étyl|la|fr|mot=mansio}}, « séjour, demeure ». + +=== {{S|nom|fr}} === +{{fr-rég|mɛ.zɔ̃}} +'''maison''' {{pron|mɛ.zɔ̃|fr}} {{f}} +# [[bâtiment|Bâtiment]] servant de [[logement]] à une [[famille]].Une note. +#* {{exemple|Il habite une '''maison''' de campagne.|lang=fr}} +#* ''Les '''maisons''' du village étaient basses.'' {{source|{{w|Victor Hugo}}, ''Les Misérables'', 1862}} +#*: (traduction ignorée) +# {{figuré|fr}} [[famille|Famille]], [[lignée]]. +#* {{exemple|lang=fr}} +## {{par extension}} [[entreprise|Entreprise]] [[commercial]]e. +# {{lexique|astrologie|fr}} Chacune des douze divisions du ciel. + +==== {{S|synonymes}} ==== +* [[demeure]] +* [[domicile]], [[logis]] +* {{lien|foyer|fr}} + +==== {{S|antonymes}} ==== +* [[cabane#fr|cabane]] + +==== {{S|traductions}} ==== +{{trad-début}} +* {{T|en}} : {{trad+|en|house}} +{{trad-fin}} + +=== {{S|adjectif|fr}} === +'''maison''' {{pron|mɛ.zɔ̃|fr}} {{invar}} +# {{familier|fr}} Fait sur place, de façon [[artisanal]]e. +#* {{exemple|Une tarte '''maison'''.|lang=fr}} + +=== {{S|prononciation}} === +* {{pron|mɛ.zɔ̃|fr}} + +== {{langue|en}} == +=== {{S|nom|en}} === +'''maison''' {{pron|meɪˈzɒn|en}} +# Maisonnette. +`; + +Deno.test("queries and languages", () => { + assertEquals(parseQuery("fr:maison", "en"), { lang: "fr", word: "maison" }); + assertEquals(parseQuery(" EN : House ", "fr"), { lang: "en", word: "House" }); + assertEquals(parseQuery("pomme de terre", "fr"), { lang: "fr", word: "pomme de terre" }); + assertEquals(parseQuery("de:haus", "en"), { lang: "en", word: "de:haus" }); + assertEquals([langOf("fr"), langOf("Français"), langOf(undefined)], ["fr", "fr", "en"]); + assertEquals(wiktionaryUrl("pomme de terre", "fr"), "https://fr.wiktionary.org/wiki/pomme_de_terre#fr"); +}); + +Deno.test("HTML and entities", () => { + assertEquals(decodeEntities("<a> & 'x' é &unknown;"), " & 'x' é &unknown;"); + assertEquals( + htmlToText('(astrology) One of twelve .'), + "(astrology) One of twelve.", + ); +}); + +Deno.test("English: Wiktionary REST, merged by part of speech", () => { + const entry = fromRest("house", REST)!; + assertEquals(entry.groups.map((g) => g.pos), ["Noun", "Verb"]); + assertEquals(entry.groups[0].senses.length, 3); + assertEquals(entry.groups[0].senses[0].definition, "A structure built or serving as an abode of human beings."); + assertEquals(entry.groups[0].senses[0].examples, ["This is my house and my family's ancestral home."]); + assertEquals(entry.groups[0].senses[1].definition, "(astrology) One of the twelve divisions."); + assertEquals(entry.groups[1].senses[0].examples, ["The car is housed in the garage."]); + assertEquals(entry.url, "https://en.wiktionary.org/wiki/house#English"); + assertEquals(fromRest("x", {}), undefined); +}); + +Deno.test("English: dictionaryapi.dev adds pronunciation and synonyms", () => { + const extra = fromFreeDictionary("house", FREE)!; + assertEquals(extra.ipa, ["/haʊs/", "/haʊz/"]); + assertEquals(extra.groups[0].synonyms, ["dwelling", "home"]); + const entry = enrich(fromRest("house", REST)!, extra); + assertEquals(entry.ipa, ["/haʊs/", "/haʊz/"]); + assertEquals(entry.groups[0].synonyms, ["dwelling", "home"]); + assertEquals(entry.groups[1].synonyms, []); + assertEquals(ipaLine(entry), "/haʊs/ · /haʊz/"); + assertEquals(fromFreeDictionary("x", []), undefined); +}); + +Deno.test("wikitext cleanup", () => { + assertEquals( + cleanWikitext("[[bâtiment|Bâtiment]] de [[logement#fr|logement]].x"), + "Bâtiment de logement.", + ); + assertEquals(cleanWikitext("{{figuré|fr}} [[famille|Famille]]"), "(Figuré) Famille"); + assertEquals(cleanWikitext("{{lexique|cuisine|fr}} Plat"), "(cuisine) Plat"); + assertEquals(cleanWikitext("Le XIX{{e}} siècle, {{w|Victor Hugo}}"), "Le XIXe siècle, Victor Hugo"); + assertEquals(cleanWikitext("{{exemple|Un '''mot''' {{lien|rare|fr}}.|sens=x|lang=fr}}"), "Un mot rare."); + assertEquals(cleanWikitext("{{unknown|42}}Texte  ici "), "Texte ici"); +}); + +Deno.test("French: Wiktionnaire wikitext", () => { + const entry = fromFrWikitext("maison", MAISON)!; + assertEquals(entry.ipa, ["mɛ.zɔ̃"]); + assertEquals(ipaLine(entry), "\\mɛ.zɔ̃\\"); + assertEquals(entry.etymology, "Du latin mansio, « séjour, demeure »."); + assertEquals(entry.groups.map((g) => g.pos), ["Nom commun", "Adjectif"]); + const nom = entry.groups[0]; + assertEquals(nom.gender, "féminin"); + assertEquals(nom.senses.map((s) => s.definition), [ + "Bâtiment servant de logement à une famille.", + "(Figuré) Famille, lignée.", + "(Par extension) Entreprise commerciale.", + "(astrologie) Chacune des douze divisions du ciel.", + ]); + assertEquals(nom.senses[0].examples, ["Il habite une maison de campagne.", "Les maisons du village étaient basses."]); + assertEquals(nom.senses[1].examples, []); + assertEquals(nom.synonyms, ["demeure", "domicile", "logis", "foyer"]); + assertEquals(nom.antonyms, ["cabane"]); + assertEquals(entry.groups[1].senses[0].definition, "(Familier) Fait sur place, de façon artisanale."); + assertEquals(entry.groups[1].gender, undefined); + assertEquals(entry.url, "https://fr.wiktionary.org/wiki/maison#fr"); + // Other languages of the page are ignored; pages without French give nothing. + assertEquals(entry.groups.some((g) => g.senses.some((s) => s.definition === "Maisonnette.")), false); + assertEquals(fromFrWikitext("house", "== {{langue|en}} ==\n=== {{S|nom|en}} ===\n# A house."), undefined); +}); + +Deno.test("French: inflected forms and locutions", () => { + const text = `== {{langue|fr}} == +=== {{S|nom|fr|flexion}} === +'''maisons''' {{pron|mɛ.zɔ̃|fr}} {{f}} +# ''Pluriel de'' [[maison]]. +=== {{S|verbe|fr|num=1}} === +'''manger''' {{pron|mɑ̃.ʒe|fr}} {{t|fr}} +# [[mâcher|Mâcher]] et [[avaler]].`; + const entry = fromFrWikitext("maisons", text)!; + assertEquals(entry.groups.map((g) => g.pos), ["Forme de nom commun", "Verbe"]); + assertEquals(entry.groups[0].senses[0].definition, "Pluriel de maison."); + assertEquals(entry.ipa, ["mɛ.zɔ̃", "mɑ̃.ʒe"]); +}); + +Deno.test("presentation", () => { + assertEquals([posColor("Noun"), posColor("Nom commun"), posColor("Verbe"), posColor("Adjective")], [ + "blue", + "blue", + "green", + "purple", + ]); + assertEquals(posColor("Forme de nom commun"), "gray"); + const md = entryMarkdown(fromFrWikitext("maison", MAISON)!); + assertStringIncludes(md, "## Nom commun · féminin"); + assertStringIncludes(md, "> Il habite une maison de campagne."); + assertStringIncludes(md, "**Synonymes:** demeure, domicile, logis, foyer"); + assertStringIncludes(md, "[Wiktionnaire](https://fr.wiktionary.org/wiki/maison#fr)"); +}); + +Deno.test("translation: MyMemory", () => { + const t = parseTranslation({ + responseData: { translatedText: "maison", match: 1 }, + responseStatus: 200, + matches: [ + { translation: "maison", quality: 74, "created-by": "MT!" }, + { translation: "Maison", quality: 70 }, + { translation: "domicile", quality: "80", "created-by": "Wikipedia" }, + ], + }); + assertEquals(t.text, "maison"); + assertEquals(t.alternatives, [{ text: "domicile", quality: 80, source: "Wikipedia" }]); + assertThrows(() => parseTranslation({ responseStatus: 403, responseDetails: "'XX' IS AN INVALID TARGET LANGUAGE" })); + assertThrows(() => + parseTranslation({ + responseStatus: 200, + responseData: { translatedText: "MYMEMORY WARNING: YOU USED ALL AVAILABLE FREE TRANSLATIONS" }, + }) + ); + assertEquals(parseTranslateQuery("house", "en", "fr"), { from: "en", to: "fr", text: "house" }); + assertEquals(parseTranslateQuery("fr>en bonjour tout le monde", "en", "fr"), { + from: "fr", + to: "en", + text: "bonjour tout le monde", + }); + assertEquals(parseTranslateQuery(">de Haus", "en", "fr"), { from: "en", to: "de", text: "Haus" }); + assertEquals(parseTranslateQuery(">en maison", "en", "fr"), { from: "fr", to: "en", text: "maison" }); + assertStringIncludes(myMemoryUrl("a b", "en", "fr"), "langpair=en%7Cfr"); +}); diff --git a/extensions/dictionary/views.ts b/extensions/dictionary/views.ts new file mode 100644 index 0000000..e12671a --- /dev/null +++ b/extensions/dictionary/views.ts @@ -0,0 +1,193 @@ +// Entries and translations as launcher items, shared by both commands. + +import { + type Action, + action, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + paste, +} from "@backslash/api"; +import { lookup, translate } from "./api.ts"; +import { + type Entry, + entryMarkdown, + entryText, + type Group, + ipaLine, + type Lang, + LANGS, + posColor, + posIcon, + type Sense, + senseText, + wiktionaryUrl, +} from "./lib.ts"; + +const other = (lang: Lang): Lang => lang === "fr" ? "en" : "fr"; + +/** Looks a word up and shows its entry, or says there is none. */ +export async function wordView(word: string, lang: Lang, signal?: AbortSignal): Promise { + const entry = await lookup(word, lang, signal); + return list({ + title: word, + placeholder: `Filter the definitions of “${word}”…`, + status: entry ? status(entry) : undefined, + items: entry ? entryItems(entry) : [notFound(word, lang)], + }); +} + +export function status(entry: Entry): string { + const n = entry.groups.reduce((sum, g) => sum + g.senses.length, 0); + return [entry.word, ipaLine(entry), `${n} definition${n === 1 ? "" : "s"}`, entry.source].filter(Boolean).join(" · "); +} + +/** A detail page with the translation of `text`. */ +export async function translationView(text: string, from: string, to: string): Promise { + const t = await translate(text, from, to); + return detail({ + title: `${from.toUpperCase()} → ${to.toUpperCase()}`, + markdown: [ + `# ${t.text}`, + "", + `> ${text}`, + "", + ...(t.alternatives.length ? ["**Other translations**", "", ...t.alternatives.map((a) => `- ${a.text}`)] : []), + ].join("\n"), + metadata: [ + meta.text("From", LANGS[from as Lang] ?? from.toUpperCase(), { icon: Icons.globe }), + meta.text("To", LANGS[to as Lang] ?? to.toUpperCase(), { icon: Icons.globe }), + ...(t.match ? [meta.progress("Match", t.match)] : []), + meta.text("Source", "MyMemory"), + ], + actions: [ + copy(t.text, "Copy Translation"), + paste(t.text, "Paste Translation"), + ...t.alternatives.slice(0, 4).map((a) => copy(a.text, `Copy “${a.text}”`, { section: "Other Translations" })), + ], + }); +} + +function translateAction(word: string, lang: Lang): Action { + const to = other(lang); + return action(`Translate to ${LANGS[to]}`, () => translationView(word, lang, to), { + icon: Icons.globe, + shortcut: "t", + section: "Word", + }); +} + +export function notFound(word: string, lang: Lang): Item { + const search = lang === "fr" + ? `https://fr.wiktionary.org/w/index.php?search=${encodeURIComponent(word)}` + : `https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(word)}`; + return { + id: `missing:${lang}:${word}`, + title: `No definition for “${word}”`, + subtitle: lang === "fr" ? "Wiktionnaire" : "Wiktionary", + icon: { source: Icons.question, tint: "gray" }, + accessories: [{ tag: lang.toUpperCase() }], + detail: { + markdown: `Nothing found for **${word}** in ${LANGS[lang]}.\n\n` + + `Check the spelling, or switch language with a prefix: \`${other(lang)}:${word}\`.`, + }, + actions: [ + open(search, "Search on Wiktionary", { icon: Icons.search }), + translateAction(word, lang), + ], + }; +} + +function senseMetadata(entry: Entry, group: Group): Metadata[] { + const fr = entry.lang === "fr"; + const ipa = ipaLine(entry); + return [ + meta.text(fr ? "Mot" : "Word", entry.word, { icon: Icons.book }), + ...(ipa ? [meta.text(fr ? "Prononciation" : "Pronunciation", ipa, { icon: Icons.speaker })] : []), + meta.tags(fr ? "Nature" : "Part of speech", [{ text: group.pos, color: posColor(group.pos) }]), + ...(group.gender ? [meta.text("Genre", group.gender)] : []), + ...(group.synonyms.length ? [meta.tags(fr ? "Synonymes" : "Synonyms", group.synonyms.slice(0, 8))] : []), + ...(group.antonyms.length ? [meta.tags(fr ? "Antonymes" : "Antonyms", group.antonyms.slice(0, 6))] : []), + meta.separator(), + meta.text(fr ? "Langue" : "Language", LANGS[entry.lang], { icon: Icons.globe }), + meta.link("Source", entry.source, entry.url), + ]; +} + +function senseMarkdown(entry: Entry, group: Group, sense: Sense, n: number): string { + const ipa = ipaLine(entry); + return [ + `## ${entry.word}`, + `*${[group.pos.toLowerCase(), group.gender, ipa].filter(Boolean).join(" · ")}*`, + "", + `**${n}.** ${sense.definition}`, + "", + ...sense.examples.map((e) => `> ${e}`), + ].join("\n"); +} + +function entryActions(entry: Entry, extra: { definition?: string; copyTitle?: string } = {}): Action[] { + return [ + ...(extra.definition ? [copy(extra.definition, extra.copyTitle ?? "Copy Definition")] : []), + open(entry.url, entry.lang === "fr" ? "Open on Wiktionnaire" : "Open on Wiktionary", { shortcut: "o" }), + action("Show Full Entry", () => + detail({ + title: entry.word, + markdown: entryMarkdown(entry), + status: status(entry), + actions: [ + copy(entryText(entry), "Copy Entry"), + open(entry.url, entry.lang === "fr" ? "Open on Wiktionnaire" : "Open on Wiktionary"), + ], + }), { icon: Icons.text, shortcut: "Return", section: "Word" }), + translateAction(entry.word, entry.lang), + copy(entry.word, "Copy Word", { shortcut: "period", section: "Copy" }), + ...(entry.ipa[0] ? [copy(ipaLine(entry)!, "Copy Pronunciation", { section: "Copy" })] : []), + copy(entryText(entry), "Copy Full Entry", { shortcut: "c", section: "Copy" }), + ]; +} + +/** One item per definition, grouped by part of speech, then the synonyms. */ +export function entryItems(entry: Entry): Item[] { + const items: Item[] = []; + for (const group of entry.groups) { + const section = group.gender ? `${group.pos} · ${group.gender}` : group.pos; + group.senses.forEach((sense, i) => { + items.push({ + id: `${entry.lang}:${entry.word}:${group.pos}:${i}`, + title: sense.definition, + icon: posIcon(group.pos), + section, + keywords: [entry.word, group.pos, ...sense.examples], + accessories: sense.examples.length + ? [{ icon: Icons.comment, text: String(sense.examples.length), tooltip: "Examples" }] + : [], + detail: { markdown: senseMarkdown(entry, group, sense, i + 1), metadata: senseMetadata(entry, group) }, + actions: entryActions(entry, { definition: senseText(entry, group, sense) }), + }); + }); + } + const synonyms = [...new Set(entry.groups.flatMap((g) => g.synonyms))].slice(0, 12); + for (const word of synonyms) { + items.push({ + id: `syn:${entry.lang}:${word}`, + title: word, + subtitle: entry.lang === "fr" ? `synonyme de ${entry.word}` : `synonym of ${entry.word}`, + icon: { source: Icons.link, tint: "teal" }, + section: entry.lang === "fr" ? "Synonymes" : "Synonyms", + actions: [ + action("Look Up", () => wordView(word, entry.lang), { icon: Icons.book }), + copy(word, "Copy Word"), + open(wiktionaryUrl(word, entry.lang), entry.lang === "fr" ? "Open on Wiktionnaire" : "Open on Wiktionary"), + ], + }); + } + return items; +} diff --git a/extensions/games/backslash.toml b/extensions/games/backslash.toml new file mode 100644 index 0000000..f74f248 --- /dev/null +++ b/extensions/games/backslash.toml @@ -0,0 +1,44 @@ +[extension] +id = "games" +title = "Games" +description = "Launch your installed Steam, Lutris and Heroic (Epic, GOG, Amazon) games, with cover art, play time, size and ProtonDB ratings" +version = "1.0.0" +author = "Backslash" +icon = "bs-gamepad" +license = "MIT" + +# Libraries are read from disk: Steam (native, Debian package, Flatpak) and its +# picture cache, Lutris art, Heroic's library files. Steam libraries on other +# drives are usually mounted under /mnt, /media or /run/media. +# `lutris` lists Lutris games as JSON. The network is only used for ProtonDB +# ratings and for covers missing from the local caches. +[permissions] +read = [ + "~/.local/share/Steam", + "~/.steam", + "~/.var/app/com.valvesoftware.Steam/.local/share/Steam", + "/mnt", + "/media", + "/run/media", + "~/.local/share/lutris", + "~/.cache/lutris", + "~/.local/share/icons/hicolor/128x128/apps", + "~/.config/heroic", + "~/.var/app/com.heroicgameslauncher.hgl/config/heroic", +] +run = ["lutris"] +env = ["HOME"] +net = [ + "www.protondb.com", + "shared.steamstatic.com", + "cdn1.epicgames.com", + "cdn2.unrealengine.com", + "images.gog-statics.com", +] + +[[commands]] +name = "games" +title = "Search Games" +description = "Installed games of Steam, Lutris and Heroic; Enter to play" +icon = "bs-gamepad" +keywords = ["games", "steam", "lutris", "heroic", "epic", "gog", "play", "proton", "jeux", "jeu", "jouer", "ludothèque"] diff --git a/extensions/games/commands/games.ts b/extensions/games/commands/games.ts new file mode 100644 index 0000000..26dbfa8 --- /dev/null +++ b/extensions/games/commands/games.ts @@ -0,0 +1,97 @@ +import { Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { gameItem, isRecent } from "../items.ts"; +import { dedupe, formatSize, type Game, type ProtonSummary, sortGames, type Source, SOURCES } from "../lib.ts"; +import { cachedProton, fetchProton, heroic, lutris, steam } from "../sources.ts"; + +function build(games: Game[], proton: Map, filter: string, unreadable: string[]): Item[] { + const now = Date.now(); + const recent = games.filter((g) => isRecent(g, now)).slice(0, 6); + // Grouped by launcher (one section each), alphabetical inside. + const order = Object.keys(SOURCES); + const rest = games.filter((g) => !recent.includes(g)) + .sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source) || a.title.localeCompare(b.title)); + const item = (g: Game, section: string): Item => ({ + ...gameItem(g, g.appid ? proton.get(g.appid) : undefined, now), + section, + }); + const items = [ + ...recent.map((g) => item(g, "Recently Played")), + ...rest.map((g) => item(g, filter === "all" ? SOURCES[g.source].title : "Installed")), + ]; + if (unreadable.length && (filter === "all" || filter === "steam")) { + items.push({ + id: "unreadable", + title: `${unreadable.length} Steam library folder${unreadable.length > 1 ? "s" : ""} not available`, + subtitle: unreadable.join(", "), + icon: { source: Icons.info, tint: "gray" }, + section: "Notes", + detail: { + markdown: "Steam lists games in these folders, but they can't be read: the drive may be unmounted, " + + "or the folder is outside **/mnt**, **/media** and **/run/media**, where this extension may look.\n\n" + + unreadable.map((u) => `- ${u}`).join("\n"), + }, + actions: [], + }); + } + return items; +} + +export default list({ + placeholder: "Search your games…", + filter: { + options: [ + { value: "all", title: "All" }, + ...(Object.keys(SOURCES) as Source[]).map((value) => ({ value, title: SOURCES[value].title })), + ], + }, + empty: { + title: "No games found", + description: "Installed games of Steam (native or Flatpak), Lutris and Heroic show up here", + icon: Icons.gamepad, + }, + status: (items) => { + const games = items.filter((i) => i.id !== "unreadable"); + return `${games.length} game${games.length === 1 ? "" : "s"}`; + }, + async *items({ filter, signal }) { + const mode = filter ?? "all"; + const [s, l, h] = await Promise.all([ + mode === "all" || mode === "steam" ? steam() : undefined, + mode === "all" || mode === "lutris" ? lutris(signal) : undefined, + mode === "all" || mode === "heroic" ? heroic() : undefined, + ]); + const games = sortGames(dedupe([...(s?.games ?? []), ...(l?.games ?? []), ...(h?.games ?? [])])); + const unreadable = s?.unreadable ?? []; + if (mode === "all") { + const total = formatSize(games.reduce((n, g) => n + (g.sizeBytes ?? 0), 0)); + updateCommandMetadata({ subtitle: games.length ? `${games.length} games${total ? ` · ${total}` : ""}` : null }) + .catch(() => {}); + } + // ProtonDB ratings: the cached ones right away, the others as they come. + const proton = new Map(); + const ids = games.filter((g) => g.appid).map((g) => g.appid!); + const missing: string[] = []; + for (const id of ids) { + const hit = await cachedProton(id); + if (hit === undefined) missing.push(id); + else proton.set(id, hit); + } + yield build(games, proton, mode, unreadable); + // Batches of 12, six requests at a time, so the list fills in progressively. + for (let i = 0; i < missing.length && !signal.aborted; i += 12) { + const pending = missing.slice(i, i + 12); + let changed = false; + await Promise.all(Array.from({ length: 6 }, async () => { + while (pending.length && !signal.aborted) { + const id = pending.shift()!; + const summary = await fetchProton(id, signal).catch(() => null); + if (summary) { + proton.set(id, summary); + changed = true; + } + } + })); + if (changed) yield build(games, proton, mode, unreadable); + } + }, +}); diff --git a/extensions/games/items.ts b/extensions/games/items.ts new file mode 100644 index 0000000..b6d402e --- /dev/null +++ b/extensions/games/items.ts @@ -0,0 +1,115 @@ +// Games as launcher items. + +import { type Action, copy, Icons, type Item, meta, type Metadata, open, type Stat } from "@backslash/api"; +import { + ago, + formatPlaytime, + formatSize, + type Game, + protonDbPage, + type ProtonSummary, + sourceIcon, + SOURCES, + steamStore, + tier, +} from "./lib.ts"; + +const RECENT_DAYS = 14; + +export function isRecent(g: Game, now = Date.now()): boolean { + return !!g.lastPlayed && now - g.lastPlayed < RECENT_DAYS * 86_400_000; +} + +function actions(g: Game): Action[] { + const out: Action[] = [open(g.launch, "Play", { icon: Icons.play })]; + if (g.source === "steam" && g.appid) { + out.push( + open(`steam://nav/games/details/${g.appid}`, "Show in Steam Library", { icon: Icons.apps }), + open(steamStore(g.appid), "Open Store Page", { icon: Icons.globe, shortcut: "o", section: "Links" }), + open(protonDbPage(g.appid), "Open on ProtonDB", { icon: Icons.shield, shortcut: "p", section: "Links" }), + open(`steam://validate/${g.appid}`, "Verify Game Files", { icon: Icons.check, section: "Steam" }), + open(`steam://uninstall/${g.appid}`, "Uninstall", { + icon: Icons.trash, + destructive: true, + confirm: `Uninstall ${g.title}? Steam will ask to confirm`, + section: "Steam", + }), + ); + } + if (g.installDir) { + out.push(open(g.installDir, "Open Install Folder", { icon: Icons.folder, shortcut: "f", section: "Files" })); + } + out.push(copy(g.title, "Copy Name", { shortcut: "period", section: "Copy" })); + if (g.appid) out.push(copy(g.appid, "Copy App ID", { section: "Copy" })); + if (g.installDir) out.push(copy(g.installDir, "Copy Install Path", { section: "Copy" })); + return out; +} + +export function gameItem(g: Game, proton: ProtonSummary | null | undefined, now = Date.now()): Item { + const src = SOURCES[g.source]; + const t = tier(proton?.tier); + const size = formatSize(g.sizeBytes); + const played = formatPlaytime(g.playtimeMinutes); + const last = ago(g.lastPlayed, now); + const stats: Stat[] = [ + ...(size ? [{ label: "Size", value: size }] : []), + ...(played ? [{ label: "Played", value: played }] : []), + ...(last ? [{ label: "Last played", value: last }] : []), + ...(t ? [{ label: "ProtonDB", value: t.label, color: t.color }] : []), + ]; + const metadata: Metadata[] = [ + meta.text("Launcher", g.runner && g.source === "heroic" ? `Heroic · ${g.runner}` : src.title, { + icon: Icons.gamepad, + color: src.tint, + }), + ...(g.source === "lutris" && g.runner ? [meta.text("Runner", g.runner, { icon: Icons.cpu })] : []), + ...(g.platform ? [meta.text("Platform", g.platform, { icon: Icons.monitor })] : []), + ...(g.year ? [meta.text("Released", g.year, { icon: Icons.calendar })] : []), + ...(g.appid ? [meta.text("App ID", g.appid, { icon: Icons.hash })] : []), + ...(g.lastPlayed + ? [meta.text("Last played", new Date(g.lastPlayed).toLocaleDateString("en-GB", { dateStyle: "medium" }), { + icon: Icons.clock, + })] + : []), + ...(g.lastUpdated + ? [meta.text("Updated", new Date(g.lastUpdated).toLocaleDateString("en-GB", { dateStyle: "medium" }), { + icon: Icons.download, + })] + : []), + ]; + if (proton && t) { + const trending = tier(proton.trendingTier); + metadata.push( + meta.separator(), + meta.tags("ProtonDB", [ + { text: t.label, color: t.color }, + ...(trending && trending.label !== t.label + ? [{ text: `trending ${trending.label}`, color: trending.color }] + : []), + ]), + ...(proton.total + ? [meta.text("Reports", `${proton.total}${proton.confidence ? ` · ${proton.confidence} confidence` : ""}`)] + : []), + ...(g.appid ? [meta.link("Details", "protondb.com", protonDbPage(g.appid))] : []), + ); + } + if (g.installDir) metadata.push(meta.separator(), meta.text("Folder", g.installDir, { icon: Icons.folder })); + return { + id: g.id, + title: g.title, + subtitle: g.source === "lutris" && g.runner + ? `${src.title} · ${g.runner}` + : g.runner && g.source === "heroic" + ? g.runner + : src.title, + icon: g.icon ?? sourceIcon(g.source), + keywords: [src.title, g.runner ?? "", g.platform ?? "", g.appid ?? ""].filter(Boolean), + accessories: [ + ...(t ? [{ tag: t.label, color: t.color, tooltip: "ProtonDB rating" }] : []), + ...(played ? [{ text: played, icon: Icons.clock, tooltip: "Time played" }] : []), + ...(size ? [{ text: size, tooltip: "Size on disk" }] : []), + ], + detail: { image: g.cover, stats, metadata }, + actions: actions(g), + }; +} diff --git a/extensions/games/lib.ts b/extensions/games/lib.ts new file mode 100644 index 0000000..23bee44 --- /dev/null +++ b/extensions/games/lib.ts @@ -0,0 +1,510 @@ +// Game libraries: Valve's KeyValues (VDF/ACF) format, Lutris and Heroic +// listings, ProtonDB tiers. Pure, tested in lib_test.ts. + +import { type Color, Icons } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// VDF (Valve KeyValues, text form) + +export type Vdf = { [key: string]: string | Vdf }; + +/** + * Parses text KeyValues: `"key" "value"` pairs and `"key" { … }` blocks, + * quoted or bare tokens, `\"` escapes, `//` comments and `[$PLATFORM]` + * conditions (ignored). Later duplicate keys win. + */ +export function parseVdf(text: string): Vdf { + const tokens: { t: string; brace?: boolean }[] = []; + const n = text.length; + let i = 0; + while (i < n) { + const c = text[i]; + if (c === " " || c === "\t" || c === "\r" || c === "\n" || c === "") { + i++; + } else if (c === "/" && text[i + 1] === "/") { + while (i < n && text[i] !== "\n") i++; + } else if (c === "{" || c === "}") { + tokens.push({ t: c, brace: true }); + i++; + } else if (c === "[") { + // Conditional like [$WIN32]: skipped. + while (i < n && text[i] !== "]") i++; + i++; + } else if (c === '"') { + let s = ""; + i++; + while (i < n && text[i] !== '"') { + if (text[i] === "\\" && i + 1 < n) { + const e = text[i + 1]; + s += e === "n" ? "\n" : e === "t" ? "\t" : e; + i += 2; + } else s += text[i++]; + } + i++; + tokens.push({ t: s }); + } else { + let s = ""; + while (i < n && !/[\s{}"]/.test(text[i])) s += text[i++]; + tokens.push({ t: s }); + } + } + let k = 0; + const block = (): Vdf => { + const out: Vdf = {}; + while (k < tokens.length) { + const key = tokens[k++]; + if (key.brace) { + if (key.t === "}") return out; + continue; + } + const value = tokens[k++]; + if (!value) break; + if (value.brace && value.t === "{") out[key.t] = block(); + else if (!value.brace) out[key.t] = value.t; + else return out; + } + return out; + }; + return block(); +} + +/** Case-insensitive lookup along a path: `get(vdf, "AppState", "name")`. */ +export function vdfGet(v: Vdf | string | undefined, ...path: string[]): Vdf | string | undefined { + let cur: Vdf | string | undefined = v; + for (const p of path) { + if (!cur || typeof cur === "string") return undefined; + const key: string | undefined = Object.keys(cur).find((k) => k.toLowerCase() === p.toLowerCase()); + cur = key === undefined ? undefined : cur[key]; + } + return cur; +} + +const str = (v: Vdf | string | undefined) => typeof v === "string" ? v : undefined; +const int = (v: Vdf | string | undefined) => { + const n = Number(str(v)); + return Number.isFinite(n) && n > 0 ? n : undefined; +}; + +export interface LibraryFolder { + path: string; + /** App ids installed there, with their size, when Steam lists them. */ + apps: Record; +} + +/** `steamapps/libraryfolders.vdf`, current and pre-2021 formats. */ +export function libraryFolders(text: string): LibraryFolder[] { + const root = vdfGet(parseVdf(text), "libraryfolders"); + if (!root || typeof root === "string") return []; + const out: LibraryFolder[] = []; + for (const [key, value] of Object.entries(root)) { + if (!/^\d+$/.test(key)) continue; + if (typeof value === "string") out.push({ path: value, apps: {} }); + else { + const path = str(vdfGet(value, "path")); + const apps = vdfGet(value, "apps"); + if (path) { + out.push({ + path, + apps: apps && typeof apps !== "string" + ? Object.fromEntries(Object.entries(apps).map(([id, size]) => [id, Number(size) || 0])) + : {}, + }); + } + } + } + return out; +} + +export interface SteamApp { + appid: string; + name: string; + installdir: string; + sizeOnDisk?: number; + lastPlayed?: number; + lastUpdated?: number; + buildId?: string; + /** 4 = fully installed. */ + stateFlags?: number; +} + +/** `steamapps/appmanifest_.acf`. */ +export function parseAppManifest(text: string): SteamApp | undefined { + const state = vdfGet(parseVdf(text), "AppState"); + if (!state || typeof state === "string") return undefined; + const appid = str(vdfGet(state, "appid")); + const name = str(vdfGet(state, "name")) ?? str(vdfGet(state, "UserConfig", "name")); + if (!appid || !name) return undefined; + const lastPlayed = int(vdfGet(state, "LastPlayed")); + const lastUpdated = int(vdfGet(state, "LastUpdated")); + return { + appid, + name, + installdir: str(vdfGet(state, "installdir")) ?? "", + sizeOnDisk: int(vdfGet(state, "SizeOnDisk")), + lastPlayed: lastPlayed ? lastPlayed * 1000 : undefined, + lastUpdated: lastUpdated ? lastUpdated * 1000 : undefined, + buildId: str(vdfGet(state, "buildid")), + stateFlags: int(vdfGet(state, "StateFlags")), + }; +} + +/** Proton, runtimes and redistributables are not games. */ +const TOOL_IDS = new Set([ + "228980", // Steamworks Common Redistributables + "1070560", // Steam Linux Runtime 1.0 (scout) + "1391110", // Steam Linux Runtime 2.0 (soldier) + "1628350", // Steam Linux Runtime 3.0 (sniper) + "1493710", // Proton Experimental + "2180100", // Proton Hotfix + "961940", // Proton 3.16 (old) + "1826330", // Proton EasyAntiCheat Runtime + "1161040", // Proton BattlEye Runtime + "2348590", // Proton 8.0 + "3658110", // Proton 10.0 +]); + +export function isTool(app: { appid: string; name: string }): boolean { + return TOOL_IDS.has(app.appid) || + /^(Proton\b|Steam Linux Runtime|Steamworks Common|SteamVR\b|Steam Audio|Proton EasyAntiCheat|Proton BattlEye)/i + .test(app.name); +} + +/** `userdata//config/localconfig.vdf`: last played and minutes played per app. */ +export function playtimes(text: string): Map { + const apps = vdfGet(parseVdf(text), "UserLocalConfigStore", "Software", "Valve", "Steam", "apps"); + const out = new Map(); + if (!apps || typeof apps === "string") return out; + for (const [id, v] of Object.entries(apps)) { + if (typeof v === "string") continue; + const last = int(vdfGet(v, "LastPlayed")); + const minutes = int(vdfGet(v, "Playtime")); + if (last || minutes) out.set(id, { lastPlayed: last ? last * 1000 : undefined, minutes }); + } + return out; +} + +// --------------------------------------------------------------------------- +// The common model + +export type Source = "steam" | "lutris" | "heroic"; + +export interface Game { + id: string; + source: Source; + title: string; + launch: string; + appid?: string; + slug?: string; + sizeBytes?: number; + lastPlayed?: number; + playtimeMinutes?: number; + installDir?: string; + /** A local file or URL. */ + cover?: string; + icon?: string; + /** Lutris runner, Heroic store. */ + runner?: string; + platform?: string; + year?: number; + lastUpdated?: number; + buildId?: string; + library?: string; +} + +export const SOURCES: Record = { + steam: { title: "Steam", tint: "blue" }, + lutris: { title: "Lutris", tint: "orange" }, + heroic: { title: "Heroic", tint: "purple" }, +}; + +export const steamLaunch = (appid: string) => `steam://rungameid/${appid}`; +export const steamStore = (appid: string) => `https://store.steampowered.com/app/${appid}`; +export const steamHeader = (appid: string) => + `https://shared.steamstatic.com/store_item_assets/steam/apps/${appid}/header.jpg`; +export const protonDbPage = (appid: string) => `https://www.protondb.com/app/${appid}`; +export const lutrisLaunch = (slug: string) => `lutris:rungame/${slug}`; +export const heroicLaunch = (appName: string, runner: string) => + `heroic://launch?appName=${encodeURIComponent(appName)}&runner=${encodeURIComponent(runner)}`; + +export function steamGame( + app: SteamApp, + libraryPath: string, + extra: { lastPlayed?: number; minutes?: number } = {}, +): Game { + return { + id: `steam:${app.appid}`, + source: "steam", + title: app.name, + launch: steamLaunch(app.appid), + appid: app.appid, + sizeBytes: app.sizeOnDisk, + lastPlayed: Math.max(app.lastPlayed ?? 0, extra.lastPlayed ?? 0) || undefined, + playtimeMinutes: extra.minutes, + installDir: app.installdir ? `${libraryPath}/steamapps/common/${app.installdir}` : undefined, + lastUpdated: app.lastUpdated, + buildId: app.buildId, + library: libraryPath, + }; +} + +// Lutris: `lutris -l -o -j` + +export interface LutrisEntry { + id: number; + slug: string; + name: string; + runner?: string | null; + platform?: string | null; + year?: number | null; + directory?: string | null; + playtime?: string | number | null; + lastplayed?: string | number | null; +} + +/** Lutris prints logs before the JSON sometimes. */ +export function parseLutrisJson(stdout: string): LutrisEntry[] { + const start = stdout.indexOf("["); + if (start < 0) return []; + try { + const data = JSON.parse(stdout.slice(start, stdout.lastIndexOf("]") + 1)); + return Array.isArray(data) ? data.filter((g) => g && g.slug && g.name) : []; + } catch { + return []; + } +} + +/** "2:30:00", "1 day, 2:00:00", "2.5" (hours) → minutes. */ +export function parseDuration(v: string | number | null | undefined): number | undefined { + if (v === null || v === undefined || v === "") return undefined; + if (typeof v === "number") return v > 0 ? Math.round(v * 60) : undefined; + const days = /(\d+)\s+days?,\s*/.exec(v); + const rest = days ? v.slice(days[0].length) : v; + const hms = /^(\d+):(\d{2})(?::(\d{2}(?:\.\d+)?))?$/.exec(rest.trim()); + if (hms) { + const m = (days ? Number(days[1]) * 1440 : 0) + Number(hms[1]) * 60 + Number(hms[2]) + + Math.round(Number(hms[3] ?? 0) / 60); + return m > 0 ? m : undefined; + } + const hours = Number(rest); + if (Number.isFinite(hours) && hours > 0) return Math.round(hours * 60); + const words = /(?:(\d+)\s*hours?)?\D*(?:(\d+)\s*minutes?)?/i.exec(rest); + const m = Number(words?.[1] ?? 0) * 60 + Number(words?.[2] ?? 0); + return m > 0 ? m : undefined; +} + +/** Lutris' `%x %X` local date ("10/27/26 14:03:00"), a timestamp, or ISO. */ +export function parseLooseDate(v: string | number | null | undefined): number | undefined { + if (v === null || v === undefined || v === "") return undefined; + if (typeof v === "number") return v > 0 ? (v < 1e12 ? v * 1000 : v) : undefined; + if (/^\d{9,13}$/.test(v.trim())) return parseLooseDate(Number(v)); + const m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{2,4})(?:[ T,]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/.exec(v.trim()); + if (m) { + let [a, b] = [Number(m[1]), Number(m[2])]; + // Month first, unless the first number can't be a month. + if (a > 12) [a, b] = [b, a]; + const y = m[3].length === 2 ? 2000 + Number(m[3]) : Number(m[3]); + return new Date(y, a - 1, b, Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)).getTime(); + } + const t = Date.parse(v); + return Number.isFinite(t) ? t : undefined; +} + +export function lutrisGame(g: LutrisEntry): Game { + return { + id: `lutris:${g.slug}`, + source: "lutris", + title: g.name, + launch: lutrisLaunch(g.slug), + slug: g.slug, + runner: g.runner ?? undefined, + platform: g.platform ?? undefined, + year: g.year ?? undefined, + installDir: g.directory || undefined, + playtimeMinutes: parseDuration(g.playtime), + lastPlayed: parseLooseDate(g.lastplayed), + }; +} + +// Heroic: store_cache/*_library.json and the installed lists + +export interface HeroicEntry { + app_name: string; + title: string; + runner?: string; + is_installed?: boolean; + art_cover?: string; + art_square?: string; + developer?: string; + install?: { install_path?: string; install_size?: string | number; platform?: string }; +} + +export type HeroicRunner = "legendary" | "gog" | "nile"; + +export const HEROIC_STORES: Record = { legendary: "Epic Games", gog: "GOG", nile: "Amazon" }; + +/** `{ library: [...] }` or `{ games: [...] }`. */ +export function heroicLibrary(json: unknown): HeroicEntry[] { + const j = json as { library?: HeroicEntry[]; games?: HeroicEntry[] } | null; + const list = j?.library ?? j?.games ?? (Array.isArray(json) ? json as HeroicEntry[] : []); + return list.filter((g) => g && g.app_name && g.title); +} + +export interface HeroicInstall { + path?: string; + size?: number; + platform?: string; +} + +/** + * Installed games: legendary's `installed.json` (`{ appName: {...} }`) or + * GOG/Amazon's (`{ installed: [...] }`). + */ +export function heroicInstalled(json: unknown): Map { + const out = new Map(); + if (!json || typeof json !== "object") return out; + const j = json as Record; + const entries: any[] = Array.isArray(j.installed) ? j.installed : Object.values(j); + for (const e of entries) { + const name = e?.appName ?? e?.app_name ?? e?.id; + if (!name || typeof e !== "object") continue; + out.set(name, { path: e.install_path, size: parseSize(e.install_size), platform: e.platform }); + } + return out; +} + +/** Heroic's `store/timestamp.json`: `{ appName: { lastPlayed, totalPlayed } }`. */ +export function heroicPlaytimes(json: unknown): Map { + const out = new Map(); + if (!json || typeof json !== "object") return out; + for (const [name, v] of Object.entries(json as Record)) { + if (!v || typeof v !== "object") continue; + out.set(name, { + lastPlayed: parseLooseDate(v.lastPlayed), + minutes: typeof v.totalPlayed === "number" && v.totalPlayed > 0 ? Math.round(v.totalPlayed) : undefined, + }); + } + return out; +} + +export function heroicGame( + g: HeroicEntry, + runner: HeroicRunner, + install: HeroicInstall | undefined, + played: { lastPlayed?: number; minutes?: number } = {}, +): Game { + return { + id: `heroic:${runner}:${g.app_name}`, + source: "heroic", + title: g.title, + launch: heroicLaunch(g.app_name, runner), + slug: g.app_name, + runner: HEROIC_STORES[runner], + platform: install?.platform ?? g.install?.platform, + installDir: install?.path ?? g.install?.install_path, + sizeBytes: install?.size ?? parseSize(g.install?.install_size), + cover: g.art_square || g.art_cover || undefined, + lastPlayed: played.lastPlayed, + playtimeMinutes: played.minutes, + }; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** 12.3 GiB, "12.3 GiB", "850 MB", bytes → bytes. */ +export function parseSize(v: unknown): number | undefined { + if (typeof v === "number") return v > 0 ? v : undefined; + if (typeof v !== "string") return undefined; + const m = /^\s*([\d.,]+)\s*([KMGT]?)(i?)B?\s*$/i.exec(v); + if (!m) return undefined; + const n = Number(m[1].replace(",", ".")); + const pow = ["", "K", "M", "G", "T"].indexOf(m[2].toUpperCase()); + const base = m[3] || m[2] ? 1024 : 1; + const bytes = n * base ** Math.max(0, pow); + return bytes > 0 ? Math.round(bytes) : undefined; +} + +export function formatSize(bytes: number | undefined): string | undefined { + if (!bytes) return undefined; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = bytes; + let u = 0; + while (v >= 1024 && u < units.length - 1) { + v /= 1024; + u++; + } + return `${v >= 100 || u === 0 ? Math.round(v) : v.toFixed(1)} ${units[u]}`; +} + +export function formatPlaytime(minutes: number | undefined): string | undefined { + if (!minutes) return undefined; + if (minutes < 60) return `${minutes} min`; + const h = minutes / 60; + return `${h >= 10 ? Math.round(h) : h.toFixed(1).replace(/\.0$/, "")} h`; +} + +export function ago(ms: number | undefined, now = Date.now()): string | undefined { + if (!ms) return undefined; + // Calendar days: last night is "yesterday". + const day = (t: number) => { + const d = new Date(t); + return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000; + }; + const d = day(now) - day(ms); + if (d <= 0) return "today"; + if (d === 1) return "yesterday"; + if (d < 30) return `${d} days ago`; + if (d < 365) return `${Math.floor(d / 30)} mo ago`; + return `${Math.floor(d / 365)} y ago`; +} + +/** Recently played first, then by title. */ +export function sortGames(games: Game[]): Game[] { + return [...games].sort((a, b) => (b.lastPlayed ?? 0) - (a.lastPlayed ?? 0) || a.title.localeCompare(b.title)); +} + +/** One entry per game: a Steam game also added to Lutris keeps its Steam entry. */ +export function dedupe(games: Game[]): Game[] { + const seen = new Set(); + const key = (g: Game) => g.title.toLowerCase().replace(/[^a-z0-9]/g, ""); + const out: Game[] = []; + for (const g of [...games].sort((a, b) => (a.source === "steam" ? -1 : 0) - (b.source === "steam" ? -1 : 0))) { + if (g.source === "lutris" && g.runner === "steam" && seen.has(key(g))) continue; + seen.add(key(g)); + out.push(g); + } + return out; +} + +// --------------------------------------------------------------------------- +// ProtonDB + +export interface ProtonSummary { + tier: string; + trendingTier?: string; + bestReportedTier?: string; + confidence?: string; + score?: number; + total?: number; +} + +export const protonDbApi = (appid: string) => `https://www.protondb.com/api/v1/reports/summaries/${appid}.json`; + +const TIERS: Record = { + native: { label: "Native", color: "green" }, + platinum: { label: "Platinum", color: "teal" }, + gold: { label: "Gold", color: "yellow" }, + silver: { label: "Silver", color: "gray" }, + bronze: { label: "Bronze", color: "orange" }, + borked: { label: "Borked", color: "red" }, + pending: { label: "Pending", color: "gray" }, +}; + +export function tier(name: string | undefined): { label: string; color: Color } | undefined { + if (!name) return undefined; + return TIERS[name.toLowerCase()] ?? { label: name[0].toUpperCase() + name.slice(1), color: "gray" }; +} + +export function sourceIcon(source: Source): { source: string; tint: Color } { + return { source: Icons.gamepad, tint: SOURCES[source].tint }; +} diff --git a/extensions/games/lib_test.ts b/extensions/games/lib_test.ts new file mode 100644 index 0000000..419006d --- /dev/null +++ b/extensions/games/lib_test.ts @@ -0,0 +1,280 @@ +import { assertEquals } from "@std/assert"; +import { + dedupe, + formatPlaytime, + formatSize, + heroicGame, + heroicInstalled, + heroicLibrary, + heroicPlaytimes, + isTool, + libraryFolders, + lutrisGame, + parseAppManifest, + parseDuration, + parseLooseDate, + parseLutrisJson, + parseSize, + parseVdf, + playtimes, + sortGames, + steamGame, + tier, + vdfGet, +} from "./lib.ts"; +import { gameItem } from "./items.ts"; + +const LIBRARY_FOLDERS = `"libraryfolders" +{ + "0" + { + "path" "/home/ada/.local/share/Steam" + "label" "" + "contentid" "1234567890" + "totalsize" "0" + "apps" + { + "228980" "191985384" + "570" "40177422336" + } + } + "1" + { + "path" "/mnt/games/SteamLibrary" + "label" "Games" + "apps" + { + "1091500" "70000000000" + } + } +} +`; + +const OLD_LIBRARY_FOLDERS = `"LibraryFolders" +{ + "TimeNextStatsReport" "1600000000" + "ContentStatsID" "-123" + "1" "/media/ada/Big/SteamLibrary" +}`; + +const MANIFEST = `"AppState" +{ + "appid" "570" + "Universe" "1" + "name" "Dota 2" + "StateFlags" "4" + "installdir" "dota 2 beta" + "LastUpdated" "1761500000" + "LastPlayed" "1761600000" + "SizeOnDisk" "40177422336" + "buildid" "20123456" + "UserConfig" + { + "language" "english" + } + "InstalledDepots" + { + "373301" + { + "manifest" "123" + "size" "456" + } + } +}`; + +const LOCALCONFIG = `"UserLocalConfigStore" +{ + "Software" + { + "Valve" + { + "Steam" + { + "apps" + { + "570" + { + "LastPlayed" "1761700000" + "Playtime" "6012" + "cloud" { "last_sync_state" "synchronized" } + } + "440" + { + "Playtime2wks" "0" + } + } + } + } + } +}`; + +Deno.test("VDF: nesting, escapes, comments, conditions, bare tokens", () => { + const v = parseVdf(`// a comment +"Root" +{ + "quoted" "say \\"hi\\" \\\\ there" + bare value + "cond" "1" [$WIN32] + "empty" "" + "child" { "a" "1" } + "child" { "b" "2" } +}`); + assertEquals(vdfGet(v, "root", "QUOTED"), 'say "hi" \\ there'); + assertEquals(vdfGet(v, "Root", "bare"), "value"); + assertEquals(vdfGet(v, "Root", "cond"), "1"); + assertEquals(vdfGet(v, "Root", "empty"), ""); + // Later duplicates win. + assertEquals(vdfGet(v, "Root", "child"), { b: "2" }); + assertEquals(vdfGet(v, "Root", "missing", "deeper"), undefined); + assertEquals(parseVdf(""), {}); + assertEquals(parseVdf('"unterminated" {'), { unterminated: {} }); +}); + +Deno.test("Steam library folders, both formats", () => { + assertEquals(libraryFolders(LIBRARY_FOLDERS), [ + { path: "/home/ada/.local/share/Steam", apps: { "228980": 191985384, "570": 40177422336 } }, + { path: "/mnt/games/SteamLibrary", apps: { "1091500": 70000000000 } }, + ]); + assertEquals(libraryFolders(OLD_LIBRARY_FOLDERS), [{ path: "/media/ada/Big/SteamLibrary", apps: {} }]); + assertEquals(libraryFolders("garbage"), []); +}); + +Deno.test("app manifests and play times", () => { + const app = parseAppManifest(MANIFEST)!; + assertEquals(app, { + appid: "570", + name: "Dota 2", + installdir: "dota 2 beta", + sizeOnDisk: 40177422336, + lastPlayed: 1761600000000, + lastUpdated: 1761500000000, + buildId: "20123456", + stateFlags: 4, + }); + assertEquals(parseAppManifest('"AppState" { "appid" "1" }'), undefined); + const played = playtimes(LOCALCONFIG); + assertEquals(played.get("570"), { lastPlayed: 1761700000000, minutes: 6012 }); + assertEquals(played.has("440"), false); + const game = steamGame(app, "/home/ada/.local/share/Steam", played.get("570")); + assertEquals(game.launch, "steam://rungameid/570"); + assertEquals(game.lastPlayed, 1761700000000); + assertEquals(game.installDir, "/home/ada/.local/share/Steam/steamapps/common/dota 2 beta"); + assertEquals( + [isTool({ appid: "1493710", name: "Proton Experimental" }), isTool({ appid: "9", name: "Proton 9.0" })], + [ + true, + true, + ], + ); + assertEquals(isTool({ appid: "1628350", name: "x" }), true); + assertEquals(isTool(app), false); +}); + +Deno.test("Lutris", () => { + const out = + `2026-10-28 10:00:00,000: Startup info\n[{"id": 3, "slug": "gog-witcher-3", "name": "The Witcher 3", "runner": "wine", ` + + `"platform": "Windows", "year": 2015, "directory": "/home/ada/Games/witcher3", "playtime": "12:30:00", ` + + `"lastplayed": "10/27/26 14:03:00"}, {"id": 4, "slug": "", "name": "broken"}]`; + const games = parseLutrisJson(out); + assertEquals(games.length, 1); + const g = lutrisGame(games[0]); + assertEquals([g.id, g.launch, g.runner, g.platform, g.year], [ + "lutris:gog-witcher-3", + "lutris:rungame/gog-witcher-3", + "wine", + "Windows", + 2015, + ]); + assertEquals(g.playtimeMinutes, 750); + assertEquals(g.lastPlayed, new Date(2026, 9, 27, 14, 3).getTime()); + assertEquals(parseLutrisJson("no json here"), []); + assertEquals([parseDuration("1 day, 2:00:00"), parseDuration(2.5), parseDuration("3 hours 5 minutes")], [ + 1560, + 150, + 185, + ]); + assertEquals([parseDuration(null), parseDuration("0:00:00")], [undefined, undefined]); + assertEquals(parseLooseDate("27/10/2026 08:00"), new Date(2026, 9, 27, 8).getTime()); + assertEquals(parseLooseDate(1761600000), 1761600000000); + assertEquals(parseLooseDate("2026-10-27T10:00:00.000Z"), Date.UTC(2026, 9, 27, 10)); +}); + +Deno.test("Heroic: Epic, GOG and Amazon libraries", () => { + const epic = heroicLibrary({ + library: [ + { + app_name: "Fortnite", + title: "Fortnite", + is_installed: true, + art_square: "https://cdn1.epicgames.com/offer/fn/square.jpg", + install: { install_path: "/home/ada/Games/Heroic/Fortnite", install_size: "31.2 GiB", platform: "Windows" }, + }, + { app_name: "Other", title: "Not installed", is_installed: false }, + { title: "no app name" }, + ], + }); + assertEquals(epic.length, 2); + const gog = heroicLibrary({ games: [{ app_name: "1207658924", title: "Unreal Tournament", is_installed: false }] }); + const installed = heroicInstalled({ + installed: [{ appName: "1207658924", install_path: "/games/ut", install_size: "620 MiB", platform: "linux" }], + }); + assertEquals(installed.get("1207658924"), { path: "/games/ut", size: 650117120, platform: "linux" }); + const legendary = heroicInstalled({ + Fortnite: { app_name: "Fortnite", install_path: "/x", install_size: 33500000000 }, + }); + assertEquals(legendary.get("Fortnite")?.size, 33500000000); + const played = heroicPlaytimes({ + Fortnite: { firstPlayed: "2026-01-01T00:00:00.000Z", lastPlayed: "2026-10-20T20:00:00.000Z", totalPlayed: 95 }, + }); + const g = heroicGame(epic[0], "legendary", undefined, played.get("Fortnite")); + assertEquals(g.launch, "heroic://launch?appName=Fortnite&runner=legendary"); + assertEquals([g.runner, g.platform, g.sizeBytes, g.playtimeMinutes], ["Epic Games", "Windows", 33500744909, 95]); + assertEquals(g.lastPlayed, Date.UTC(2026, 9, 20, 20)); + assertEquals(heroicGame(gog[0], "gog", installed.get("1207658924")).installDir, "/games/ut"); +}); + +Deno.test("sizes, durations, ordering", () => { + assertEquals([formatSize(40177422336), formatSize(650117120), formatSize(512), formatSize(0)], [ + "37.4 GB", + "620 MB", + "512 B", + undefined, + ]); + assertEquals([parseSize("12,5 GB"), parseSize("100"), parseSize("abc")], [13421772800, 100, undefined]); + assertEquals([formatPlaytime(45), formatPlaytime(90), formatPlaytime(6012), formatPlaytime(0)], [ + "45 min", + "1.5 h", + "100 h", + undefined, + ]); + const a = { id: "a", source: "steam" as const, title: "Alpha", launch: "", lastPlayed: 1 }; + const b = { id: "b", source: "steam" as const, title: "Beta", launch: "", lastPlayed: 5 }; + const c = { id: "c", source: "lutris" as const, title: "Charlie", launch: "" }; + assertEquals(sortGames([c, a, b]).map((g) => g.id), ["b", "a", "c"]); + const dup = { id: "l", source: "lutris" as const, title: "alpha!", launch: "", runner: "steam" }; + assertEquals(dedupe([dup, a]).map((g) => g.id), ["a"]); +}); + +Deno.test("ProtonDB tiers and the game item", () => { + assertEquals(tier("platinum"), { label: "Platinum", color: "teal" }); + assertEquals(tier("borked")?.color, "red"); + assertEquals(tier("unknownish")?.label, "Unknownish"); + assertEquals(tier(undefined), undefined); + const app = parseAppManifest(MANIFEST)!; + const game = { ...steamGame(app, "/s", { minutes: 6012 }), cover: "/s/appcache/librarycache/570_header.jpg" }; + const item = gameItem( + game, + { tier: "gold", trendingTier: "platinum", confidence: "strong", total: 1200 }, + 1761700000000, + ); + assertEquals(item.icon, { source: "bs-gamepad", tint: "blue" }); + assertEquals(item.accessories?.[0], { tag: "Gold", color: "yellow", tooltip: "ProtonDB rating" }); + assertEquals(item.detail?.image, "/s/appcache/librarycache/570_header.jpg"); + assertEquals(item.detail?.stats?.map((s) => s.label), ["Size", "Played", "Last played", "ProtonDB"]); + assertEquals(item.detail?.stats?.[3].color, "yellow"); + const proton = item.detail?.metadata?.find((m) => m.label === "ProtonDB"); + assertEquals(proton?.tags, [{ text: "Gold", color: "yellow" }, { text: "trending Platinum", color: "teal" }]); + const titles = item.actions?.map((a) => a.title); + assertEquals(titles?.slice(0, 4), ["Play", "Show in Steam Library", "Open Store Page", "Open on ProtonDB"]); + assertEquals(item.actions?.find((a) => a.title === "Uninstall")?.destructive, true); +}); diff --git a/extensions/games/sources.ts b/extensions/games/sources.ts new file mode 100644 index 0000000..0e55099 --- /dev/null +++ b/extensions/games/sources.ts @@ -0,0 +1,272 @@ +// Reading the libraries on disk (and running `lutris`). Everything is +// best effort: a launcher that isn't installed simply contributes nothing. + +import { exec, storage } from "@backslash/api"; +import { + type Game, + heroicGame, + heroicInstalled, + heroicLibrary, + heroicPlaytimes, + type HeroicRunner, + isTool, + libraryFolders, + lutrisGame, + parseAppManifest, + parseLutrisJson, + playtimes, + protonDbApi, + type ProtonSummary, + steamGame, + steamHeader, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +const STEAM_ROOTS = [ + ".local/share/Steam", + ".steam/debian-installation", + ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ".steam/steam", +]; + +const HEROIC_ROOTS = [".config/heroic", ".var/app/com.heroicgameslauncher.hgl/config/heroic"]; + +async function readText(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch { + return undefined; + } +} + +async function readJson(path: string): Promise { + const text = await readText(path); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +async function isFile(path: string): Promise { + try { + return (await Deno.stat(path)).isFile; + } catch { + return false; + } +} + +async function list(dir: string): Promise { + const out: Deno.DirEntry[] = []; + try { + for await (const e of Deno.readDir(dir)) out.push(e); + } catch { + // Missing, or outside the read permission. + } + return out; +} + +async function firstFile(paths: string[]): Promise { + for (const p of paths) if (await isFile(p)) return p; + return undefined; +} + +// --------------------------------------------------------------------------- +// Steam + +/** Steam's picture cache: `_header.jpg` (old) or `/header.jpg`, maybe in a hashed folder (2024+). */ +async function steamArt(root: string, appid: string): Promise<{ cover?: string; icon?: string }> { + const cache = `${root}/appcache/librarycache`; + let cover = await firstFile([`${cache}/${appid}_header.jpg`, `${cache}/${appid}/header.jpg`]); + let icon = await firstFile([`${cache}/${appid}_icon.jpg`]); + if (!cover || !icon) { + const entries = await list(`${cache}/${appid}`); + // The icon is the file named by a 40-character hash. + icon ??= entries.filter((e) => + e.isFile && /^[0-9a-f]{40}\.jpg$/.test(e.name) + ).map((e) => `${cache}/${appid}/${e.name}`)[0]; + for ( + const d of entries.filter((e) => + e.isDirectory + ) + ) { + cover ??= await firstFile([`${cache}/${appid}/${d.name}/header.jpg`]); + } + } + return { cover, icon }; +} + +async function latestLocalConfig(root: string): Promise { + let best: { path: string; mtime: number } | undefined; + for (const user of await list(`${root}/userdata`)) { + const path = `${root}/userdata/${user.name}/config/localconfig.vdf`; + try { + const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0; + if (!best || mtime > best.mtime) best = { path, mtime }; + } catch { + // No config for this account. + } + } + return best ? await readText(best.path) : undefined; +} + +export interface Scan { + games: Game[]; + /** Library folders Steam knows about but the extension may not read. */ + unreadable: string[]; + found: string[]; +} + +export async function steam(): Promise { + const roots: string[] = []; + for (const rel of STEAM_ROOTS) { + const path = `${HOME}/${rel}`; + try { + const real = await Deno.realPath(path); + if (!roots.includes(real) && await isFile(`${real}/steamapps/libraryfolders.vdf`)) roots.push(real); + } catch { + // Not there. + } + } + const games = new Map(); + const unreadable: string[] = []; + for (const root of roots) { + const vdf = await readText(`${root}/steamapps/libraryfolders.vdf`) ?? ""; + const folders = libraryFolders(vdf); + if (!folders.some((f) => f.path === root)) folders.unshift({ path: root, apps: {} }); + const local = await latestLocalConfig(root); + const played = local ? playtimes(local) : new Map(); + for (const folder of folders) { + const entries = await list(`${folder.path}/steamapps`); + if (!entries.length && Object.keys(folder.apps).length) unreadable.push(folder.path); + for (const e of entries) { + if (!/^appmanifest_\d+\.acf$/.test(e.name)) continue; + const app = parseAppManifest(await readText(`${folder.path}/steamapps/${e.name}`) ?? ""); + if (!app || isTool(app) || games.has(app.appid)) continue; + const game = steamGame(app, folder.path, played.get(app.appid)); + game.sizeBytes ??= folder.apps[app.appid] || undefined; + const art = await steamArt(root, app.appid); + // The local cache first; Steam's CDN for games it hasn't cached yet. + game.cover = art.cover ?? steamHeader(app.appid); + game.icon = art.icon; + games.set(app.appid, game); + } + } + } + return { games: [...games.values()], unreadable, found: roots }; +} + +// --------------------------------------------------------------------------- +// Lutris + +const LUTRIS_ART = [ + ".cache/lutris/coverart", + ".local/share/lutris/coverart", + ".cache/lutris/banners", + ".local/share/lutris/banners", +]; + +export async function lutris(signal?: AbortSignal): Promise { + let out: string; + try { + out = await exec("lutris", ["--list-games", "--installed", "--json"], { signal }); + } catch { + return { games: [], unreadable: [], found: [] }; + } + const games = await Promise.all( + parseLutrisJson(out).map(async (entry) => { + const g = lutrisGame(entry); + g.cover = await firstFile( + LUTRIS_ART.flatMap((d) => [`${HOME}/${d}/${entry.slug}.jpg`, `${HOME}/${d}/${entry.slug}.png`]), + ); + g.icon = await firstFile([`${HOME}/.local/share/icons/hicolor/128x128/apps/lutris_${entry.slug}.png`]); + return g; + }), + ); + return { games, unreadable: [], found: games.length ? ["lutris"] : [] }; +} + +// --------------------------------------------------------------------------- +// Heroic + +const HEROIC_FILES: Record = { + legendary: { + library: "store_cache/legendary_library.json", + installed: ["legendaryConfig/legendary/installed.json"], + }, + gog: { library: "store_cache/gog_library.json", installed: ["gog_store/installed.json"] }, + nile: { library: "store_cache/nile_library.json", installed: ["nile_config/nile/installed.json"] }, +}; + +/** Heroic keeps downloaded art in `images-cache/`. */ +async function cachedImage(root: string, url: string | undefined): Promise { + if (!url) return undefined; + const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)))) + .map((b) => b.toString(16).padStart(2, "0")).join(""); + return await firstFile([ + `${root}/images-cache/${hash}`, + `${root}/images-cache/${hash}.jpg`, + `${root}/images-cache/${hash}.png`, + ]); +} + +export async function heroic(): Promise { + const games: Game[] = []; + const found: string[] = []; + for (const rel of HEROIC_ROOTS) { + const root = `${HOME}/${rel}`; + const played = heroicPlaytimes(await readJson(`${root}/store/timestamp.json`)); + for (const runner of Object.keys(HEROIC_FILES) as HeroicRunner[]) { + const files = HEROIC_FILES[runner]; + const library = heroicLibrary(await readJson(`${root}/${files.library}`)); + if (!library.length) continue; + found.push(`${root}/${files.library}`); + const installed = new Map(); + for (const f of files.installed) { + for (const [k, v] of heroicInstalled(await readJson(`${root}/${f}`))) installed.set(k, v); + } + for (const g of library) { + if (!g.is_installed && !installed.has(g.app_name)) continue; + if (games.some((x) => x.slug === g.app_name)) continue; + const game = heroicGame(g, runner, installed.get(g.app_name), played.get(g.app_name)); + game.cover = await cachedImage(root, game.cover) ?? game.cover; + games.push(game); + } + } + } + return { games, unreadable: [], found }; +} + +// --------------------------------------------------------------------------- +// ProtonDB, cached a week (a day when it has nothing for the game) + +const WEEK = 7 * 86_400_000; +const DAY = 86_400_000; + +type Json = Parameters[1]; + +export async function cachedProton(appid: string): Promise { + const hit = await storage.get(`proton:${appid}`).catch(() => undefined) as + | { at: number; summary: ProtonSummary | null } + | undefined; + if (!hit) return undefined; + const ttl = hit.summary ? WEEK : DAY; + return Date.now() - hit.at < ttl ? hit.summary : undefined; +} + +export async function fetchProton(appid: string, signal?: AbortSignal): Promise { + let summary: ProtonSummary | null = null; + try { + const res = await fetch(protonDbApi(appid), { signal }); + if (res.ok) summary = await res.json() as ProtonSummary; + else await res.body?.cancel(); + if (!res.ok && res.status !== 404) return null; + } catch (e) { + if (signal?.aborted) throw e; + return null; + } + await storage.set(`proton:${appid}`, { at: Date.now(), summary } as unknown as Json).catch(() => {}); + return summary; +} diff --git a/extensions/hacker-news/api.ts b/extensions/hacker-news/api.ts new file mode 100644 index 0000000..f5d0e7e --- /dev/null +++ b/extensions/hacker-news/api.ts @@ -0,0 +1,179 @@ +// Hacker News (Firebase and Algolia APIs) and feed storage. + +import { storage } from "@backslash/api"; +import { + type AlgoliaItem, + type Comment, + discoverFeeds, + type Feed, + type FeedName, + FEEDS, + type HnItem, + parseFeed, + type Story, + topComments, + toStory, +} from "./lib.ts"; + +const FIREBASE = "https://hacker-news.firebaseio.com/v0"; +const ALGOLIA = "https://hn.algolia.com/api/v1"; + +type Json = Parameters[1]; + +export async function load(key: string): Promise { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise { + await storage.set(key, value as Json).catch(() => {}); +} + +async function getJson(url: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(url, { signal }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`${new URL(url).host} is unreachable: ${(e as Error).message}`); + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`${new URL(url).host} answered ${res.status}`); + } + return await res.json() as T; +} + +/** The first `count` stories of a list, in HN's order. */ +export async function stories(feed: FeedName, count: number, signal?: AbortSignal): Promise { + const ids = await getJson(`${FIREBASE}/${FEEDS[feed].path}.json`, signal); + const items = await Promise.all( + ids.slice(0, count).map((id) => getJson(`${FIREBASE}/item/${id}.json`, signal).catch(() => null)), + ); + return items.map((item, i) => item && toStory(item, i + 1)).filter((s): s is Story => !!s); +} + +/** Top comments, with the whole tree from Algolia in one request; Firebase if Algolia fails. */ +export async function comments(story: Story, signal?: AbortSignal, limit = 8): Promise { + try { + const tree = await getJson(`${ALGOLIA}/items/${story.id}`, signal); + return topComments(tree, story.kids, limit); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + const kids = await Promise.all( + story.kids.slice(0, limit).map((id) => getJson(`${FIREBASE}/item/${id}.json`, signal)), + ); + const tree: AlgoliaItem = { + id: story.id, + children: kids.filter((k): k is HnItem => !!k && !k.dead && !k.deleted).map((k) => ({ + id: k.id, + author: k.by, + text: k.text, + created_at_i: k.time, + children: [], + })), + }; + return topComments(tree, story.kids, limit); + } +} + +// Stories opened from the launcher, to dim them. +export async function seenStories(): Promise> { + return new Set(await load("hn:seen") ?? []); +} + +export async function markSeen(id: number): Promise { + const seen = (await load("hn:seen") ?? []).filter((s) => s !== id); + await save("hn:seen", [id, ...seen].slice(0, 500)); +} + +// --------------------------------------------------------------------------- +// Feeds + +export interface SavedFeed { + url: string; + title?: string; +} + +export async function savedFeeds(): Promise { + return await load("rss:feeds") ?? []; +} + +export async function setFeeds(feeds: SavedFeed[]): Promise { + await save("rss:feeds", feeds); +} + +const ACCEPT = "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, */*;q=0.5"; + +async function getText(url: string, signal?: AbortSignal): Promise<{ text: string; type: string; url: string }> { + const timeout = AbortSignal.timeout(15_000); + let res: Response; + try { + res = await fetch(url, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + headers: { Accept: ACCEPT, "User-Agent": "Backslash-RSS/1.0 (+https://github.com/backslash-app/plugins)" }, + }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Error(timeout.aborted ? "timed out" : (e as Error).message); + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`answered ${res.status}`); + } + return { text: await res.text(), type: res.headers.get("content-type") ?? "", url: res.url || url }; +} + +export interface CachedFeed { + at: number; + feed: Feed; +} + +/** Fetches and parses a feed, and keeps a trimmed copy to show instantly next time. */ +export async function fetchFeed(url: string, signal?: AbortSignal): Promise { + const { text } = await getText(url, signal); + const feed = parseFeed(text, url); + feed.items = feed.items.slice(0, 50).map((i) => ({ ...i, html: i.html.slice(0, 4000) })); + await save(`rss:cache:${url}`, { at: Date.now(), feed }); + return feed; +} + +export function cachedFeed(url: string): Promise { + return load(`rss:cache:${url}`); +} + +/** A feed URL, or a web page advertising one. */ +export async function findFeed(url: string, signal?: AbortSignal): Promise<{ url: string; feed: Feed }> { + const page = await getText(url, signal); + try { + return { url: page.url, feed: parseFeed(page.text, page.url) }; + } catch { + const found = discoverFeeds(page.text, page.url); + if (!found.length) throw new Error("No RSS or Atom feed found at this address"); + const feedUrl = found[0]; + const { text } = await getText(feedUrl, signal); + return { url: feedUrl, feed: parseFeed(text, feedUrl) }; + } +} + +// Read state: item keys ("|") with when they were read. +export async function readState(): Promise> { + return await load>("rss:read") ?? {}; +} + +export async function setRead(keys: string[], read: boolean): Promise { + const state = await readState(); + for (const k of keys) { + if (read) state[k] = Date.now(); + else delete state[k]; + } + await save("rss:read", state); +} + +/** Forgets read marks of items no feed has anymore. */ +export async function pruneRead(present: Set): Promise { + const state = await readState(); + const kept = Object.fromEntries(Object.entries(state).filter(([k]) => present.has(k))); + if (Object.keys(kept).length !== Object.keys(state).length) await save("rss:read", kept); +} + +export const itemKey = (feedUrl: string, id: string) => `${feedUrl}|${id}`; diff --git a/extensions/hacker-news/backslash.toml b/extensions/hacker-news/backslash.toml new file mode 100644 index 0000000..8c72f05 --- /dev/null +++ b/extensions/hacker-news/backslash.toml @@ -0,0 +1,47 @@ +[extension] +id = "hacker-news" +title = "Hacker News & RSS" +description = "Top, new, best, Ask and Show HN stories with their comments, and your RSS and Atom feeds with unread tracking" +version = "1.0.0" +author = "Backslash" +icon = "bs-rss" +license = "MIT" + +# RSS feeds live on hosts the user chooses, so the extension may reach any +# website ("*" is the only way to allow hosts not known in advance). Hacker News +# itself only uses hacker-news.firebaseio.com and hn.algolia.com. +[permissions] +net = ["*"] + +[[commands]] +name = "hacker-news" +title = "Hacker News" +description = "Top, new, best, Ask HN and Show HN stories, with points, comments and the top comments" +icon = "bs-arrow-up" +keywords = ["hn", "hacker news", "ycombinator", "news", "tech", "actualités", "nouvelles", "informatique"] + +[[commands]] +name = "rss" +title = "RSS Feeds" +description = "Articles of your feeds, unread first" +icon = "bs-rss" +keywords = ["rss", "atom", "feeds", "news", "reader", "blog", "flux", "actualités", "articles", "lecteur"] + +[[commands]] +name = "add-feed" +title = "Add RSS Feed" +description = "Subscribe to an RSS or Atom feed, or a website advertising one" +icon = "bs-plus" +keywords = ["rss", "atom", "subscribe", "feed", "ajouter", "flux", "abonner"] + +[[preferences]] +name = "feeds" +title = "Feeds" +description = "Feed URLs, separated by spaces or commas (feeds added from the launcher are kept separately)" + +[[preferences]] +name = "stories" +title = "Number of stories" +description = "Stories loaded per Hacker News list (10 to 100)" +type = "number" +default = 40 diff --git a/extensions/hacker-news/commands/add-feed.ts b/extensions/hacker-news/commands/add-feed.ts new file mode 100644 index 0000000..089cc7f --- /dev/null +++ b/extensions/hacker-news/commands/add-feed.ts @@ -0,0 +1,4 @@ +import { close } from "@backslash/api"; +import { addFeedForm } from "../feeds.ts"; + +export default addFeedForm((title) => close(`Subscribed to ${title}`)); diff --git a/extensions/hacker-news/commands/hacker-news.ts b/extensions/hacker-news/commands/hacker-news.ts new file mode 100644 index 0000000..8100168 --- /dev/null +++ b/extensions/hacker-news/commands/hacker-news.ts @@ -0,0 +1,135 @@ +import { + type Action, + action, + close, + type Color, + type Context, + copy, + detail, + Icons, + type Item, + list, + meta, + openExternal, +} from "@backslash/api"; +import { comments, markSeen, seenStories, stories } from "../api.ts"; +import { + ago, + commentsMarkdown, + type FeedName, + FEEDS, + fullDate, + hnUrl, + htmlToMarkdown, + plural, + type Story, + userUrl, +} from "../lib.ts"; + +const KINDS: Record = { + story: { label: "Story", icon: Icons.arrowUp, tint: "orange" }, + ask: { label: "Ask HN", icon: Icons.question, tint: "purple" }, + show: { label: "Show HN", icon: Icons.eye, tint: "teal" }, + job: { label: "Job", icon: Icons.ticket, tint: "green" }, + poll: { label: "Poll", icon: Icons.activity, tint: "blue" }, +}; + +/** Opens a page and remembers the story as read. */ +const visit = (story: Story, url: string, title: string, options = {}) => + action(title, async () => { + await markSeen(story.id); + await openExternal(url); + return close(); + }, { icon: Icons.external, ...options }); + +async function commentsView(story: Story, ctx: Context) { + const top = await comments(story, ctx.signal); + return detail({ + title: story.title, + markdown: commentsMarkdown(story, top), + status: `${plural(story.points, "point")} · ${plural(story.comments, "comment")}`, + metadata: [ + meta.text("Points", story.points, { icon: Icons.arrowUp, color: "orange" }), + meta.text("Comments", story.comments, { icon: Icons.comment }), + meta.person("Author", story.author), + meta.text("Posted", ago(story.time)), + ...(story.domain ? [meta.link("Site", story.domain, story.url!)] : []), + ], + actions: [ + visit(story, hnUrl(story.id), "Open Comments on Hacker News"), + ...(story.url ? [visit(story, story.url, "Open Link")] : []), + copy(hnUrl(story.id), "Copy Hacker News Link"), + ], + }); +} + +function storyItem(story: Story, seen: boolean): Item { + const kind = KINDS[story.kind]; + const hot = story.points >= 300; + const link = story.url ?? hnUrl(story.id); + const actions: Action[] = [ + visit(story, link, story.url ? "Open Link" : "Open on Hacker News"), + visit(story, hnUrl(story.id), "Open Comments", { icon: Icons.comment }), + action("Show Top Comments", (ctx) => commentsView(story, ctx), { + icon: Icons.comment, + shortcut: "t", + }), + visit(story, userUrl(story.author), `Open ${story.author}’s Profile`, { + icon: Icons.person, + section: "Hacker News", + }), + copy(link, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(hnUrl(story.id), "Copy Hacker News Link", { shortcut: "period", section: "Copy" }), + copy(`[${story.title}](${link})`, "Copy as Markdown Link", { section: "Copy" }), + ]; + return { + id: String(story.id), + title: story.title, + subtitle: story.domain, + icon: { source: kind.icon, tint: seen ? "gray" : kind.tint }, + keywords: [story.author, story.domain ?? "", kind.label], + accessories: [ + { + text: String(story.points), + icon: { source: Icons.arrowUp, tint: hot ? "orange" : "gray" }, + tooltip: plural(story.points, "point"), + }, + { text: String(story.comments), icon: Icons.comment, tooltip: plural(story.comments, "comment") }, + { text: ago(story.time), tooltip: fullDate(story.time) }, + ], + detail: { + stats: [ + { label: "Points", value: story.points, color: hot ? "orange" : undefined }, + { label: "Comments", value: story.comments }, + { label: "Rank", value: `#${story.rank}` }, + ], + markdown: [`**${story.title}**`, ...(story.text ? ["", htmlToMarkdown(story.text).slice(0, 1200)] : [])].join( + "\n", + ), + metadata: [ + meta.person("Author", story.author), + meta.text("Posted", `${ago(story.time)} · ${fullDate(story.time)}`, { icon: Icons.clock }), + meta.tags("Type", [{ text: kind.label, color: kind.tint }]), + ...(story.domain ? [meta.link("Site", story.domain, story.url!)] : []), + meta.link("Discussion", `news.ycombinator.com/item?id=${story.id}`, hnUrl(story.id)), + ...(seen ? [meta.text("Status", "Opened", { icon: Icons.check, color: "green" })] : []), + ], + }, + actions, + }; +} + +export default list({ + placeholder: "Filter stories by title, site or author…", + filter: { + options: (Object.keys(FEEDS) as FeedName[]).map((value) => ({ value, title: FEEDS[value].title })), + }, + empty: { title: "No stories", description: "Hacker News returned nothing for this list", icon: Icons.arrowUp }, + status: (items) => `${items.length} stories · Hacker News`, + async items({ filter, signal, preferences }) { + const count = Math.max(10, Math.min(100, Number(preferences.stories) || 40)); + const feed = (filter && filter in FEEDS ? filter : "top") as FeedName; + const [found, seen] = await Promise.all([stories(feed, count, signal), seenStories()]); + return found.map((s) => storyItem(s, seen.has(s.id))); + }, +}); diff --git a/extensions/hacker-news/commands/rss.ts b/extensions/hacker-news/commands/rss.ts new file mode 100644 index 0000000..bc70799 --- /dev/null +++ b/extensions/hacker-news/commands/rss.ts @@ -0,0 +1,298 @@ +import { + type Action, + action, + close, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + openExternal, + pop, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { cachedFeed, fetchFeed, itemKey, pruneRead, readState, type SavedFeed, setRead } from "../api.ts"; +import { addFeedForm, allFeeds, live, removeFeed } from "../feeds.ts"; +import { ago, dateSection, domain, excerpt, type Feed, type FeedItem, fullDate, plural } from "../lib.ts"; + +type Source = SavedFeed & { fromPreferences: boolean }; +interface Result { + source: Source; + feed?: Feed; + error?: string; +} + +// Fetched feeds, kept while the launcher runs this extension so that marking +// an article as read doesn't download every feed again. +const memory = new Map(); +const FRESH_FOR = 5 * 60_000; + +async function fetchAll(sources: Source[], signal: AbortSignal): Promise { + return await Promise.all(sources.map(async (source): Promise => { + const hit = memory.get(source.url); + if (hit && Date.now() - hit.at < FRESH_FOR) return { source, feed: hit.feed }; + try { + const feed = await fetchFeed(source.url, signal); + memory.set(source.url, { at: Date.now(), feed }); + return { source, feed }; + } catch (e) { + if (signal.aborted) throw e; + const cached = await cachedFeed(source.url); + return { source, feed: cached?.feed, error: (e as Error).message }; + } + })); +} + +const feedTitle = (r: Result) => r.source.title || r.feed?.title || domain(r.source.url) || r.source.url; + +const addFeed = action("Add Feed", () => addFeedForm(() => pop()), { + icon: Icons.plus, + shortcut: "n", + section: "Feeds", +}); + +const refreshAll = action("Refresh Feeds", () => { + memory.clear(); + return refresh(); +}, { icon: Icons.refresh, shortcut: "r", section: "Feeds" }); + +function manageView(pref: unknown): ListSpec { + return list({ + title: "Manage Feeds", + placeholder: "Filter feeds…", + empty: { title: "No feeds yet", description: "Add one with Ctrl+N", icon: Icons.rss }, + actions: [addFeed], + status: (items) => plural(items.length, "feed"), + items: ({ signal }) => + live(async function* () { + const sources = await allFeeds(pref); + yield sources.map((s): Item => { + const feed = memory.get(s.url)?.feed; + const title = s.title || feed?.title || domain(s.url) || s.url; + return { + id: s.url, + title, + subtitle: s.url, + icon: { source: Icons.rss, tint: "orange" }, + accessories: [ + ...(feed ? [{ text: plural(feed.items.length, "article") }] : []), + ...(s.fromPreferences ? [{ tag: "Settings", tooltip: "Listed in the extension settings" }] : []), + ], + detail: { + markdown: feed?.description, + metadata: [ + meta.text("Feed", s.url, { icon: Icons.rss }), + ...(feed?.link ? [meta.link("Website", domain(feed.link) ?? feed.link, feed.link)] : []), + ...(feed ? [meta.text("Articles", feed.items.length)] : []), + meta.text("Added", s.fromPreferences ? "In the extension settings" : "From the launcher"), + ], + }, + actions: [ + ...(feed?.link ? [open(feed.link, "Open Website")] : []), + copy(s.url, "Copy Feed URL"), + ...(s.fromPreferences ? [] : [ + action("Remove Feed", async () => { + await removeFeed(s.url); + memory.delete(s.url); + await toast(`Unsubscribed from ${title}`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Unsubscribe from ${title}?`, + shortcut: "x", + section: "Feeds", + }), + ]), + addFeed, + ], + }; + }); + }, signal), + }); +} + +function articleItem(r: Result, it: FeedItem, unread: boolean, section: string, all: Result[], pref: unknown): Item { + const key = itemKey(r.source.url, it.id); + const title = feedTitle(r); + const keysOf = (rs: Result[]) => rs.flatMap((x) => (x.feed?.items ?? []).map((i) => itemKey(x.source.url, i.id))); + const link = it.link ?? r.feed?.link; + const actions: Action[] = [ + ...(link + ? [action("Open Article", async () => { + await setRead([key], true); + await openExternal(link); + return close(); + }, { icon: Icons.external })] + : []), + action(unread ? "Mark as Read" : "Mark as Unread", async () => { + await setRead([key], unread); + return refresh(); + }, { icon: unread ? Icons.check : Icons.dot, shortcut: "u" }), + ...(it.comments ? [open(it.comments, "Open Comments", { icon: Icons.comment })] : []), + ...(it.enclosure ? [open(it.enclosure.url, "Open Attachment", { icon: Icons.download })] : []), + action(`Mark All in ${title} as Read`, async () => { + await setRead(keysOf([r]), true); + return refresh(); + }, { icon: Icons.check, shortcut: "u", section: "Feed" }), + action("Mark Everything as Read", async () => { + await setRead(keysOf(all), true); + return refresh(); + }, { icon: Icons.check, confirm: "Mark every article as read?", section: "Feed" }), + ...(r.feed?.link ? [open(r.feed.link, `Open ${title} Website`, { icon: Icons.globe, section: "Feed" })] : []), + ...(r.source.fromPreferences ? [] : [ + action(`Unsubscribe from ${title}`, async () => { + await removeFeed(r.source.url); + memory.delete(r.source.url); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: `Unsubscribe from ${title}?`, section: "Feed" }), + ]), + addFeed, + action("Manage Feeds", () => manageView(pref), { icon: Icons.settings, section: "Feeds" }), + refreshAll, + ...(link + ? [ + copy(link, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${it.title}](${link})`, "Copy as Markdown Link", { section: "Copy" }), + ] + : []), + ]; + const summary = excerpt(it.html); + return { + id: key, + title: it.title, + subtitle: section === title ? it.author : title, + icon: { source: Icons.rss, tint: unread ? "orange" : "gray" }, + section, + keywords: [title, it.author ?? "", ...it.categories], + accessories: [ + ...(it.categories[0] ? [{ tag: it.categories[0] }] : []), + ...(unread ? [{ icon: { source: Icons.dot, tint: "blue" as const }, tooltip: "Unread" }] : []), + ...(it.date ? [{ text: ago(it.date), tooltip: fullDate(it.date) }] : []), + ], + detail: { + markdown: `## ${it.title}${summary ? `\n\n${summary}` : ""}`, + metadata: [ + meta.text("Feed", title, { icon: Icons.rss, color: "orange" }), + ...(it.author ? [meta.person("Author", it.author)] : []), + ...(it.date ? [meta.text("Published", fullDate(it.date), { icon: Icons.calendar })] : []), + ...(it.categories.length ? [meta.tags("Categories", it.categories.slice(0, 5))] : []), + ...(link ? [meta.link("Link", domain(link) ?? link, link)] : []), + meta.text("Status", unread ? "Unread" : "Read", { + icon: unread ? Icons.dot : Icons.check, + color: unread ? "blue" : "green", + }), + ], + }, + actions, + }; +} + +function build(results: Result[], read: Record, filter: string, pref: unknown): Item[] { + type Row = { r: Result; it: FeedItem; unread: boolean }; + const rows: Row[] = results.flatMap((r) => + (r.feed?.items ?? []).map((it) => ({ r, it, unread: !read[itemKey(r.source.url, it.id)] })) + ); + const byDate = (a: Row, b: Row) => (b.it.date ?? 0) - (a.it.date ?? 0); + let items: Item[]; + if (filter === "date") { + items = rows.sort(byDate).map((x) => articleItem(x.r, x.it, x.unread, dateSection(x.it.date), results, pref)); + } else { + const shown = filter === "unread" ? rows.filter((x) => x.unread) : rows; + // Feeds in the order they were added; newest first in each. + items = results.flatMap((r) => + shown.filter((x) => x.r === r).sort(byDate).map((x) => + articleItem(r, x.it, x.unread, feedTitle(r), results, pref) + ) + ); + if (filter === "unread" && !items.length && rows.length) { + items.push({ + id: "caught-up", + title: "All caught up", + subtitle: `${plural(rows.length, "article")} read`, + icon: { source: Icons.check, tint: "green" }, + detail: { markdown: "Nothing new in your feeds. Press **Tab** to see every article." }, + actions: [refreshAll, addFeed, action("Manage Feeds", () => manageView(pref), { icon: Icons.settings })], + }); + } + } + for (const r of results.filter((x) => x.error)) { + items.push({ + id: `error:${r.source.url}`, + title: `Couldn't refresh ${feedTitle(r)}`, + subtitle: r.error, + icon: { source: Icons.alert, tint: "red" }, + section: "Problems", + detail: { + markdown: `**${r.source.url}** ${r.error}.${r.feed ? "\n\nShowing the articles from the last refresh." : ""}`, + }, + actions: [ + refreshAll, + open(r.source.url, "Open Feed URL"), + ...(r.source.fromPreferences ? [] : [ + action("Unsubscribe", async () => { + await removeFeed(r.source.url); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: `Unsubscribe from ${feedTitle(r)}?` }), + ]), + ], + }); + } + return items; +} + +const onboarding: Item = { + id: "onboarding", + title: "Add your first feed", + subtitle: "Any RSS or Atom feed, or a website that has one", + icon: { source: Icons.plus, tint: "orange" }, + detail: { + markdown: "Paste the address of a feed or of a blog: its feed is found automatically.\n\n" + + "You can also list feed URLs in **Settings → Extensions → Hacker News & RSS**.", + }, + actions: [addFeed], +}; + +export default list({ + placeholder: "Filter articles by title, feed, author or category…", + filter: { + options: [ + { value: "unread", title: "Unread" }, + { value: "feed", title: "By Feed" }, + { value: "date", title: "By Date" }, + ], + }, + empty: { title: "No articles", description: "Your feeds have no articles yet", icon: Icons.rss }, + actions: [addFeed, refreshAll], + status: (items) => { + const unread = items.filter((i) => i.accessories?.some((a) => a.tooltip === "Unread")).length; + return items.some((i) => i.id === "onboarding") + ? "No feeds yet" + : `${unread} unread · ${plural(items.filter((i) => !i.id?.startsWith("error:")).length, "article")}`; + }, + items: ({ preferences, filter, signal }) => + live(async function* () { + const sources = await allFeeds(preferences.feeds); + if (!sources.length) { + yield [onboarding]; + return; + } + const mode = filter ?? "unread"; + // The last copy first, so the list shows up instantly, then the network. + const cached = await Promise.all(sources.map(async (source) => ({ + source, + feed: memory.get(source.url)?.feed ?? (await cachedFeed(source.url))?.feed, + }))); + if (cached.some((c) => c.feed)) yield build(cached, await readState(), mode, preferences.feeds); + const results = await fetchAll(sources, signal); + const read = await readState(); + yield build(results, read, mode, preferences.feeds); + const keys = results.flatMap((r) => (r.feed?.items ?? []).map((i) => itemKey(r.source.url, i.id))); + const unread = keys.filter((k) => !read[k]).length; + updateCommandMetadata({ subtitle: unread ? `${unread} unread` : null }).catch(() => {}); + if (results.every((r) => !r.error)) await pruneRead(new Set(keys)); + }, signal), +}); diff --git a/extensions/hacker-news/feeds.ts b/extensions/hacker-news/feeds.ts new file mode 100644 index 0000000..cea9baa --- /dev/null +++ b/extensions/hacker-news/feeds.ts @@ -0,0 +1,68 @@ +// Adding and removing feeds, shared by "RSS Feeds" and "Add RSS Feed". + +import { type Effect, field, form, type FormSpec, toast } from "@backslash/api"; +import { findFeed, type SavedFeed, savedFeeds, setFeeds } from "./api.ts"; +import { feedList } from "./lib.ts"; + +// Views of this extension run in one process: adding a feed from a form +// reloads the list still open below it. +const changes = new EventTarget(); + +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live(load: () => AsyncIterable, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + for await (const value of load()) yield value; + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +/** Feeds from the preference, then those added from the launcher. */ +export async function allFeeds(pref: unknown): Promise<(SavedFeed & { fromPreferences: boolean })[]> { + const saved = await savedFeeds(); + const fromPrefs = feedList(pref).filter((u) => !saved.some((s) => s.url === u)); + return [ + ...fromPrefs.map((url) => ({ url, fromPreferences: true })), + ...saved.map((s) => ({ ...s, fromPreferences: false })), + ]; +} + +export async function removeFeed(url: string): Promise { + await setFeeds((await savedFeeds()).filter((f) => f.url !== url)); + notifyChanged(); +} + +export function addFeedForm(done: (title: string) => Effect): FormSpec { + return form({ + title: "Add RSS Feed", + submitTitle: "Add Feed", + fields: [ + field.text("url", "Address", { placeholder: "https://example.com/feed.xml or a website", required: true }), + field.text("title", "Name", { placeholder: "Optional: the feed's own title by default" }), + ], + async submit(values, ctx) { + let raw = String(values.url ?? "").trim(); + if (!raw) throw new Error("Enter the address of a feed"); + if (!/^https?:\/\//i.test(raw)) raw = `https://${raw}`; + const { url, feed } = await findFeed(raw, ctx.signal); + const feeds = await savedFeeds(); + if (feeds.some((f) => f.url === url) || feedList(ctx.preferences.feeds).includes(url)) { + throw new Error(`Already subscribed to ${feed.title}`); + } + const title = String(values.title ?? "").trim() || feed.title; + await setFeeds([...feeds, { url, title }]); + notifyChanged(); + await toast(`Subscribed to ${title}`, { style: "success", message: `${feed.items.length} articles` }); + return done(title); + }, + }); +} diff --git a/extensions/hacker-news/lib.ts b/extensions/hacker-news/lib.ts new file mode 100644 index 0000000..fe7299c --- /dev/null +++ b/extensions/hacker-news/lib.ts @@ -0,0 +1,650 @@ +// Hacker News responses, HTML to markdown, and an RSS 2.0 / RSS 1.0 / Atom +// parser. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Text + +const ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + ndash: "–", + mdash: "—", + hellip: "…", + laquo: "«", + raquo: "»", + lsquo: "‘", + rsquo: "’", + ldquo: "“", + rdquo: "”", + bull: "•", + middot: "·", + copy: "©", + reg: "®", + trade: "™", + euro: "€", + pound: "£", + deg: "°", + times: "×", + eacute: "é", + egrave: "è", + ecirc: "ê", + agrave: "à", + acirc: "â", + ccedil: "ç", + ocirc: "ô", + ucirc: "û", + icirc: "î", + ouml: "ö", + uuml: "ü", + auml: "ä", + szlig: "ß", + thinsp: " ", + zwnj: "", + shy: "", +}; + +export function decodeEntities(s: string): string { + return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all; + } + return ENTITIES[e] ?? ENTITIES[e.toLowerCase()] ?? all; + }); +} + +/** HTML → one line of plain text. */ +export function htmlToText(html: string): string { + return decodeEntities( + html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "").replace(/|<\/p>/gi, " ").replace(/<[^>]+>/g, ""), + ).replace(/\s+/g, " ").trim(); +} + +/** + * HTML (HN comments, feed articles) → the launcher's markdown subset: + * paragraphs, links, emphasis, code blocks, lists, quotes, headings. + */ +export function htmlToMarkdown(html: string): string { + const blocks: string[] = []; + // Code blocks first, so their content is kept as is. + let s = html.replace(/]*>([\s\S]*?)<\/pre>/gi, (_, code: string) => { + blocks.push(decodeEntities(code.replace(/<\/?code[^>]*>/gi, "").replace(/<[^>]+>/g, "")).replace(/\n+$/, "")); + return `\n\n\uE000${blocks.length - 1}\uE000\n\n`; + }); + s = s + .replace(/<(script|style)[\s\S]*?<\/\1>/gi, "") + .replace(//g, "") + .replace(/\s*\n\s*/g, " ") + .replace( + /]*>([\s\S]*?)<\/h\1>/gi, + (_, l: string, t: string) => `\n\n${"#".repeat(Math.min(3, Number(l) + 1))} ${t}\n\n`, + ) + .replace(/]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href: string, label: string) => { + const text = htmlToText(label).replace(/[[\]]/g, "") || href; + const url = decodeEntities(href).replace(/ /g, "%20"); + return /^https?:/i.test(url) ? `[${text}](${url})` : text; + }) + .replace(/<(b|strong)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, t: string) => t.trim() ? `**${t.trim()}**` : "") + .replace(/<(i|em)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, t: string) => t.trim() ? `*${t.trim()}*` : "") + .replace(/]*>([\s\S]*?)<\/code>/gi, "`$1`") + .replace(/]*>/gi, "\n- ") + .replace(/<\/li>/gi, "") + .replace(/<\/?(ul|ol)[^>]*>/gi, "\n\n") + .replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_, q: string) => `\n\n> ${htmlToText(q)}\n\n`) + .replace(//gi, "\n") + .replace(/<\/?(p|div|section|article|figure|figcaption|header|footer|table|tr)[^>]*>/gi, "\n\n") + .replace(//gi, "\n\n---\n\n") + .replace(/<[^>]+>/g, ""); + const text = decodeEntities(s) + .split("\n") + .map((l) => l.replace(/[ \t ]+/g, " ").trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return text.replace(/\uE000(\d+)\uE000/g, (_, i: string) => "```\n" + blocks[Number(i)] + "\n```"); +} + +/** "https://www.example.com/a" → "example.com". */ +export function domain(url: string | undefined): string | undefined { + if (!url) return undefined; + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +/** "5 min", "3 h", "2 d", then a date. */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, Math.round((now - ms) / 1000)); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)} min`; + if (s < 86400) return `${Math.floor(s / 3600)} h`; + if (s < 30 * 86400) return `${Math.floor(s / 86400)} d`; + return new Date(ms).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +export function fullDate(ms: number): string { + return new Date(ms).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +// --------------------------------------------------------------------------- +// Hacker News + +export const FEEDS = { + top: { title: "Top", path: "topstories" }, + new: { title: "New", path: "newstories" }, + best: { title: "Best", path: "beststories" }, + ask: { title: "Ask HN", path: "askstories" }, + show: { title: "Show HN", path: "showstories" }, +} as const; +export type FeedName = keyof typeof FEEDS; + +/** An item of the Firebase API (`/v0/item/.json`). */ +export interface HnItem { + id: number; + type?: "story" | "job" | "comment" | "poll" | "pollopt"; + by?: string; + time?: number; + title?: string; + url?: string; + text?: string; + score?: number; + descendants?: number; + kids?: number[]; + dead?: boolean; + deleted?: boolean; +} + +export interface Story { + id: number; + rank: number; + title: string; + url?: string; + domain?: string; + author: string; + points: number; + comments: number; + time: number; + kind: "story" | "ask" | "show" | "job" | "poll"; + text?: string; + kids: number[]; +} + +export const hnUrl = (id: number) => `https://news.ycombinator.com/item?id=${id}`; +export const userUrl = (name: string) => `https://news.ycombinator.com/user?id=${encodeURIComponent(name)}`; + +export function toStory(item: HnItem, rank: number): Story | undefined { + if (!item || item.dead || item.deleted || !item.title) return undefined; + const title = decodeEntities(item.title); + const kind = item.type === "job" + ? "job" + : item.type === "poll" + ? "poll" + : /^Ask HN\b/i.test(title) + ? "ask" + : /^Show HN\b/i.test(title) + ? "show" + : "story"; + return { + id: item.id, + rank, + title, + url: item.url, + domain: domain(item.url), + author: item.by ?? "unknown", + points: item.score ?? 0, + comments: item.descendants ?? 0, + time: (item.time ?? 0) * 1000, + kind, + text: item.text, + kids: item.kids ?? [], + }; +} + +/** A comment of the Algolia API (`/api/v1/items/`). */ +export interface AlgoliaItem { + id: number; + author?: string | null; + text?: string | null; + created_at_i?: number; + title?: string | null; + points?: number | null; + children?: AlgoliaItem[]; +} + +export interface Comment { + id: number; + author: string; + time: number; + markdown: string; + replies: number; +} + +function countReplies(c: AlgoliaItem): number { + return (c.children ?? []).reduce((n, k) => n + (k.text ? 1 : 0) + countReplies(k), 0); +} + +/** Top-level comments in HN's order (the story's `kids`), dead ones skipped. */ +export function topComments(tree: AlgoliaItem, order: number[], limit = 8): Comment[] { + const rank = new Map(order.map((id, i) => [id, i])); + return [...(tree.children ?? [])] + .filter((c) => c.text && c.author) + .sort((a, b) => (rank.get(a.id) ?? 1e9) - (rank.get(b.id) ?? 1e9)) + .slice(0, limit) + .map((c) => ({ + id: c.id, + author: c.author!, + time: (c.created_at_i ?? 0) * 1000, + markdown: htmlToMarkdown(c.text!), + replies: countReplies(c), + })); +} + +/** The comments page of a story as markdown. */ +export function commentsMarkdown(story: Story, comments: Comment[], now = Date.now()): string { + const head = [`# ${story.title}`, ""]; + head.push( + [`**${plural(story.points, "point")}**`, `by ${story.author}`, ago(story.time, now), story.domain] + .filter(Boolean).join(" · "), + ); + if (story.text) head.push("", htmlToMarkdown(story.text)); + if (!comments.length) return [...head, "", "---", "", "*No comments yet.*"].join("\n"); + const body = comments.flatMap((c) => [ + "", + "---", + "", + `**${c.author}** · ${ago(c.time, now)}${ + c.replies ? ` · ${c.replies} ${c.replies === 1 ? "reply" : "replies"}` : "" + }`, + "", + c.markdown, + ]); + const more = story.comments - comments.length; + return [ + ...head, + ...body, + ...(more > 0 ? ["", "---", "", `[${plural(more, "more comment")} on Hacker News](${hnUrl(story.id)})`] : []), + ] + .join("\n"); +} + +// --------------------------------------------------------------------------- +// XML + +export interface XmlNode { + name: string; + attrs: Record; + children: XmlNode[]; + /** Text and CDATA directly inside, decoded. */ + text: string; + /** Text and children in document order. */ + content: (string | XmlNode)[]; +} + +const node = (name: string, attrs: Record = {}): XmlNode => ({ + name, + attrs, + children: [], + text: "", + content: [], +}); + +function addText(n: XmlNode, text: string) { + if (!text) return; + n.text += text; + n.content.push(text); +} + +function parseAttrs(s: string): Record { + const attrs: Record = {}; + for (const m of s.matchAll(/([^\s=/]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)) { + attrs[m[1]] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? ""); + } + return attrs; +} + +/** + * A forgiving XML parser: enough for feeds found in the wild (CDATA, + * entities, comments, doctype, unclosed or mismatched tags). + */ +export function parseXml(xml: string): XmlNode { + const root = node("#document"); + const stack: XmlNode[] = [root]; + const top = () => stack[stack.length - 1]; + let i = 0; + const n = xml.length; + while (i < n) { + const lt = xml.indexOf("<", i); + if (lt < 0) { + addText(top(), decodeEntities(xml.slice(i))); + break; + } + if (lt > i) addText(top(), decodeEntities(xml.slice(i, lt))); + if (xml.startsWith("", lt + 4); + i = end < 0 ? n : end + 3; + } else if (xml.startsWith("", lt + 9); + addText(top(), xml.slice(lt + 9, end < 0 ? n : end)); + i = end < 0 ? n : end + 3; + } else if (xml.startsWith("", lt + 2); + i = end < 0 ? n : end + 2; + } else if (xml.startsWith("", lt); + const end = bracket >= 0 && bracket < close ? xml.indexOf("]>", bracket) + 1 : close; + i = end <= 0 ? n : end + 1; + } else if (xml[lt + 1] === "/") { + const end = xml.indexOf(">", lt); + const name = xml.slice(lt + 2, end < 0 ? n : end).trim(); + const at = stack.map((s) => s.name).lastIndexOf(name); + if (at > 0) stack.length = at; + i = end < 0 ? n : end + 1; + } else { + // Find the end of the tag, skipping quoted attribute values. + let j = lt + 1; + let quote = ""; + while (j < n) { + const c = xml[j]; + if (quote) { + if (c === quote) quote = ""; + } else if (c === '"' || c === "'") quote = c; + else if (c === ">") break; + j++; + } + const inner = xml.slice(lt + 1, j); + const selfClosing = inner.endsWith("/"); + const m = /^([^\s/>]+)([\s\S]*)$/.exec(selfClosing ? inner.slice(0, -1) : inner); + i = j + 1; + if (!m) { + addText(top(), "<"); + i = lt + 1; + continue; + } + const el = node(m[1], parseAttrs(m[2])); + top().children.push(el); + top().content.push(el); + if (!selfClosing) stack.push(el); + } + } + return root; +} + +const local = (name: string) => name.slice(name.indexOf(":") + 1).toLowerCase(); + +/** The first child named `name` (qualified like `dc:creator`, or local like `link`). */ +export function child(n: XmlNode | undefined, ...names: string[]): XmlNode | undefined { + if (!n) return undefined; + for (const name of names) { + const found = name.includes(":") + ? n.children.find((c) => c.name.toLowerCase() === name.toLowerCase()) + : n.children.find((c) => !c.name.includes(":") && c.name.toLowerCase() === name.toLowerCase()); + if (found) return found; + } + return undefined; +} + +export function children(n: XmlNode | undefined, name: string): XmlNode[] { + return n?.children.filter((c) => c.name.toLowerCase() === name.toLowerCase()) ?? []; +} + +/** Serializes the content of a node back to markup (Atom `type="xhtml"`). */ +export function innerXml(n: XmlNode): string { + const esc = (s: string) => s.replace(/&/g, "&").replace(/ { + if (typeof c === "string") return esc(c); + const attrs = Object.entries(c.attrs).map(([k, v]) => ` ${k}="${v.replace(/"/g, """)}"`).join(""); + return `<${c.name}${attrs}>${innerXml(c)}`; + }).join(""); +} + +function allText(n: XmlNode): string { + return n.content.map((c) => typeof c === "string" ? c : allText(c)).join(""); +} + +const textOf = (n: XmlNode | undefined): string => n ? allText(n).trim() : ""; + +// --------------------------------------------------------------------------- +// Dates + +const ZONES: Record = { + UT: "+0000", + UTC: "+0000", + GMT: "+0000", + Z: "+0000", + EST: "-0500", + EDT: "-0400", + CST: "-0600", + CDT: "-0500", + MST: "-0700", + MDT: "-0600", + PST: "-0800", + PDT: "-0700", + AKST: "-0900", + AKDT: "-0800", + HST: "-1000", + WET: "+0000", + WEST: "+0100", + BST: "+0100", + CET: "+0100", + CEST: "+0200", + MET: "+0100", + MEST: "+0200", + EET: "+0200", + EEST: "+0300", + MSK: "+0300", + IST: "+0530", + SGT: "+0800", + HKT: "+0800", + JST: "+0900", + KST: "+0900", + AEST: "+1000", + AEDT: "+1100", + NZST: "+1200", + NZDT: "+1300", +}; + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** RFC 822 (RSS), ISO 8601 (Atom) and the usual variations; milliseconds. */ +export function parseDate(raw: string | undefined): number | undefined { + const s = (raw ?? "").trim(); + if (!s) return undefined; + // ISO 8601, possibly with a space instead of "T". + const iso = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/i.exec( + s, + ); + if (iso) { + const [, y, mo, d, h = "00", mi = "00", se = "00", z] = iso; + const zone = !z || z.toUpperCase() === "Z" ? "Z" : z.length === 5 ? `${z.slice(0, 3)}:${z.slice(3)}` : z; + const t = Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${se}${zone}`); + return Number.isFinite(t) ? t : undefined; + } + // RFC 822: "Tue, 27 Oct 2026 14:03:00 +0100", weekday optional, zone names. + const rfc = + /^(?:[a-z]+,?\s*)?(\d{1,2})\s+([a-z]+)\.?\s+(\d{2,4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?\s*([a-z]+|[+-]\d{4}|[+-]\d{2}:\d{2})?/i + .exec(s); + if (rfc) { + const [, d, monName, yRaw, h = "0", mi = "0", se = "0", zRaw] = rfc; + const mo = MONTHS.indexOf(monName.slice(0, 3).toLowerCase()); + if (mo >= 0) { + const y = yRaw.length === 2 ? 2000 + Number(yRaw) : Number(yRaw); + const z = !zRaw ? "+0000" : /^[+-]/.test(zRaw) ? zRaw.replace(":", "") : ZONES[zRaw.toUpperCase()] ?? "+0000"; + const sign = z[0] === "-" ? -1 : 1; + const offset = sign * (Number(z.slice(1, 3)) * 60 + Number(z.slice(3, 5))); + const t = Date.UTC(y, mo, Number(d), Number(h), Number(mi), Number(se)) - offset * 60_000; + return Number.isFinite(t) ? t : undefined; + } + } + const t = Date.parse(s); + return Number.isFinite(t) ? t : undefined; +} + +// --------------------------------------------------------------------------- +// Feeds + +export interface FeedItem { + /** Stable within its feed: guid, id, link or title. */ + id: string; + title: string; + link?: string; + date?: number; + author?: string; + /** HTML of the summary or content. */ + html: string; + categories: string[]; + enclosure?: { url: string; type?: string }; + comments?: string; +} + +export interface Feed { + title: string; + link?: string; + description?: string; + items: FeedItem[]; +} + +function resolve(href: string | undefined, base: string | undefined): string | undefined { + if (!href) return undefined; + try { + return new URL(href.trim(), base).href; + } catch { + return href.trim() || undefined; + } +} + +/** The HTML-ish text of a title: entities decoded, tags removed. */ +const titleText = (n: XmlNode | undefined) => htmlToText(textOf(n)); + +function atomLink(n: XmlNode, base?: string): string | undefined { + const links = children(n, "link").concat(children(n, "atom:link")); + const alt = links.find((l) => !l.attrs.rel || l.attrs.rel === "alternate") ?? links[0]; + return resolve(alt?.attrs.href, n.attrs["xml:base"] ?? base); +} + +function atomContent(n: XmlNode | undefined): string { + if (!n) return ""; + if (n.attrs.type === "xhtml") return innerXml(n); + if (n.attrs.type === "text" || !n.attrs.type) { + const t = textOf(n); + // Plain text: escape so it isn't read as HTML later, unless it obviously is HTML. + return /<\w+[^>]*>/.test(t) && n.attrs.type !== "text" ? t : t.replace(/&/g, "&").replace(/ c.attrs.label || c.attrs.term).filter(Boolean), + }; +} + +/** Parses RSS 2.0, RSS 1.0 (RDF) or Atom. `url` resolves relative links. */ +export function parseFeed(xml: string, url?: string): Feed { + const doc = parseXml(xml); + const root = doc.children.find((c) => ["rss", "feed", "rdf"].includes(local(c.name))); + if (!root) throw new Error("Not an RSS or Atom feed"); + if (local(root.name) === "feed") { + const base = root.attrs["xml:base"] ? resolve(root.attrs["xml:base"], url) : url; + return { + title: titleText(child(root, "title")) || domain(url) || "Feed", + link: atomLink(root, base), + description: titleText(child(root, "subtitle")) || undefined, + items: children(root, "entry").map((e) => atomEntry(e, base)), + }; + } + const channel = child(root, "channel"); + const items = [...children(channel, "item"), ...children(root, "item")]; + const link = resolve(textOf(child(channel, "link")) || undefined, url); + return { + title: titleText(child(channel, "title")) || domain(url) || "Feed", + link, + description: titleText(child(channel, "description")) || undefined, + items: items.map((i) => rssItem(i, link ?? url)), + }; +} + +/** Feed URLs advertised by a web page (``). */ +export function discoverFeeds(html: string, base: string): string[] { + const out: string[] = []; + for (const m of html.matchAll(/]*>/gi)) { + const attrs = parseAttrs(m[0].slice(5, -1)); + const type = (attrs.type ?? "").toLowerCase(); + if (/alternate/i.test(attrs.rel ?? "") && /(rss|atom)\+xml/.test(type) && attrs.href) { + const href = resolve(attrs.href, base); + if (href && !out.includes(href)) out.push(href); + } + } + return out; +} + +/** The preference: URLs separated by spaces, commas or new lines. */ +export function feedList(pref: unknown): string[] { + return [...new Set(String(pref ?? "").split(/[\s,]+/).map((s) => s.trim()).filter((s) => /^https?:\/\//i.test(s)))]; +} + +/** "Today", "Yesterday", "This Week", "This Month", "Older"; undated last. */ +export function dateSection(ms: number | undefined, now = Date.now()): string { + if (ms === undefined) return "Undated"; + const startOfDay = new Date(now); + startOfDay.setHours(0, 0, 0, 0); + const day = 86_400_000; + if (ms >= startOfDay.getTime()) return "Today"; + if (ms >= startOfDay.getTime() - day) return "Yesterday"; + if (ms >= startOfDay.getTime() - 6 * day) return "This Week"; + if (ms >= startOfDay.getTime() - 30 * day) return "This Month"; + return "Older"; +} + +/** A short excerpt of an article. */ +export function excerpt(html: string, max = 1200): string { + const md = htmlToMarkdown(html); + return md.length > max ? `${md.slice(0, max).replace(/\s+\S*$/, "")}…` : md; +} diff --git a/extensions/hacker-news/lib_test.ts b/extensions/hacker-news/lib_test.ts new file mode 100644 index 0000000..c77af28 --- /dev/null +++ b/extensions/hacker-news/lib_test.ts @@ -0,0 +1,262 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { + ago, + commentsMarkdown, + dateSection, + decodeEntities, + discoverFeeds, + domain, + excerpt, + feedList, + htmlToMarkdown, + htmlToText, + parseDate, + parseFeed, + parseXml, + topComments, + toStory, +} from "./lib.ts"; + +const RSS = ` + + + + Ars & Crafts + https://example.com/ + things]]> + + + <![CDATA[Rust 2.0 & "friends" <released>]]> + https://example.com/rust-2 + post-42 + Tue, 27 Oct 2026 14:03:00 +0100 + Ada Lovelace + Programming + + Short <i>summary</i> … + The Rust team…

    fn main() {}
    ]]>
    + https://example.com/rust-2#comments +
    + + L’été est fini + /relative/post + Mon, 26 Oct 2026 08:00:00 PDT + Plain text + + + + An item without a title nor a link + + +`; + +const ATOM = ` + + Example Blog + Thoughts + + + 2026-10-27T10:00:00Z + + Hello <em>world</em> + + + tag:blog.example.org,2026:hello + 2026-10-27T09:30:00+02:00 + 2026-10-27T10:00:00Z + Grace Hopper + + <p>A summary</p> +

    Some bold text & more

    +
    + + Plain <3 text + + urn:uuid:2 + 2026-10-20 + 1 < 2 + +
    `; + +const RDF = ` + + Slashhttps://slashdot.example/ + Firsthttps://slashdot.example/12026-10-27T12:00:00+00:00 +`; + +Deno.test("entities and HTML", () => { + assertEquals(decodeEntities("&lt; 'a' … é &bogus; �"), "< 'a' … é &bogus; �"); + assertEquals(htmlToText("

    Hello world

    again

    "), "Hello world again"); + const md = htmlToMarkdown( + 'First line

    See https://example.com/a and this.

    > quoted text

      let x = 1 < 2;\n
    ', + ); + assertEquals( + md, + "First line\n\nSee [https://example.com/a](https://example.com/a) and *this*.\n\n> quoted text\n\n```\n let x = 1 < 2;\n```", + ); + assertEquals(htmlToMarkdown("
    • One
    • Two

    Title

    "), "- One\n- Two\n\n### Title"); + assertEquals(htmlToMarkdown('x'), "x"); +}); + +Deno.test("small helpers", () => { + assertEquals(domain("https://www.github.com/x"), "github.com"); + assertEquals(domain("nope"), undefined); + const now = Date.UTC(2026, 9, 28, 12); + assertEquals([ago(now - 30_000, now), ago(now - 5 * 60_000, now), ago(now - 3 * 3600_000, now)], [ + "now", + "5 min", + "3 h", + ]); + assertEquals(ago(now - 2 * 86400_000, now), "2 d"); + assertEquals(feedList("https://a.com/feed, https://b.com/rss\nnot-a-url https://a.com/feed"), [ + "https://a.com/feed", + "https://b.com/rss", + ]); + const today = new Date(2026, 9, 28, 15).getTime(); + assertEquals(dateSection(new Date(2026, 9, 28, 9).getTime(), today), "Today"); + assertEquals(dateSection(new Date(2026, 9, 27, 23).getTime(), today), "Yesterday"); + assertEquals(dateSection(new Date(2026, 9, 24).getTime(), today), "This Week"); + assertEquals(dateSection(new Date(2026, 9, 10).getTime(), today), "This Month"); + assertEquals(dateSection(new Date(2025, 0, 1).getTime(), today), "Older"); + assertEquals(dateSection(undefined, today), "Undated"); +}); + +Deno.test("dates: RFC 822 and ISO 8601 variants", () => { + assertEquals(parseDate("Tue, 27 Oct 2026 14:03:00 +0100"), Date.UTC(2026, 9, 27, 13, 3)); + assertEquals(parseDate("Mon, 26 Oct 2026 08:00:00 PDT"), Date.UTC(2026, 9, 26, 15, 0)); + assertEquals(parseDate("27 Oct 2026 14:03 CEST"), Date.UTC(2026, 9, 27, 12, 3)); + assertEquals(parseDate("Thurs, 1 Oct 26 10:00:00 GMT"), Date.UTC(2026, 9, 1, 10)); + assertEquals(parseDate("Sun, 4 January 2026 09:00:00 Z"), Date.UTC(2026, 0, 4, 9)); + assertEquals(parseDate("2026-10-27T09:30:00+02:00"), Date.UTC(2026, 9, 27, 7, 30)); + assertEquals(parseDate("2026-10-27T09:30:00.123Z"), Date.UTC(2026, 9, 27, 9, 30)); + assertEquals(parseDate("2026-10-27 09:30:00 +0200"), Date.UTC(2026, 9, 27, 7, 30)); + assertEquals(parseDate("2026-10-20"), Date.UTC(2026, 9, 20)); + assertEquals(parseDate("garbage"), undefined); + assertEquals(parseDate(""), undefined); +}); + +Deno.test("XML parser: CDATA, comments, attributes, self-closing and broken markup", () => { + const doc = parseXml( + `]>]]> <t>unclosed`, + ); + const a = doc.children[0]; + assertEquals(a.name, "a"); + assertEquals(a.attrs, { x: "1 & 2", y: "q>r" }); + assertEquals(a.children.map((c) => c.name), ["b", "c", "d"]); + assertEquals(a.children[1].text, " "); + assertEquals(a.children[2].text, "unclosed"); +}); + +Deno.test("RSS 2.0", () => { + const feed = parseFeed(RSS, "https://example.com/feed"); + assertEquals(feed.title, "Ars & Crafts"); + assertEquals(feed.link, "https://example.com/"); + assertEquals(feed.description, "News about things"); + assertEquals(feed.items.length, 3); + const [first, second, third] = feed.items; + assertEquals(first.title, 'Rust 2.0 & "friends"'); + assertEquals(first.id, "post-42"); + assertEquals(first.link, "https://example.com/rust-2"); + assertEquals(first.date, Date.UTC(2026, 9, 27, 13, 3)); + assertEquals(first.author, "Ada Lovelace"); + assertEquals(first.categories, ["Programming", "Rust"]); + assertEquals(first.comments, "https://example.com/rust-2#comments"); + assertStringIncludes(first.html, 'Rust'); + assertEquals(excerpt(first.html), "The [Rust](https://rust-lang.org) team…\n\n```\nfn main() {}\n```"); + assertEquals(second.title, "L’été est fini"); + assertEquals(second.link, "https://example.com/relative/post"); + assertEquals(second.id, "https://example.com/relative/post"); + assertEquals(second.enclosure, { url: "https://example.com/ep1.mp3", type: "audio/mpeg" }); + assertEquals(third.title, "An item without a title nor a link"); + assertEquals(third.link, undefined); +}); + +Deno.test("Atom", () => { + const feed = parseFeed(ATOM, "https://blog.example.org/atom.xml"); + assertEquals([feed.title, feed.link, feed.description], ["Example Blog", "https://blog.example.org/", "Thoughts"]); + const [hello, plain] = feed.items; + assertEquals(hello.title, "Hello world"); + assertEquals(hello.link, "https://blog.example.org/posts/hello"); + assertEquals(hello.id, "tag:blog.example.org,2026:hello"); + assertEquals(hello.date, Date.UTC(2026, 9, 27, 7, 30)); + assertEquals(hello.author, "Grace Hopper"); + assertEquals(hello.categories, ["Go"]); + assertEquals(htmlToText(hello.html), "Some bold text & more"); + assertEquals(plain.title, "Plain <3 text"); + assertEquals(plain.link, "https://elsewhere.example.com/x"); + assertEquals(plain.date, Date.UTC(2026, 9, 20)); + assertEquals(htmlToText(plain.html), "1 < 2"); +}); + +Deno.test("RSS 1.0 (RDF) and non-feeds", () => { + const feed = parseFeed(RDF); + assertEquals(feed.title, "Slash"); + assertEquals(feed.items.map((i) => [i.title, i.link, i.date]), [ + ["First", "https://slashdot.example/1", Date.UTC(2026, 9, 27, 12)], + ]); + assertThrows(() => parseFeed("Hi"), Error, "Not an RSS or Atom feed"); +}); + +Deno.test("feed discovery in web pages", () => { + const html = ` + + + + `; + assertEquals(discoverFeeds(html, "https://example.com/blog/"), [ + "https://example.com/feed.xml", + "https://example.com/atom", + ]); + assertEquals(discoverFeeds("", "https://example.com"), []); +}); + +Deno.test("Hacker News stories and comments", () => { + const story = toStory({ + id: 1, + type: "story", + by: "pg", + time: 1_761_000_000, + title: "Ask HN: What's new?", + score: 321, + descendants: 12, + kids: [12, 11, 13], + text: "

    Tell us

    ", + }, 3)!; + assertEquals([story.title, story.kind, story.rank, story.points, story.comments], [ + "Ask HN: What's new?", + "ask", + 3, + 321, + 12, + ]); + assertEquals(story.domain, undefined); + assertEquals( + toStory({ id: 2, title: "Show HN: A thing", url: "https://www.thing.dev/x", type: "story" }, 1)?.kind, + "show", + ); + assertEquals(toStory({ id: 3, title: "Hiring", type: "job" }, 1)?.kind, "job"); + assertEquals(toStory({ id: 4, dead: true, title: "x" }, 1), undefined); + assertEquals(toStory({ id: 5, deleted: true }, 1), undefined); + + const tree = { + id: 1, + children: [ + { id: 11, author: "b", text: "

    Second

    ", created_at_i: 1_761_000_100, children: [] }, + { + id: 12, + author: "a", + text: "First comment", + created_at_i: 1_761_000_000, + children: [{ id: 14, author: "c", text: "reply", children: [{ id: 15, author: "d", text: "deeper" }] }], + }, + { id: 13, author: null, text: null }, + ], + }; + const top = topComments(tree, story.kids); + assertEquals(top.map((c) => [c.author, c.markdown, c.replies]), [["a", "First *comment*", 2], ["b", "Second", 0]]); + const md = commentsMarkdown(story, top, 1_761_000_000_000 + 3600_000); + assertStringIncludes(md, "# Ask HN: What's new?"); + assertStringIncludes(md, "**321 points** · by pg · 1 h"); + assertStringIncludes(md, "**a** · 1 h · 2 replies"); + assertStringIncludes(md, "[10 more comments on Hacker News](https://news.ycombinator.com/item?id=1)"); +}); diff --git a/extensions/spotify/api.ts b/extensions/spotify/api.ts new file mode 100644 index 0000000..1ff4a2f --- /dev/null +++ b/extensions/spotify/api.ts @@ -0,0 +1,90 @@ +// Authenticated calls to the Spotify Web API. + +import { type Context, oauth } from "@backslash/api"; +import { API, type Device, errorMessage, playBody, provider } from "./lib.ts"; + +export interface Client { + get(path: string, signal?: AbortSignal): Promise; + send(method: "PUT" | "POST" | "DELETE", path: string, body?: unknown): Promise; +} + +/** A client signed in with the user's own Spotify app (the first time opens the browser). */ +export async function client(ctx: Pick): Promise { + const p = provider(ctx.preferences); + const token = await oauth.token(p); + const call = async (method: string, path: string, body?: unknown, signal?: AbortSignal) => { + const url = path.startsWith("https://") ? path : `${API}${path}`; + let res: Response; + try { + res = await fetch(url, { + method, + signal, + headers: { + Authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Spotify is unreachable: ${(e as Error).message}`); + } + if (res.status === 204 || res.status === 202) { + await res.body?.cancel(); + return undefined; + } + const text = await res.text(); + let json: unknown = undefined; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + // Some write endpoints answer a plain snapshot id. + } + if (!res.ok) { + if (res.status === 401) await oauth.signOut(p).catch(() => {}); + throw new Error(errorMessage(res.status, json, res.headers.get("retry-after"))); + } + return json; + }; + return { + get: (path: string, signal?: AbortSignal) => call("GET", path, undefined, signal) as Promise, + send: async (method, path, body) => { + await call(method, path, body); + }, + }; +} + +export async function signOut(ctx: Pick): Promise { + await oauth.signOut(provider(ctx.preferences)); +} + +export async function devices(api: Client): Promise { + return (await api.get<{ devices: Device[] }>("/me/player/devices"))?.devices ?? []; +} + +export type PlayTarget = { context?: string; uris?: string[]; offset?: string }; + +/** Plays on the given device, or the active one; with no active device, the first available. */ +export async function play(api: Client, target: PlayTarget, deviceId?: string): Promise { + const body = playBody(target); + const q = (id?: string) => id ? `?device_id=${encodeURIComponent(id)}` : ""; + try { + await api.send("PUT", `/me/player/play${q(deviceId)}`, body); + } catch (e) { + if (deviceId || !/No active device/.test((e as Error).message)) throw e; + const first = (await devices(api)).find((d) => d.id && !d.is_restricted); + if (!first?.id) throw new Error("No Spotify device is online: open Spotify on your computer or phone"); + await api.send("PUT", `/me/player/play${q(first.id)}`, body); + } +} + +/** Track ids → liked, in batches of 50. */ +export async function liked(api: Client, ids: string[]): Promise> { + const out = new Set(); + for (let i = 0; i < ids.length; i += 50) { + const batch = ids.slice(i, i + 50); + const flags = await api.get(`/me/tracks/contains?ids=${batch.join(",")}`).catch(() => undefined); + flags?.forEach((f, k) => f && out.add(batch[k])); + } + return out; +} diff --git a/extensions/spotify/backslash.toml b/extensions/spotify/backslash.toml new file mode 100644 index 0000000..c601ae0 --- /dev/null +++ b/extensions/spotify/backslash.toml @@ -0,0 +1,54 @@ +[extension] +id = "spotify" +title = "Spotify" +description = "Search Spotify, play on any of your devices, queue songs, browse your playlists and save what's playing to your library" +version = "1.0.0" +author = "Backslash" +icon = "bs-music" +license = "MIT" + +# The Web API, the sign-in (token endpoint), and cover art: i.scdn.co for +# albums and artists, mosaic.scdn.co and the spotifycdn.com hosts for playlists. +[permissions] +net = [ + "api.spotify.com", + "accounts.spotify.com", + "i.scdn.co", + "mosaic.scdn.co", + "image-cdn-ak.spotifycdn.com", + "image-cdn-fa.spotifycdn.com", +] + +[[commands]] +name = "search" +title = "Search Spotify" +description = "Songs, artists, albums and playlists: play, queue, save" +icon = "bs-music" +keywords = ["spotify", "music", "song", "track", "album", "artist", "play", "musique", "chanson", "titre", "écouter"] + +[[commands]] +name = "playlists" +title = "My Spotify Playlists" +description = "Your playlists and Liked Songs" +icon = "bs-music" +keywords = ["spotify", "playlist", "liked songs", "library", "bibliothèque", "titres likés"] + +[[commands]] +name = "now-playing" +title = "Spotify Now Playing" +description = "The current song with like / unlike, what's up next and your devices" +icon = "bs-headphones" +keywords = ["spotify", "now playing", "current", "song", "like", "queue", "devices", "en cours", "file d'attente", "appareils"] + +[[preferences]] +name = "clientId" +title = "Client ID" +description = "Create an app on developer.spotify.com/dashboard (Web API), add the redirect URI http://127.0.0.1:43117/callback, then paste its Client ID here" +required = true + +[[preferences]] +name = "redirectPort" +title = "Redirect port" +description = "The port of the redirect URI registered in your app (default 43117). 0 picks a free port each time: register http://127.0.0.1/callback without a port then" +type = "number" +default = 43117 diff --git a/extensions/spotify/commands/now-playing.ts b/extensions/spotify/commands/now-playing.ts new file mode 100644 index 0000000..16c8d4d --- /dev/null +++ b/extensions/spotify/commands/now-playing.ts @@ -0,0 +1,194 @@ +import { action, copy, Icons, type Item, list, meta, open, refresh, updateCommandMetadata } from "@backslash/api"; +import { type Client, client, devices, liked } from "../api.ts"; +import { + artists, + type Device, + deviceIcon, + duration, + type Episode, + pickImage, + type PlaybackState, + type Track, + webUrl, +} from "../lib.ts"; +import { likeAction, signOutAction, trackItem } from "../views.ts"; + +const REPEAT = { off: "Off", track: "Track", context: "On" } as const; + +function deviceItem(d: Device): Item { + return { + id: `device:${d.id ?? d.name}`, + title: d.name, + subtitle: d.is_active ? "Playing here" : d.type, + icon: { source: deviceIcon(d.type), tint: d.is_active ? "green" : "gray" }, + section: "Devices", + accessories: [ + ...(d.volume_percent !== null && d.volume_percent !== undefined + ? [{ text: `${d.volume_percent} %`, icon: Icons.speaker, tooltip: "Volume" }] + : []), + ...(d.is_active ? [{ tag: "Active" }] : []), + ], + detail: { + metadata: [ + meta.text("Type", d.type, { icon: deviceIcon(d.type) }), + meta.text("Status", d.is_active ? "Active" : "Available", { + icon: d.is_active ? Icons.play : Icons.pause, + color: d.is_active ? "green" : undefined, + }), + ...(d.volume_percent !== null && d.volume_percent !== undefined + ? [meta.progress("Volume", d.volume_percent / 100)] + : []), + ...(d.is_restricted ? [meta.text("Control", "Restricted by the device", { icon: Icons.lock })] : []), + ], + }, + actions: d.id && !d.is_active + ? [ + action("Transfer Playback Here", async (ctx) => { + await (await client(ctx)).send("PUT", "/me/player", { device_ids: [d.id], play: true }); + return refresh(); + }, { icon: Icons.speaker }), + signOutAction, + ] + : [signOutAction], + }; +} + +function currentItem(state: PlaybackState, likedSet: Set): Item { + const progress = state.progress_ms ?? 0; + const item = state.item!; + const where = `${duration(progress)} / ${duration(item.duration_ms)}`; + const playing = state.is_playing; + const extra = [ + meta.separator(), + meta.progress("Position", item.duration_ms ? progress / item.duration_ms : 0, where), + ...(state.device ? [meta.text("Device", state.device.name, { icon: deviceIcon(state.device.type) })] : []), + meta.text("Shuffle", state.shuffle_state ? "On" : "Off", { icon: Icons.shuffle }), + meta.text("Repeat", REPEAT[state.repeat_state ?? "off"], { icon: Icons.repeat }), + ]; + const status = { + icon: { source: playing ? Icons.play : Icons.pause, tint: playing ? "green" as const : "gray" as const }, + }; + if (item.type === "episode") { + const ep = item as Episode; + const url = ep.external_urls?.spotify ?? webUrl("episode", ep.id); + return { + id: `now:${ep.id}`, + title: ep.name, + subtitle: ep.show?.name, + icon: pickImage(ep.images, "small") ?? { source: Icons.mic, tint: "green" }, + section: playing ? "Now Playing" : "Paused", + accessories: [{ text: where }, { ...status, text: state.device?.name }], + detail: { + image: pickImage(ep.images, "large"), + metadata: [ + ...(ep.show ? [meta.text("Show", ep.show.name, { icon: Icons.mic })] : []), + ...(ep.release_date ? [meta.text("Released", ep.release_date, { icon: Icons.calendar })] : []), + ...extra, + ], + }, + actions: [open(ep.uri, "Open in Spotify", { icon: Icons.music }), copy(url, "Copy Link"), signOutAction], + }; + } + const track = item as Track; + const base = trackItem(track, { liked: likedSet }); + const isLiked = likedSet.has(track.id); + const actions = base.actions ?? []; + // Playback itself is the Media extension's job: here, the library and links come first. + const first = likeAction(track, isLiked, { section: undefined }); + return { + ...base, + id: `now:${track.id}`, + section: playing ? "Now Playing" : "Paused", + accessories: [ + ...(isLiked ? [{ icon: { source: Icons.heartFilled, tint: "green" as const }, tooltip: "In Liked Songs" }] : []), + { text: where }, + { ...status, text: state.device?.name }, + ], + detail: { ...base.detail, metadata: [...(base.detail?.metadata ?? []), ...extra] }, + actions: [ + first, + ...actions.filter((a) => + !["Play", "Add to Queue", "Save to Liked Songs", "Remove from Liked Songs"].includes(a.title) + ), + ], + }; +} + +async function load(api: Client, signal: AbortSignal, wantDevices: boolean) { + const state = await api.get("/me/player?additional_types=episode", signal); + const [queue, deviceList] = await Promise.all([ + state?.item + ? api.get<{ queue: (Track | Episode)[] }>("/me/player/queue", signal).catch(() => undefined) + : undefined, + wantDevices ? devices(api).catch(() => []) : Promise.resolve(undefined), + ]); + return { state, queue: queue?.queue ?? [], deviceList }; +} + +export default list({ + placeholder: "Filter…", + empty: { + title: "Nothing is playing on Spotify", + description: "Start playing on any device, or search with Search Spotify", + icon: Icons.music, + }, + status: (items) => { + const now = items.find((i) => i.id?.startsWith("now:")); + return now ? `${now.title} · ${now.subtitle ?? ""}` : "Spotify"; + }, + // Refreshed every 5 seconds while open: the position and track changes. + async *items(ctx) { + const api = await client(ctx); + let last = ""; + let lastSong = ""; + let deviceList: Device[] = []; + for (let round = 0; !ctx.signal.aborted; round++) { + const res = await load(api, ctx.signal, round % 3 === 0); + if (res.deviceList) deviceList = res.deviceList; + const { state } = res; + const items: Item[] = []; + const tracks = [state?.item, ...res.queue].filter((t): t is Track => !!t && t.type !== "episode" && !!t.id); + const likedSet = await liked(api, [...new Set(tracks.map((t) => t.id))].slice(0, 50)); + if (state?.item) { + items.push(currentItem(state, likedSet)); + items.push( + ...res.queue.slice(0, 10).filter((t): t is Track => t.type !== "episode").map((t, i) => ({ + ...trackItem(t, { liked: likedSet, position: i }), + section: "Up Next", + })), + ); + } else { + const recent = await api.get<{ items: { track: Track; played_at: string }[] }>( + "/me/player/recently-played?limit=10", + ctx.signal, + ).catch(() => undefined); + items.push(...(recent?.items ?? []).map((r, i) => ({ + ...trackItem(r.track, { position: i }), + section: "Recently Played", + }))); + } + items.push(...deviceList.map(deviceItem)); + const song = state?.item && state.is_playing + ? `${state.item.name} — ${ + state.item.type === "episode" + ? (state.item as Episode).show?.name ?? "" + : artists((state.item as Track).artists) + }` + : ""; + if (song !== lastSong) { + lastSong = song; + updateCommandMetadata({ subtitle: song || null }).catch(() => {}); + } + const key = JSON.stringify(items.map((i) => [i.id, i.accessories, i.detail?.metadata])); + if (key !== last) { + last = key; + yield items; + } + await new Promise((r) => setTimeout(r, 5000)); + } + }, + actions: [ + open("spotify:", "Open Spotify", { icon: Icons.music }), + signOutAction, + ], +}); diff --git a/extensions/spotify/commands/playlists.ts b/extensions/spotify/commands/playlists.ts new file mode 100644 index 0000000..49dc1a1 --- /dev/null +++ b/extensions/spotify/commands/playlists.ts @@ -0,0 +1,42 @@ +import { action, Icons, type Item, list } from "@backslash/api"; +import { client } from "../api.ts"; +import { type Paging, type Playlist, present } from "../lib.ts"; +import { likedSongsView, playlistItem, signOutAction } from "../views.ts"; + +export default list({ + placeholder: "Filter your playlists…", + empty: { title: "No playlists yet", description: "Playlists you create or follow show up here", icon: Icons.music }, + status: (items) => `${items.length - 1} playlists · Spotify`, + async items(ctx) { + const api = await client(ctx); + const [me, playlists] = await Promise.all([ + api.get<{ id: string; display_name?: string }>("/me", ctx.signal), + (async () => { + const all: Playlist[] = []; + let next: string | undefined = "/me/playlists?limit=50"; + while (next && all.length < 300) { + const page: Paging | undefined = await api.get(next, ctx.signal); + all.push(...present(page)); + next = page?.next ?? undefined; + } + return all; + })(), + ]); + const liked: Item = { + id: "liked-songs", + title: "Liked Songs", + subtitle: me?.display_name ?? undefined, + icon: { source: Icons.heartFilled, tint: "green" }, + section: "Library", + detail: { markdown: "Every song you saved with the heart." }, + actions: [action("Show Liked Songs", () => likedSongsView(), { icon: Icons.heartFilled }), signOutAction], + }; + const mine = playlists.filter((p) => p.owner?.id === me?.id); + const followed = playlists.filter((p) => p.owner?.id !== me?.id); + return [ + liked, + ...mine.map((p) => playlistItem(p, "Your Playlists")), + ...followed.map((p) => playlistItem(p, "Followed")), + ]; + }, +}); diff --git a/extensions/spotify/commands/search.ts b/extensions/spotify/commands/search.ts new file mode 100644 index 0000000..3ddc934 --- /dev/null +++ b/extensions/spotify/commands/search.ts @@ -0,0 +1,38 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { client, liked } from "../api.ts"; +import { KINDS, present, type SearchResponse, searchUrl } from "../lib.ts"; +import { albumItem, artistItem, playlistItem, trackItem } from "../views.ts"; + +async function search(query: string, ctx: Context): Promise { + if (!query.trim()) return []; + const api = await client(ctx); + const all = !ctx.filter || ctx.filter === "all"; + let res: SearchResponse | undefined; + try { + res = await api.get(searchUrl(query.trim(), ctx.filter, all ? 8 : 20), ctx.signal); + } catch (e) { + // Smaller pages for apps limited by Spotify. + if (all || ctx.signal.aborted || !/limit/i.test((e as Error).message)) throw e; + res = await api.get(searchUrl(query.trim(), ctx.filter, 10), ctx.signal); + } + const tracks = present(res?.tracks); + const likedSet = await liked(api, tracks.map((t) => t.id)); + return [ + ...tracks.map((t) => trackItem(t, { liked: likedSet, section: all ? "Tracks" : undefined })), + ...present(res?.artists).map((a) => artistItem(a, all ? "Artists" : undefined)), + ...present(res?.albums).map((a) => albumItem(a, all ? "Albums" : undefined)), + ...present(res?.playlists).map((p) => playlistItem(p, all ? "Playlists" : undefined)), + ]; +} + +export default list({ + placeholder: "Search songs, artists, albums and playlists…", + filter: { options: KINDS }, + empty: { + title: "Search Spotify", + description: "Enter plays on your active device; Ctrl+K for the queue, devices and more", + icon: Icons.music, + }, + status: (items) => items.length ? `${items.length} results · Spotify` : "Spotify", + search, +}); diff --git a/extensions/spotify/lib.ts b/extensions/spotify/lib.ts new file mode 100644 index 0000000..5d72d92 --- /dev/null +++ b/extensions/spotify/lib.ts @@ -0,0 +1,289 @@ +// Spotify Web API: the OAuth provider, response types and their formatting. +// Pure, tested in lib_test.ts. + +import { type Color, Icons, type OAuthProvider } from "@backslash/api"; + +export const API = "https://api.spotify.com/v1"; + +export const SCOPES = [ + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative", + "user-library-read", + "user-library-modify", +]; + +/** The redirect port suggested in the settings: register http://127.0.0.1:43117/callback. */ +export const DEFAULT_PORT = 43117; + +/** + * Backslash has no Spotify app of its own: users create one on + * developer.spotify.com and paste its Client ID. PKCE needs no secret. + */ +export function provider(prefs: Record): OAuthProvider { + const clientId = String(prefs.clientId ?? "").trim(); + if (!clientId) { + throw new Error("Paste the Client ID of your Spotify app in Settings → Extensions → Spotify"); + } + if (!/^[0-9a-f]{32}$/i.test(clientId)) { + throw new Error("This doesn't look like a Spotify Client ID (32 hexadecimal characters)"); + } + const raw = prefs.redirectPort; + const port = raw === undefined || raw === null || raw === "" ? DEFAULT_PORT : Number(raw); + return { + id: "spotify", + title: "Spotify", + authorizeUrl: "https://accounts.spotify.com/authorize", + tokenUrl: "https://accounts.spotify.com/api/token", + clientId, + scopes: SCOPES, + // 0 = any port: register http://127.0.0.1/callback (Spotify accepts any port for loopback IPs). + ...(port > 0 && port < 65536 ? { redirectPort: Math.round(port) } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Image { + url: string; + width?: number | null; + height?: number | null; +} + +export interface ArtistRef { + id: string; + name: string; + uri: string; +} + +export interface Album { + id: string; + uri: string; + name: string; + album_type?: string; + artists: ArtistRef[]; + images?: Image[]; + release_date?: string; + total_tracks?: number; + external_urls?: { spotify?: string }; +} + +export interface Track { + id: string; + uri: string; + name: string; + type?: "track"; + artists: ArtistRef[]; + album?: Album; + duration_ms: number; + explicit?: boolean; + popularity?: number; + track_number?: number; + is_local?: boolean; + external_urls?: { spotify?: string }; +} + +export interface Episode { + id: string; + uri: string; + name: string; + type: "episode"; + duration_ms: number; + images?: Image[]; + show?: { name: string; publisher?: string }; + release_date?: string; + external_urls?: { spotify?: string }; +} + +export interface Artist { + id: string; + uri: string; + name: string; + images?: Image[]; + genres?: string[]; + followers?: { total: number }; + popularity?: number; + external_urls?: { spotify?: string }; +} + +export interface Playlist { + id: string; + uri: string; + name: string; + description?: string | null; + images?: Image[] | null; + owner?: { id?: string; display_name?: string | null }; + tracks?: { total: number } | null; + items?: { total: number } | null; + public?: boolean | null; + collaborative?: boolean; + external_urls?: { spotify?: string }; +} + +export interface Device { + id: string | null; + name: string; + type: string; + is_active: boolean; + is_restricted?: boolean; + volume_percent?: number | null; +} + +export interface PlaybackState { + device?: Device; + is_playing: boolean; + progress_ms?: number | null; + shuffle_state?: boolean; + repeat_state?: "off" | "track" | "context"; + context?: { type: string; uri: string } | null; + item?: Track | Episode | null; + currently_playing_type?: string; +} + +export interface Paging { + items: (T | null)[]; + total?: number; + next?: string | null; +} + +export interface SearchResponse { + tracks?: Paging; + albums?: Paging; + artists?: Paging; + playlists?: Paging; +} + +export type Kind = "track" | "album" | "artist" | "playlist"; + +export const KINDS: { value: string; title: string }[] = [ + { value: "all", title: "All" }, + { value: "track", title: "Tracks" }, + { value: "album", title: "Albums" }, + { value: "artist", title: "Artists" }, + { value: "playlist", title: "Playlists" }, +]; + +export function searchUrl(query: string, filter: string | undefined, limit: number): string { + const types = filter && filter !== "all" ? [filter] : ["track", "album", "artist", "playlist"]; + return `${API}/search?${new URLSearchParams({ q: query, type: types.join(","), limit: String(limit) })}`; +} + +/** Spotify sometimes returns `null` entries (removed playlists). */ +export const present = (p: Paging | undefined): T[] => (p?.items ?? []).filter((x): x is T => !!x); + +// --------------------------------------------------------------------------- +// Formatting + +/** The smallest picture at least `min` wide (icons), or the biggest (covers). */ +export function pickImage(images: Image[] | null | undefined, size: "small" | "large"): string | undefined { + const list = (images ?? []).filter((i) => i?.url); + if (!list.length) return undefined; + const sorted = [...list].sort((a, b) => (a.width ?? 0) - (b.width ?? 0)); + if (size === "large") return sorted[sorted.length - 1].url; + return (sorted.find((i) => (i.width ?? 0) >= 64) ?? sorted[0]).url; +} + +export function duration(ms: number): string { + const s = Math.round(ms / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = String(s % 60).padStart(2, "0"); + return h ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`; +} + +export function totalDuration(ms: number): string { + const min = Math.round(ms / 60000); + return min >= 60 ? `${Math.floor(min / 60)} h ${min % 60} min` : `${min} min`; +} + +export const artists = (list: ArtistRef[] | undefined) => (list ?? []).map((a) => a.name).join(", "); + +export function year(date: string | undefined): string | undefined { + return date ? date.slice(0, 4) : undefined; +} + +/** 1234567 → "1.2M". */ +export function compact(n: number): string { + return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n); +} + +/** Playlists' descriptions come as HTML. */ +export function plainDescription(html: string | null | undefined): string { + return (html ?? "") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(///g, "/") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim(); +} + +export const trackCount = (p: Playlist) => p.tracks?.total ?? p.items?.total ?? 0; + +export const webUrl = (kind: string, id: string) => `https://open.spotify.com/${kind}/${id}`; + +/** What a device looks like. No phone glyph: phones use the speaker one too. */ +export function deviceIcon(type: string): string { + switch (type.toLowerCase()) { + case "computer": + case "tv": + case "tablet": + case "game_console": + case "stb": + return Icons.monitor; + case "smartphone": + case "speaker": + case "castaudio": + case "castvideo": + case "avr": + case "audiodongle": + case "automobile": + return Icons.speaker; + default: + return Icons.headphones; + } +} + +export function popularityColor(p: number): Color { + return p >= 70 ? "green" : p >= 40 ? "yellow" : "gray"; +} + +// --------------------------------------------------------------------------- +// Errors + +/** A readable message for a failed Web API call. */ +export function errorMessage(status: number, body: unknown, retryAfter?: string | null): string { + const err = (body as { error?: { message?: string; reason?: string } | string })?.error; + const message = typeof err === "string" ? err : err?.message ?? ""; + const reason = typeof err === "object" ? err?.reason ?? "" : ""; + if (status === 401) return "Spotify signed you out: open the command again to sign in"; + if (status === 403) { + if (/premium/i.test(message) || reason === "PREMIUM_REQUIRED") return "Controlling playback needs Spotify Premium"; + if (/not be registered|not registered|user management/i.test(message)) { + return "Your Spotify account isn't allowed to use this app yet: add it under User Management in your app on developer.spotify.com"; + } + if (reason === "UNKNOWN" || /restriction/i.test(message)) { + return "Spotify refused: the player doesn't allow this right now"; + } + return `Spotify refused: ${message || "forbidden"}`; + } + if (status === 404 && (reason === "NO_ACTIVE_DEVICE" || /active device/i.test(message))) { + return "No active device: start playing on Spotify somewhere, or pick a device in Play On"; + } + if (status === 429) return `Spotify asks to slow down${retryAfter ? `: try again in ${retryAfter} s` : ""}`; + return `Spotify answered ${status}${message ? `: ${message}` : ""}`; +} + +/** The request body of "play": a context (album, playlist, artist), optionally from a track, or tracks. */ +export function playBody(target: { context?: string; uris?: string[]; offset?: string }): Record { + if (target.context) { + return { context_uri: target.context, ...(target.offset ? { offset: { uri: target.offset } } : {}) }; + } + return { uris: target.uris ?? [] }; +} diff --git a/extensions/spotify/lib_test.ts b/extensions/spotify/lib_test.ts new file mode 100644 index 0000000..91b046c --- /dev/null +++ b/extensions/spotify/lib_test.ts @@ -0,0 +1,146 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { + compact, + deviceIcon, + duration, + errorMessage, + pickImage, + plainDescription, + playBody, + present, + provider, + searchUrl, + totalDuration, + type Track, + trackCount, +} from "./lib.ts"; +import { albumItem, playlistItem, trackItem } from "./views.ts"; + +const ID = "0123456789abcdef0123456789abcdef"; + +const images = [ + { url: "https://i.scdn.co/image/640", width: 640, height: 640 }, + { url: "https://i.scdn.co/image/300", width: 300, height: 300 }, + { url: "https://i.scdn.co/image/64", width: 64, height: 64 }, +]; + +const track: Track = { + id: "4uLU6hMCjMI75M1A2tKUQC", + uri: "spotify:track:4uLU6hMCjMI75M1A2tKUQC", + name: "Never Gonna Give You Up", + artists: [{ id: "a1", name: "Rick Astley", uri: "spotify:artist:a1" }], + album: { + id: "al1", + uri: "spotify:album:al1", + name: "Whenever You Need Somebody", + artists: [{ id: "a1", name: "Rick Astley", uri: "spotify:artist:a1" }], + images, + release_date: "1987-11-12", + }, + duration_ms: 213573, + explicit: false, + popularity: 80, +}; + +Deno.test("OAuth provider from the user's own app", () => { + const p = provider({ clientId: ` ${ID} ` }); + assertEquals(p.clientId, ID); + assertEquals(p.tokenUrl, "https://accounts.spotify.com/api/token"); + assertEquals(p.redirectPort, 43117); + assertEquals(p.scopes?.includes("user-modify-playback-state"), true); + assertEquals(provider({ clientId: ID, redirectPort: 8888 }).redirectPort, 8888); + assertEquals(provider({ clientId: ID, redirectPort: 0 }).redirectPort, undefined); + assertThrows(() => provider({}), Error, "Client ID"); + assertThrows(() => provider({ clientId: "not-an-id" }), Error, "32 hexadecimal"); +}); + +Deno.test("formatting", () => { + assertEquals(pickImage(images, "small"), "https://i.scdn.co/image/64"); + assertEquals(pickImage(images, "large"), "https://i.scdn.co/image/640"); + assertEquals(pickImage([{ url: "https://mosaic.scdn.co/x", width: null }], "small"), "https://mosaic.scdn.co/x"); + assertEquals(pickImage(null, "large"), undefined); + assertEquals([duration(213573), duration(3_725_000), duration(5000)], ["3:34", "1:02:05", "0:05"]); + assertEquals([totalDuration(45 * 60_000), totalDuration(135 * 60_000)], ["45 min", "2 h 15 min"]); + assertEquals(compact(1_234_567), "1.2M"); + assertEquals(plainDescription('Best of 80's & more'), "Best of 80's & more"); + assertEquals([deviceIcon("Computer"), deviceIcon("Smartphone"), deviceIcon("Unknown")], [ + "bs-monitor", + "bs-speaker", + "bs-headphones", + ]); + assertEquals(present({ items: [null, { id: "x" }] }), [{ id: "x" }]); + assertEquals(trackCount({ id: "p", uri: "u", name: "n", items: { total: 3 } }), 3); +}); + +Deno.test("search URL", () => { + const all = new URL(searchUrl("daft punk", "all", 8)); + assertEquals(all.searchParams.get("type"), "track,album,artist,playlist"); + assertEquals(all.searchParams.get("q"), "daft punk"); + assertEquals(new URL(searchUrl("x", "album", 20)).searchParams.get("type"), "album"); +}); + +Deno.test("errors are explained", () => { + assertEquals( + errorMessage(404, { + error: { status: 404, message: "Player command failed: No active device found", reason: "NO_ACTIVE_DEVICE" }, + }), + "No active device: start playing on Spotify somewhere, or pick a device in Play On", + ); + assertEquals( + errorMessage(403, { error: { message: "Player command failed: Premium required", reason: "PREMIUM_REQUIRED" } }), + "Controlling playback needs Spotify Premium", + ); + assertStringIncludes( + errorMessage(403, { error: { message: "User not registered in the Developer Dashboard" } }), + "User Management", + ); + assertEquals(errorMessage(429, {}, "30"), "Spotify asks to slow down: try again in 30 s"); + assertStringIncludes(errorMessage(401, {}), "sign in"); + assertEquals(errorMessage(500, { error: "server_error" }), "Spotify answered 500: server_error"); +}); + +Deno.test("play requests", () => { + assertEquals(playBody({ uris: ["spotify:track:1"] }), { uris: ["spotify:track:1"] }); + assertEquals(playBody({ context: "spotify:album:2" }), { context_uri: "spotify:album:2" }); + assertEquals(playBody({ context: "spotify:album:2", offset: "spotify:track:1" }), { + context_uri: "spotify:album:2", + offset: { uri: "spotify:track:1" }, + }); +}); + +Deno.test("items: cover art, liked state, actions", () => { + const item = trackItem(track, { liked: new Set([track.id]), context: "spotify:album:al1" }); + assertEquals([item.title, item.subtitle, item.icon], [ + "Never Gonna Give You Up", + "Rick Astley", + "https://i.scdn.co/image/64", + ]); + assertEquals(item.detail?.image, "https://i.scdn.co/image/640"); + assertEquals(item.accessories?.[0], { + icon: { source: "bs-heart-filled", tint: "green" }, + tooltip: "In Liked Songs", + }); + assertEquals(item.accessories?.at(-1), { text: "3:34" }); + const titles = item.actions?.map((a) => a.title) ?? []; + assertEquals(titles.slice(0, 4), ["Play", "Add to Queue", "Play On", "Remove from Liked Songs"]); + assertEquals(titles.includes("Go to Album") && titles.includes("Open in Spotify"), true); + assertEquals(trackItem(track).actions?.[3].title, "Save to Liked Songs"); + + const album = albumItem(track.album!); + assertEquals(album.accessories, [{ text: "1987" }]); + assertEquals(album.actions?.[0].title, "Play Album"); + + const pl = playlistItem({ + id: "p1", + uri: "spotify:playlist:p1", + name: "Road Trip", + description: "Songs & more", + images: null, + owner: { id: "me", display_name: "Me" }, + tracks: { total: 42 }, + collaborative: true, + }); + assertEquals(pl.icon, { source: "bs-music", tint: "green" }); + assertEquals(pl.detail?.markdown, "Songs & more"); + assertEquals(pl.accessories, [{ tag: "Collaborative" }, { text: "42 tracks" }]); +}); diff --git a/extensions/spotify/views.ts b/extensions/spotify/views.ts new file mode 100644 index 0000000..c72c718 --- /dev/null +++ b/extensions/spotify/views.ts @@ -0,0 +1,368 @@ +// Tracks, albums, artists and playlists as launcher items, with their actions. + +import { + type Action, + action, + close, + type Context, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, + submenu, + toast, +} from "@backslash/api"; +import { type Client, client, devices, liked, play, type PlayTarget, signOut } from "./api.ts"; +import { + type Album, + type Artist, + artists, + compact, + deviceIcon, + duration, + pickImage, + plainDescription, + type Playlist, + popularityColor, + totalDuration, + type Track, + trackCount, + webUrl, + year, +} from "./lib.ts"; + +// --------------------------------------------------------------------------- +// Actions + +export function playAction(title: string, target: PlayTarget, what: string, options = {}): Action { + return action(title, async (ctx) => { + await play(await client(ctx), target); + return close(`Playing ${what}`); + }, { icon: Icons.play, ...options }); +} + +/** "Play On ›": the user's devices, loaded when the submenu opens. */ +export function playOn(target: PlayTarget, what: string): Action { + return submenu("Play On", async (ctx) => { + const found = await devices(await client(ctx)); + if (!found.length) { + return [action("No Device Online", () => toast("Open Spotify on a computer, phone or speaker"), { + icon: Icons.info, + })]; + } + return found.filter((d) => d.id).map((d) => + action(`${d.name}${d.is_active ? " (active)" : ""}`, async (c) => { + await play(await client(c), target, d.id!); + return close(`Playing ${what} on ${d.name}`); + }, { icon: deviceIcon(d.type) }) + ); + }, { icon: Icons.speaker, shortcut: "d", section: "Playback" }); +} + +export function queueAction(track: { uri: string; name: string }, options = {}): Action { + return action("Add to Queue", async (ctx) => { + await (await client(ctx)).send("POST", `/me/player/queue?uri=${encodeURIComponent(track.uri)}`); + await toast(`${track.name} added to the queue`, { style: "success" }); + }, { icon: Icons.plus, shortcut: "q", ...options }); +} + +export function likeAction(track: { id: string; name: string }, isLiked: boolean, options = {}): Action { + return action(isLiked ? "Remove from Liked Songs" : "Save to Liked Songs", async (ctx) => { + await (await client(ctx)).send(isLiked ? "DELETE" : "PUT", `/me/tracks?ids=${track.id}`); + await toast(isLiked ? `Removed ${track.name} from Liked Songs` : `Saved ${track.name} to Liked Songs`, { + style: "success", + }); + return refresh(); + }, { + icon: isLiked ? Icons.heart : Icons.heartFilled, + shortcut: "l", + section: "Library", + ...options, + }); +} + +export const signOutAction = action("Sign Out of Spotify", async (ctx) => { + await signOut(ctx); + return close("Signed out of Spotify"); +}, { icon: Icons.power, destructive: true, confirm: "Sign out of Spotify?", section: "Account" }); + +function openActions(kind: string, id: string, uri: string, web?: string): Action[] { + const url = web ?? webUrl(kind, id); + return [ + open(uri, "Open in Spotify", { icon: Icons.music, shortcut: "o", section: "Open" }), + open(url, "Open in Browser", { section: "Open" }), + copy(url, "Copy Link", { shortcut: "c", section: "Copy" }), + ]; +} + +// --------------------------------------------------------------------------- +// Items + +export interface TrackOptions { + liked?: Set; + /** Album or playlist the track belongs to: playing it continues with the next ones. */ + context?: string; + section?: string; + /** Shown instead of the duration, e.g. a track number. */ + position?: number; +} + +export function trackItem(t: Track, o: TrackOptions = {}): Item { + const isLiked = o.liked?.has(t.id) ?? false; + const cover = pickImage(t.album?.images, "large"); + const who = artists(t.artists); + const target: PlayTarget = o.context && !t.is_local ? { context: o.context, offset: t.uri } : { uris: [t.uri] }; + return { + id: `track:${t.id}:${o.position ?? ""}`, + title: t.name, + subtitle: who, + icon: pickImage(t.album?.images, "small") ?? { source: Icons.music, tint: "green" }, + section: o.section, + keywords: [who, t.album?.name ?? ""], + accessories: [ + ...(isLiked ? [{ icon: { source: Icons.heartFilled, tint: "green" as const }, tooltip: "In Liked Songs" }] : []), + ...(t.explicit ? [{ tag: "E", tooltip: "Explicit" }] : []), + { text: duration(t.duration_ms) }, + ], + detail: { + image: cover, + metadata: [ + meta.text("Artist", who, { icon: Icons.person }), + ...(t.album ? [meta.text("Album", t.album.name, { icon: Icons.music })] : []), + ...(t.album?.release_date ? [meta.text("Released", t.album.release_date, { icon: Icons.calendar })] : []), + meta.text("Duration", duration(t.duration_ms), { icon: Icons.clock }), + ...(t.popularity !== undefined ? [meta.progress("Popularity", t.popularity / 100)] : []), + ...(o.liked + ? [meta.text("Liked Songs", isLiked ? "Saved" : "Not saved", { + icon: isLiked ? Icons.heartFilled : Icons.heart, + color: isLiked ? "green" : undefined, + })] + : []), + ...(t.explicit ? [meta.tags("Content", [{ text: "Explicit", color: "red" }])] : []), + ], + }, + actions: [ + playAction("Play", target, t.name), + queueAction(t), + playOn(target, t.name), + ...(t.is_local ? [] : [likeAction(t, isLiked)]), + ...(t.album + ? [ + action("Go to Album", () => albumView(t.album!), { + icon: Icons.music, + shortcut: "a", + section: "Browse", + }), + ] + : []), + ...(t.artists[0] + ? [action(`Go to ${t.artists[0].name}`, () => artistView(t.artists[0]), { + icon: Icons.person, + shortcut: "r", + section: "Browse", + })] + : []), + ...openActions("track", t.id, t.uri, t.external_urls?.spotify), + copy(`${who} – ${t.name}`, "Copy Artist and Title", { section: "Copy" }), + signOutAction, + ], + }; +} + +export function albumItem(a: Album, section?: string): Item { + const who = artists(a.artists); + return { + id: `album:${a.id}`, + title: a.name, + subtitle: who, + icon: pickImage(a.images, "small") ?? { source: Icons.music, tint: "purple" }, + section, + keywords: [who], + accessories: [ + ...(a.album_type && a.album_type !== "album" ? [{ tag: a.album_type }] : []), + ...(year(a.release_date) ? [{ text: year(a.release_date)! }] : []), + ], + detail: { + image: pickImage(a.images, "large"), + metadata: [ + meta.text("Artist", who, { icon: Icons.person }), + ...(a.release_date ? [meta.text("Released", a.release_date, { icon: Icons.calendar })] : []), + ...(a.total_tracks ? [meta.text("Tracks", a.total_tracks, { icon: Icons.music })] : []), + ...(a.album_type ? [meta.tags("Type", [{ text: a.album_type, color: "purple" }])] : []), + ], + }, + actions: [ + playAction("Play Album", { context: a.uri }, a.name), + action("Show Tracks", () => albumView(a), { icon: Icons.music, shortcut: "t" }), + playOn({ context: a.uri }, a.name), + ...(a.artists[0] + ? [action(`Go to ${a.artists[0].name}`, () => artistView(a.artists[0]), { + icon: Icons.person, + shortcut: "r", + section: "Browse", + })] + : []), + ...openActions("album", a.id, a.uri, a.external_urls?.spotify), + signOutAction, + ], + }; +} + +export function artistItem(a: Artist, section?: string): Item { + return { + id: `artist:${a.id}`, + title: a.name, + subtitle: a.genres?.slice(0, 2).join(", ") || undefined, + icon: pickImage(a.images, "small") ?? { source: Icons.person, tint: "blue" }, + section, + keywords: a.genres ?? [], + accessories: a.followers ? [{ text: `${compact(a.followers.total)} followers` }] : [], + detail: { + image: pickImage(a.images, "large"), + metadata: [ + ...(a.followers + ? [meta.text("Followers", a.followers.total.toLocaleString("en"), { icon: Icons.person })] + : []), + ...(a.popularity !== undefined ? [meta.progress("Popularity", a.popularity / 100)] : []), + ...(a.genres?.length + ? [ + meta.tags( + "Genres", + a.genres.slice(0, 5).map((g) => ({ text: g, color: popularityColor(a.popularity ?? 0) })), + ), + ] + : []), + ], + }, + actions: [ + playAction("Play Artist", { context: a.uri }, a.name), + action("Show Top Tracks", () => artistView(a), { icon: Icons.star, shortcut: "t" }), + playOn({ context: a.uri }, a.name), + ...openActions("artist", a.id, a.uri, a.external_urls?.spotify), + signOutAction, + ], + }; +} + +export function playlistItem(p: Playlist, section?: string): Item { + const description = plainDescription(p.description); + const owner = p.owner?.display_name ?? p.owner?.id ?? ""; + const n = trackCount(p); + return { + id: `playlist:${p.id}`, + title: p.name, + subtitle: owner ? `by ${owner}` : undefined, + icon: pickImage(p.images, "small") ?? { source: Icons.music, tint: "green" }, + section, + keywords: [owner, description], + accessories: [ + ...(p.collaborative ? [{ tag: "Collaborative" }] : []), + ...(n ? [{ text: `${n} tracks` }] : []), + ], + detail: { + image: pickImage(p.images, "large"), + markdown: description || undefined, + metadata: [ + ...(owner ? [meta.person("Owner", owner)] : []), + meta.text("Tracks", n, { icon: Icons.music }), + ...(p.public !== undefined && p.public !== null + ? [meta.text("Visibility", p.public ? "Public" : "Private", { icon: p.public ? Icons.globe : Icons.lock })] + : []), + ...(p.collaborative ? [meta.tags("Type", [{ text: "Collaborative", color: "teal" }])] : []), + ], + }, + actions: [ + playAction("Play Playlist", { context: p.uri }, p.name), + action("Shuffle Play", async (ctx) => { + const api = await client(ctx); + await api.send("PUT", "/me/player/shuffle?state=true").catch(() => {}); + await play(api, { context: p.uri }); + return close(`Shuffling ${p.name}`); + }, { icon: Icons.shuffle, shortcut: "s", section: "Playback" }), + action("Show Tracks", () => playlistView(p), { icon: Icons.music, shortcut: "t" }), + playOn({ context: p.uri }, p.name), + ...openActions("playlist", p.id, p.uri, p.external_urls?.spotify), + signOutAction, + ], + }; +} + +// --------------------------------------------------------------------------- +// Track lists + +function tracksView( + title: string, + load: (api: Client, ctx: Context) => Promise, + options: { context?: string; numbered?: boolean } = {}, +): ListSpec { + return list({ + title, + placeholder: `Filter ${title}…`, + empty: { title: "No tracks", icon: Icons.music }, + status: (items) => `${title} · ${items.length} tracks`, + async items(ctx) { + const api = await client(ctx); + const tracks = (await load(api, ctx)).filter((t) => t?.id || t?.is_local); + const likedSet = await liked(api, tracks.filter((t) => t.id).map((t) => t.id)); + const total = tracks.reduce((s, t) => s + t.duration_ms, 0); + return tracks.map((t, i) => { + const item = trackItem(t, { liked: likedSet, context: options.context, position: i }); + return options.numbered ? { ...item, section: `${tracks.length} tracks · ${totalDuration(total)}` } : item; + }); + }, + }); +} + +export function albumView(a: Album): ListSpec { + return tracksView(a.name, async (api, ctx) => { + const full = await api.get(`/albums/${a.id}`, ctx.signal); + const album = full ?? a; + return (full?.tracks.items ?? []).map((t) => ({ ...t, album })); + }, { context: a.uri, numbered: true }); +} + +export function artistView(a: { id: string; name: string; uri: string }): ListSpec { + return tracksView(`${a.name}: Top Tracks`, async (api, ctx) => { + const res = await api.get<{ tracks: Track[] }>(`/artists/${a.id}/top-tracks?market=from_token`, ctx.signal); + return res?.tracks ?? []; + }); +} + +type Entry = { track?: Track | null; item?: Track | null }; + +export function playlistView(p: Playlist): ListSpec { + return tracksView(p.name, async (api, ctx) => { + const entries: Entry[] = []; + // Newer apps read playlist contents from /items, older ones from /tracks. + let next: string | undefined = `/playlists/${p.id}/tracks?limit=100`; + try { + while (next && entries.length < 500) { + const page: { items: Entry[]; next?: string | null } | undefined = await api.get(next, ctx.signal); + entries.push(...(page?.items ?? [])); + next = page?.next ?? undefined; + } + } catch (e) { + if (entries.length || ctx.signal.aborted) throw e; + const page = await api.get<{ items: Entry[] }>(`/playlists/${p.id}/items?limit=100`, ctx.signal); + entries.push(...(page?.items ?? [])); + } + return entries.map((e) => e.track ?? e.item).filter((t): t is Track => !!t && (t.type ?? "track") === "track"); + }, { context: p.uri, numbered: true }); +} + +export function likedSongsView(): ListSpec { + return tracksView("Liked Songs", async (api, ctx) => { + const tracks: Track[] = []; + let next: string | undefined = "/me/tracks?limit=50"; + while (next && tracks.length < 300) { + const page: { items: { track: Track }[]; next?: string | null } | undefined = await api.get(next, ctx.signal); + tracks.push(...(page?.items ?? []).map((i) => i.track)); + next = page?.next ?? undefined; + } + return tracks; + }); +} diff --git a/extensions/weather/api.ts b/extensions/weather/api.ts new file mode 100644 index 0000000..ecc22fa --- /dev/null +++ b/extensions/weather/api.ts @@ -0,0 +1,88 @@ +// Network and cache: forecasts are kept 15 minutes, cities forever. + +import { storage } from "@backslash/api"; +import { + type Forecast, + type ForecastResponse, + forecastUrl, + geocodingUrl, + type Place, + rankPlaces, + splitQuery, + toForecast, + type Units, +} from "./lib.ts"; + +const FORECAST_TTL = 15 * 60_000; + +type Json = Parameters[1]; + +async function load(key: string): Promise { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +async function save(key: string, value: unknown): Promise { + await storage.set(key, value as Json).catch(() => {}); +} + +async function getJson(url: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(url, { signal }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Open-Meteo is unreachable: ${(e as Error).message}`); + } + const body = await res.json().catch(() => ({})) as { error?: boolean; reason?: string }; + if (!res.ok || body.error) throw new Error(`Open-Meteo: ${body.reason ?? `answered ${res.status}`}`); + return body as T; +} + +export async function findPlaces(query: string, signal?: AbortSignal, count = 8): Promise { + const q = splitQuery(query); + if (!q.name) return []; + const res = await getJson<{ results?: Place[] }>(geocodingUrl(q.name, count), signal); + return rankPlaces(res.results ?? [], q.hint); +} + +/** The first city matching `name`, remembered so the default city costs no request. */ +export async function resolvePlace(name: string, signal?: AbortSignal): Promise { + const key = `place:${name.trim().toLowerCase()}`; + const cached = await load(key); + if (cached) return cached; + const place = (await findPlaces(name, signal, 5))[0]; + if (place) await save(key, place); + return place; +} + +export async function forecast(place: Place, units: Units, signal?: AbortSignal, fresh = false): Promise { + const key = `forecast:${place.latitude.toFixed(3)},${place.longitude.toFixed(3)}:${units}`; + const cached = fresh ? undefined : await load<{ at: number; data: ForecastResponse }>(key); + if (cached && Date.now() - cached.at < FORECAST_TTL) return toForecast(cached.data, units); + const data = await getJson(forecastUrl(place.latitude, place.longitude, units), signal); + await save(key, { at: Date.now(), data }); + return toForecast(data, units); +} + +// Saved cities + +export async function savedCities(): Promise { + return await load("cities") ?? []; +} + +export async function saveCity(place: Place): Promise { + await save("cities", [...(await savedCities()).filter((c) => c.id !== place.id), place]); +} + +export async function removeCity(place: Place): Promise { + await save("cities", (await savedCities()).filter((c) => c.id !== place.id)); +} + +export async function moveCity(place: Place, delta: number): Promise { + const cities = await savedCities(); + const i = cities.findIndex((c) => c.id === place.id); + const j = i + delta; + if (i < 0 || j < 0 || j >= cities.length) return; + [cities[i], cities[j]] = [cities[j], cities[i]]; + await save("cities", cities); +} diff --git a/extensions/weather/backslash.toml b/extensions/weather/backslash.toml new file mode 100644 index 0000000..1960dc5 --- /dev/null +++ b/extensions/weather/backslash.toml @@ -0,0 +1,30 @@ +[extension] +id = "weather" +title = "Weather" +description = "Current conditions, the next 24 hours and the week ahead for any city, from Open-Meteo" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud-sun" +license = "MIT" + +# Open-Meteo is free and needs no API key: one host to find cities, one for forecasts. +[permissions] +net = ["geocoding-api.open-meteo.com", "api.open-meteo.com"] + +[[commands]] +name = "weather" +title = "Weather" +description = "Forecast for your city and saved cities; type a city name to look it up" +icon = "bs-cloud-sun" +keywords = ["weather", "forecast", "temperature", "rain", "météo", "meteo", "prévisions", "température", "pluie"] + +[[preferences]] +name = "city" +title = "Default city" +description = "Shown when you open the command, e.g. \"Paris\" or \"Lyon, France\"" + +[[preferences]] +name = "units" +title = "Units" +description = "\"metric\" (°C, km/h, mm) or \"imperial\" (°F, mph, in)" +default = "metric" diff --git a/extensions/weather/commands/weather.ts b/extensions/weather/commands/weather.ts new file mode 100644 index 0000000..0629f70 --- /dev/null +++ b/extensions/weather/commands/weather.ts @@ -0,0 +1,202 @@ +import { + type Action, + action, + type Context, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { findPlaces, forecast, moveCity, removeCity, resolvePlace, saveCity, savedCities } from "../api.ts"; +import { + condition, + dayItem, + deg, + type Forecast, + hourItem, + mapUrl, + nowItem, + type Place, + placeLabel, + shareText, + summary, + toKmh, + unitsOf, +} from "../lib.ts"; + +const FILTER = { + options: [{ value: "days", title: "7 Days" }, { value: "hours", title: "24 Hours" }], +}; + +// Set by "Refresh": the next load skips the 15 minute cache. +let fresh = false; + +const refreshAction = action("Refresh", () => { + fresh = true; + return refresh(); +}, { icon: Icons.refresh, shortcut: "r", section: "Forecast" }); + +function placeActions(place: Place, f: Forecast | undefined, saved: boolean, home: boolean): Action[] { + return [ + ...(f ? [copy(shareText(f, place), "Copy Forecast", { shortcut: "c", section: "Forecast" })] : []), + open(mapUrl(place), "Open Weather Map", { icon: Icons.globe, shortcut: "o", section: "Forecast" }), + refreshAction, + ...(home ? [] : saved + ? [ + action("Move Up", async () => { + await moveCity(place, -1); + return refresh(); + }, { icon: Icons.arrowUp, shortcut: "Up", section: "Saved Cities" }), + action("Move Down", async () => { + await moveCity(place, 1); + return refresh(); + }, { icon: Icons.arrowDown, shortcut: "Down", section: "Saved Cities" }), + action("Remove City", async () => { + await removeCity(place); + await toast(`${place.name} removed`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${place.name}?`, + shortcut: "x", + section: "Saved Cities", + }), + ] + : [ + action("Save City", async () => { + await saveCity(place); + await toast(`${place.name} saved`, { style: "success" }); + return refresh(); + }, { icon: Icons.star, shortcut: "s", section: "Saved Cities" }), + ]), + copy(`${place.latitude}, ${place.longitude}`, "Copy Coordinates", { icon: Icons.pin, section: "Copy" }), + ]; +} + +/** Now, then the next 24 hours or 7 days. */ +function forecastItems(f: Forecast, place: Place, range: string, actions: Action[]): Item[] { + return [ + nowItem(f, place, actions, `Now · ${place.name}`), + ...(range === "hours" + ? f.hours.map((h) => hourItem(h, f, place, actions)) + : f.days.map((d) => dayItem(d, f, place, actions))), + ]; +} + +/** The forecast of one city, opened from the search results or saved cities. */ +function forecastView(place: Place): ListSpec { + return list({ + title: placeLabel(place), + placeholder: `Forecast for ${place.name}`, + filter: FILTER, + status: () => placeLabel(place), + async items(ctx: Context) { + const units = unitsOf(ctx.preferences.units); + const f = await forecast(place, units, ctx.signal, fresh); + fresh = false; + const saved = (await savedCities()).some((c) => c.id === place.id); + return forecastItems(f, place, ctx.filter ?? "days", placeActions(place, f, saved, false)); + }, + }); +} + +const showForecast = (place: Place) => + action("Show Forecast", () => forecastView(place), { icon: Icons.cloudSun, section: "Forecast" }); + +/** A saved city, with its current conditions. */ +function cityItem(place: Place, f: Forecast | undefined): Item { + const actions = [showForecast(place), ...placeActions(place, f, true, false)]; + if (!f) { + return { + id: `city:${place.id}`, + title: place.name, + subtitle: "Forecast unavailable", + icon: { source: Icons.cloud, tint: "gray" }, + section: "Saved Cities", + actions, + }; + } + const item = nowItem(f, place, actions, "Saved Cities"); + return { ...item, id: `city:${place.id}`, title: place.name, subtitle: item.title }; +} + +/** Typed query: matching cities, the first with its current temperature. */ +async function search(query: string, ctx: Context): Promise { + const places = await findPlaces(query, ctx.signal); + const saved = new Set((await savedCities()).map((c) => c.id)); + const units = unitsOf(ctx.preferences.units); + const first = places[0] ? await forecast(places[0], units, ctx.signal).catch(() => undefined) : undefined; + return places.map((p, i) => { + const f = i === 0 ? first : undefined; + const cond = f ? condition(f.now.code, f.now.isDay, toKmh(f.now.wind, units)) : undefined; + return { + id: `place:${p.id}`, + title: p.name, + subtitle: [p.admin1, p.country].filter(Boolean).join(", "), + icon: cond ? { source: cond.icon, tint: cond.tint } : { source: Icons.pin, tint: "blue" as const }, + section: "Cities", + accessories: [ + ...(f && cond ? [{ text: `${deg(f.now.temp)} ${cond.label}` }] : []), + ...(saved.has(p.id) ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Saved" }] : []), + ...(p.country_code ? [{ tag: p.country_code }] : []), + ], + detail: f ? nowItem(f, p, []).detail : { + metadata: [ + meta.text("Country", p.country ?? "—", { icon: Icons.globe }), + ...(p.admin1 ? [meta.text("Region", p.admin1)] : []), + ...(p.population ? [meta.text("Population", p.population.toLocaleString("en"))] : []), + ...(p.elevation !== undefined ? [meta.text("Elevation", `${Math.round(p.elevation)} m`)] : []), + ...(p.timezone ? [meta.text("Time zone", p.timezone, { icon: Icons.clock })] : []), + meta.text("Coordinates", `${p.latitude.toFixed(3)}, ${p.longitude.toFixed(3)}`, { icon: Icons.pin }), + ], + }, + actions: [showForecast(p), ...placeActions(p, f, saved.has(p.id), false)], + }; + }); +} + +/** Empty query: the default city's forecast, then the saved cities. */ +async function home(ctx: Context): Promise { + const units = unitsOf(ctx.preferences.units); + const cities = await savedCities(); + const name = String(ctx.preferences.city ?? "").trim(); + const main = name ? await resolvePlace(name, ctx.signal) : cities[0]; + if (name && !main) throw new Error(`No city named “${name}”: check the default city in the extension settings`); + const others = cities.filter((c) => c.id !== main?.id); + const [f, ...rest] = await Promise.all([ + main ? forecast(main, units, ctx.signal, fresh) : Promise.resolve(undefined), + ...others.map((c) => forecast(c, units, ctx.signal, fresh).catch(() => undefined)), + ]); + fresh = false; + const items: Item[] = []; + if (main && f) { + updateCommandMetadata({ subtitle: summary(f, main) }).catch(() => {}); + const isSaved = cities.some((c) => c.id === main.id); + items.push(...forecastItems(f, main, ctx.filter ?? "days", placeActions(main, f, isSaved, !!name))); + } + items.push(...others.map((c, i) => cityItem(c, rest[i]))); + return items; +} + +export default list({ + placeholder: "Search for a city…", + filter: FILTER, + empty: { + title: "Type a city name", + description: "Or set a default city in Settings → Extensions → Weather", + icon: Icons.cloudSun, + }, + status: (items) => { + const now = items.find((i) => i.id?.startsWith("now:")); + if (now) return `${now.subtitle} · Open-Meteo`; + return items.length ? `${items.length} cities` : "Open-Meteo"; + }, + search: (query, ctx) => query.trim() ? search(query, ctx) : home(ctx), +}); diff --git a/extensions/weather/lib.ts b/extensions/weather/lib.ts new file mode 100644 index 0000000..3410b37 --- /dev/null +++ b/extensions/weather/lib.ts @@ -0,0 +1,546 @@ +// Open-Meteo: request URLs, WMO weather codes and the mapping from a +// forecast to launcher items. Pure, tested in lib_test.ts. + +import { type Action, type Color, Icons, type Item, meta, type Metadata, type Stat } from "@backslash/api"; + +export type Units = "metric" | "imperial"; + +export function unitsOf(pref: unknown): Units { + return String(pref ?? "").trim().toLowerCase().startsWith("imp") ? "imperial" : "metric"; +} + +/** A city found by the geocoding API. */ +export interface Place { + id: number; + name: string; + latitude: number; + longitude: number; + country?: string; + country_code?: string; + admin1?: string; + timezone?: string; + population?: number; + elevation?: number; +} + +/** "Lyon, Auvergne-Rhône-Alpes, France". */ +export function placeLabel(p: Place): string { + return [p.name, p.admin1 !== p.name ? p.admin1 : undefined, p.country].filter(Boolean).join(", "); +} + +export function geocodingUrl(name: string, count = 8, language = "en"): string { + const q = new URLSearchParams({ name, count: String(count), language, format: "json" }); + return `https://geocoding-api.open-meteo.com/v1/search?${q}`; +} + +/** "Lyon, France" → search "Lyon", keep results in France first. */ +export function splitQuery(query: string): { name: string; hint?: string } { + const [name, ...rest] = query.split(",").map((s) => s.trim()).filter(Boolean); + return { name: name ?? "", hint: rest.join(" ") || undefined }; +} + +export function rankPlaces(places: Place[], hint?: string): Place[] { + if (!hint) return places; + const h = hint.toLowerCase(); + const matches = (p: Place) => + [p.country, p.country_code, p.admin1].some((v) => v && (v.toLowerCase() === h || v.toLowerCase().startsWith(h))); + return [...places.filter(matches), ...places.filter((p) => !matches(p))]; +} + +export const CURRENT = [ + "temperature_2m", + "apparent_temperature", + "relative_humidity_2m", + "is_day", + "weather_code", + "wind_speed_10m", + "wind_direction_10m", + "wind_gusts_10m", + "precipitation", + "cloud_cover", + "pressure_msl", +]; +export const HOURLY = [ + "temperature_2m", + "apparent_temperature", + "precipitation_probability", + "precipitation", + "weather_code", + "wind_speed_10m", + "relative_humidity_2m", + "uv_index", + "is_day", +]; +export const DAILY = [ + "weather_code", + "temperature_2m_max", + "temperature_2m_min", + "apparent_temperature_max", + "apparent_temperature_min", + "precipitation_probability_max", + "precipitation_sum", + "wind_speed_10m_max", + "wind_direction_10m_dominant", + "uv_index_max", + "sunrise", + "sunset", +]; + +export function forecastUrl(lat: number, lon: number, units: Units): string { + const q = new URLSearchParams({ + latitude: lat.toFixed(4), + longitude: lon.toFixed(4), + current: CURRENT.join(","), + hourly: HOURLY.join(","), + daily: DAILY.join(","), + timezone: "auto", + forecast_days: "7", + }); + if (units === "imperial") { + q.set("temperature_unit", "fahrenheit"); + q.set("wind_speed_unit", "mph"); + q.set("precipitation_unit", "inch"); + } + return `https://api.open-meteo.com/v1/forecast?${q}`; +} + +/** The parts of an Open-Meteo forecast response we use. */ +export interface ForecastResponse { + timezone?: string; + current: Record; + hourly: Record; + daily: Record; +} + +// --------------------------------------------------------------------------- +// WMO weather codes + +export interface Condition { + label: string; + icon: string; + tint: Color; +} + +const WMO: Record = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Freezing fog", + 51: "Light drizzle", + 53: "Drizzle", + 55: "Heavy drizzle", + 56: "Freezing drizzle", + 57: "Heavy freezing drizzle", + 61: "Light rain", + 63: "Rain", + 65: "Heavy rain", + 66: "Freezing rain", + 67: "Heavy freezing rain", + 71: "Light snow", + 73: "Snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Light showers", + 81: "Showers", + 82: "Violent showers", + 85: "Snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with hail", + 99: "Thunderstorm with heavy hail", +}; + +/** Above this (km/h), a dry sky shows the wind glyph instead. */ +const WINDY_KMH = 45; + +/** + * Label, glyph and tint of a WMO code. Clear nights show the moon; a dry, + * very windy sky shows the wind. + */ +export function condition(code: number, isDay = true, windKmh = 0): Condition { + const label = WMO[code] ?? "Unknown"; + const dry = code <= 3; + if (dry && windKmh >= WINDY_KMH) return { label: `${label}, windy`, icon: Icons.wind, tint: "teal" }; + if (code <= 1) { + return isDay ? { label, icon: Icons.sun, tint: "yellow" } : { label, icon: Icons.moon, tint: "purple" }; + } + // No "cloud-moon" glyph: partly cloudy nights use the plain cloud. + if (code === 2) { + return isDay ? { label, icon: Icons.cloudSun, tint: "yellow" } : { label, icon: Icons.cloud, tint: "gray" }; + } + if (code === 3 || code === 45 || code === 48) return { label, icon: Icons.cloud, tint: "gray" }; + if (code >= 51 && code <= 57) return { label, icon: Icons.droplet, tint: "teal" }; + if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return { label, icon: Icons.cloudRain, tint: "blue" }; + if ((code >= 71 && code <= 77) || code === 85 || code === 86) return { label, icon: Icons.snowflake, tint: "teal" }; + if (code >= 95) return { label, icon: Icons.bolt, tint: "orange" }; + return { label, icon: Icons.cloud, tint: "gray" }; +} + +/** Colors a temperature, in °C. */ +export function tempColor(celsius: number): Color { + if (celsius <= 0) return "blue"; + if (celsius < 10) return "teal"; + if (celsius < 20) return "green"; + if (celsius < 28) return "orange"; + return "red"; +} + +export function uvColor(uv: number): Color { + if (uv < 3) return "green"; + if (uv < 6) return "yellow"; + if (uv < 8) return "orange"; + if (uv < 11) return "red"; + return "purple"; +} + +export function uvLabel(uv: number): string { + const level = uv < 3 ? "low" : uv < 6 ? "moderate" : uv < 8 ? "high" : uv < 11 ? "very high" : "extreme"; + return `${Math.round(uv)} · ${level}`; +} + +const COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + +export function compass(degrees: number): string { + return COMPASS[Math.round(((degrees % 360) + 360) % 360 / 45) % 8]; +} + +// --------------------------------------------------------------------------- +// The model shown by the command + +export interface Hour { + time: string; + temp: number; + feels: number; + precipProb: number; + precip: number; + code: number; + wind: number; + humidity: number; + uv: number; + isDay: boolean; +} + +export interface Day { + date: string; + code: number; + max: number; + min: number; + feelsMax: number; + feelsMin: number; + precipProb: number; + precip: number; + wind: number; + windDir: number; + uv: number; + sunrise: string; + sunset: string; +} + +export interface Forecast { + units: Units; + timezone?: string; + now: { + time: string; + temp: number; + feels: number; + humidity: number; + isDay: boolean; + code: number; + wind: number; + windDir: number; + gusts: number; + precip: number; + clouds: number; + pressure: number; + }; + hours: Hour[]; + days: Day[]; +} + +const num = (v: unknown, fallback = 0) => typeof v === "number" && Number.isFinite(v) ? v : fallback; +const str = (v: unknown) => typeof v === "string" ? v : ""; + +/** The response as plain records, hourly values from the current hour on (24 of them). */ +export function toForecast(res: ForecastResponse, units: Units, hours = 24): Forecast { + const c = res.current ?? {}; + const h = res.hourly ?? {}; + const d = res.daily ?? {}; + const nowTime = str(c.time); + const thisHour = nowTime.slice(0, 13); // "2026-10-28T10" + const times = (h.time ?? []).map(str); + let start = times.findIndex((t) => t.slice(0, 13) >= thisHour); + if (start < 0) start = 0; + const at = (key: string, i: number) => num(h[key]?.[i]); + const hourList: Hour[] = times.slice(start, start + hours).map((time, k) => { + const i = start + k; + return { + time, + temp: at("temperature_2m", i), + feels: at("apparent_temperature", i), + precipProb: at("precipitation_probability", i), + precip: at("precipitation", i), + code: at("weather_code", i), + wind: at("wind_speed_10m", i), + humidity: at("relative_humidity_2m", i), + uv: at("uv_index", i), + isDay: at("is_day", i) === 1, + }; + }); + const dayAt = (key: string, i: number) => num(d[key]?.[i]); + const days: Day[] = (d.time ?? []).map((date, i) => ({ + date: str(date), + code: dayAt("weather_code", i), + max: dayAt("temperature_2m_max", i), + min: dayAt("temperature_2m_min", i), + feelsMax: dayAt("apparent_temperature_max", i), + feelsMin: dayAt("apparent_temperature_min", i), + precipProb: dayAt("precipitation_probability_max", i), + precip: dayAt("precipitation_sum", i), + wind: dayAt("wind_speed_10m_max", i), + windDir: dayAt("wind_direction_10m_dominant", i), + uv: dayAt("uv_index_max", i), + sunrise: str(d.sunrise?.[i]), + sunset: str(d.sunset?.[i]), + })); + return { + units, + timezone: res.timezone, + now: { + time: nowTime, + temp: num(c.temperature_2m), + feels: num(c.apparent_temperature), + humidity: num(c.relative_humidity_2m), + isDay: num(c.is_day, 1) === 1, + code: num(c.weather_code), + wind: num(c.wind_speed_10m), + windDir: num(c.wind_direction_10m), + gusts: num(c.wind_gusts_10m), + precip: num(c.precipitation), + clouds: num(c.cloud_cover), + pressure: num(c.pressure_msl), + }, + hours: hourList, + days, + }; +} + +// --------------------------------------------------------------------------- +// Formatting + +export const deg = (t: number) => `${Math.round(t)}°`; +export const windUnit = (u: Units) => u === "imperial" ? "mph" : "km/h"; +export const precipUnit = (u: Units) => u === "imperial" ? "in" : "mm"; +export const toCelsius = (t: number, u: Units) => u === "imperial" ? (t - 32) * 5 / 9 : t; +export const toKmh = (w: number, u: Units) => u === "imperial" ? w * 1.609 : w; + +export function wind(speed: number, units: Units, dir?: number): string { + return `${Math.round(speed)} ${windUnit(units)}${dir === undefined ? "" : ` ${compass(dir)}`}`; +} + +export function precipAmount(v: number, units: Units): string { + return `${units === "imperial" ? v.toFixed(2) : v.toFixed(1)} ${precipUnit(units)}`; +} + +/** "2026-10-28T07:52" → "07:52". */ +export const clock = (iso: string) => iso.slice(11, 16); + +/** "2026-10-28" → "Wednesday 28 October"; relative to `today` ("Today", "Tomorrow"). */ +export function dayName(date: string, today: string, style: "long" | "short" = "long"): string { + const d = new Date(`${date}T12:00:00Z`); + const diff = Math.round((d.getTime() - new Date(`${today}T12:00:00Z`).getTime()) / 86_400_000); + if (diff === 0) return "Today"; + if (diff === 1) return "Tomorrow"; + return d.toLocaleDateString("en-GB", { weekday: style, timeZone: "UTC" }); +} + +export function fullDate(date: string): string { + return new Date(`${date}T12:00:00Z`).toLocaleDateString("en-GB", { + weekday: "long", + day: "numeric", + month: "long", + timeZone: "UTC", + }); +} + +/** Daylight length, "10 h 24 min". */ +export function daylight(sunrise: string, sunset: string): string { + const m = (Date.parse(`${sunset}:00Z`) - Date.parse(`${sunrise}:00Z`)) / 60_000; + if (!Number.isFinite(m) || m <= 0) return ""; + return `${Math.floor(m / 60)} h ${String(Math.round(m % 60)).padStart(2, "0")} min`; +} + +/** The root search subtitle: "12° Paris". */ +export function summary(f: Forecast, place: Place): string { + return `${deg(f.now.temp)} ${place.name}`; +} + +// --------------------------------------------------------------------------- +// Items + +function tint(t: number, units: Units): Color { + return tempColor(toCelsius(t, units)); +} + +export function nowItem(f: Forecast, place: Place, actions: Action[], section?: string): Item { + const n = f.now; + const today = f.days[0]; + const cond = condition(n.code, n.isDay, toKmh(n.wind, f.units)); + const u = f.units; + const stats: Stat[] = [ + { label: "Temperature", value: deg(n.temp), color: tint(n.temp, u) }, + { label: "Feels like", value: deg(n.feels), color: tint(n.feels, u) }, + { label: "Wind", value: wind(n.wind, u) }, + { label: "Humidity", value: `${Math.round(n.humidity)} %` }, + ]; + const metadata: Metadata[] = [ + meta.text("Conditions", cond.label, { icon: cond.icon, color: cond.tint }), + ...(today ? [meta.text("High / Low", `${deg(today.max)} / ${deg(today.min)}`, { icon: Icons.thermometer })] : []), + meta.text("Wind", `${wind(n.wind, u, n.windDir)} · gusts ${Math.round(n.gusts)} ${windUnit(u)}`, { + icon: Icons.wind, + }), + ...(today + ? [meta.text("Precipitation", `${Math.round(today.precipProb)} % · ${precipAmount(today.precip, u)} today`, { + icon: Icons.droplet, + color: today.precipProb >= 50 ? "blue" : undefined, + })] + : []), + ...(today ? [meta.text("UV index", uvLabel(today.uv), { icon: Icons.sun, color: uvColor(today.uv) })] : []), + meta.progress("Cloud cover", n.clouds / 100), + meta.text("Pressure", `${Math.round(n.pressure)} hPa`), + meta.separator(), + ...(today + ? [ + meta.text("Sunrise", clock(today.sunrise), { icon: Icons.sun, color: "orange" }), + meta.text("Sunset", clock(today.sunset), { icon: Icons.moon, color: "purple" }), + meta.text("Daylight", daylight(today.sunrise, today.sunset)), + ] + : []), + meta.text("Location", placeLabel(place), { icon: Icons.pin }), + meta.text("Updated", `${clock(n.time)}${f.timezone ? ` (${f.timezone})` : ""}`, { icon: Icons.clock }), + ]; + return { + id: `now:${place.id}`, + title: `${deg(n.temp)} ${cond.label}`, + subtitle: placeLabel(place), + icon: { source: cond.icon, tint: cond.tint }, + section, + keywords: ["now", "current", "maintenant", place.name], + accessories: [ + { text: `Feels ${deg(n.feels)}`, icon: { source: Icons.thermometer, tint: tint(n.feels, u) } }, + ...(today ? [{ text: `${deg(today.max)} / ${deg(today.min)}`, tooltip: "High / Low" }] : []), + ], + detail: { stats, metadata }, + actions, + }; +} + +export function hourItem(h: Hour, f: Forecast, place: Place, actions: Action[]): Item { + const u = f.units; + const cond = condition(h.code, h.isDay, toKmh(h.wind, u)); + return { + id: `hour:${place.id}:${h.time}`, + title: clock(h.time), + subtitle: cond.label, + icon: { source: cond.icon, tint: cond.tint }, + section: "Next 24 Hours", + keywords: [cond.label, "hourly"], + accessories: [ + ...(h.precipProb > 0 + ? [{ + text: `${Math.round(h.precipProb)} %`, + icon: { source: Icons.droplet, tint: h.precipProb >= 50 ? "blue" as const : "gray" as const }, + tooltip: "Chance of precipitation", + }] + : []), + { text: wind(h.wind, u), icon: Icons.wind, tooltip: "Wind" }, + { tag: deg(h.temp), color: tint(h.temp, u) }, + ], + detail: { + stats: [ + { label: "Temperature", value: deg(h.temp), color: tint(h.temp, u) }, + { label: "Feels like", value: deg(h.feels), color: tint(h.feels, u) }, + { + label: "Precipitation", + value: `${Math.round(h.precipProb)} %`, + color: h.precipProb >= 50 ? "blue" : undefined, + }, + ], + metadata: [ + meta.text("Conditions", cond.label, { icon: cond.icon, color: cond.tint }), + meta.text("Time", `${dayName(h.time.slice(0, 10), f.now.time.slice(0, 10))} ${clock(h.time)}`, { + icon: Icons.clock, + }), + meta.text("Precipitation", precipAmount(h.precip, u), { icon: Icons.droplet }), + meta.text("Wind", wind(h.wind, u), { icon: Icons.wind }), + meta.text("Humidity", `${Math.round(h.humidity)} %`, { icon: Icons.droplet }), + ...(h.isDay ? [meta.text("UV index", uvLabel(h.uv), { icon: Icons.sun, color: uvColor(h.uv) })] : []), + ], + }, + actions, + }; +} + +export function dayItem(d: Day, f: Forecast, place: Place, actions: Action[]): Item { + const u = f.units; + const cond = condition(d.code, true, toKmh(d.wind, u) * 0.6); + const name = dayName(d.date, f.now.time.slice(0, 10)); + return { + id: `day:${place.id}:${d.date}`, + title: name, + subtitle: cond.label, + icon: { source: cond.icon, tint: cond.tint }, + section: "Next 7 Days", + keywords: [cond.label, d.date, "daily", fullDate(d.date)], + accessories: [ + ...(d.precipProb > 0 + ? [{ + text: `${Math.round(d.precipProb)} %`, + icon: { source: Icons.droplet, tint: d.precipProb >= 50 ? "blue" as const : "gray" as const }, + tooltip: "Chance of precipitation", + }] + : []), + { text: deg(d.min), color: tint(d.min, u), tooltip: "Low" }, + { tag: deg(d.max), color: tint(d.max, u) }, + ], + detail: { + stats: [ + { label: "High", value: deg(d.max), color: tint(d.max, u) }, + { label: "Low", value: deg(d.min), color: tint(d.min, u) }, + { + label: "Precipitation", + value: `${Math.round(d.precipProb)} %`, + color: d.precipProb >= 50 ? "blue" : undefined, + }, + { label: "UV", value: Math.round(d.uv), color: uvColor(d.uv) }, + ], + metadata: [ + meta.text("Date", fullDate(d.date), { icon: Icons.calendar }), + meta.text("Conditions", cond.label, { icon: cond.icon, color: cond.tint }), + meta.text("Feels like", `${deg(d.feelsMax)} / ${deg(d.feelsMin)}`, { icon: Icons.thermometer }), + meta.text("Precipitation", precipAmount(d.precip, u), { icon: Icons.droplet }), + meta.text("Wind", `up to ${wind(d.wind, u, d.windDir)}`, { icon: Icons.wind }), + meta.text("UV index", uvLabel(d.uv), { icon: Icons.sun, color: uvColor(d.uv) }), + meta.separator(), + meta.text("Sunrise", clock(d.sunrise), { icon: Icons.sun, color: "orange" }), + meta.text("Sunset", clock(d.sunset), { icon: Icons.moon, color: "purple" }), + meta.text("Daylight", daylight(d.sunrise, d.sunset)), + ], + }, + actions, + }; +} + +/** A one-line forecast to copy: "Paris: 12° Partly cloudy, feels 10°, high 14° / low 7°". */ +export function shareText(f: Forecast, place: Place): string { + const cond = condition(f.now.code, f.now.isDay); + const today = f.days[0]; + return `${place.name}: ${deg(f.now.temp)} ${cond.label}, feels ${deg(f.now.feels)}` + + (today ? `, high ${deg(today.max)} / low ${deg(today.min)}` : ""); +} + +/** The forecast map of the place on windy.com. */ +export function mapUrl(place: Place): string { + return `https://www.windy.com/?${place.latitude.toFixed(3)},${place.longitude.toFixed(3)},9`; +} diff --git a/extensions/weather/lib_test.ts b/extensions/weather/lib_test.ts new file mode 100644 index 0000000..f8d7893 --- /dev/null +++ b/extensions/weather/lib_test.ts @@ -0,0 +1,193 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + compass, + condition, + dayItem, + daylight, + dayName, + forecastUrl, + geocodingUrl, + hourItem, + nowItem, + type Place, + placeLabel, + rankPlaces, + shareText, + splitQuery, + summary, + tempColor, + toForecast, + unitsOf, + uvLabel, +} from "./lib.ts"; + +const paris: Place = { + id: 2988507, + name: "Paris", + latitude: 48.85341, + longitude: 2.3488, + country: "France", + country_code: "FR", + admin1: "Île-de-France", + timezone: "Europe/Paris", + population: 2138551, +}; + +// Shaped like a real Open-Meteo answer (trimmed to 2 days). +const hours = Array.from({ length: 48 }, (_, i) => { + const d = i < 24 ? "2026-10-28" : "2026-10-29"; + return `${d}T${String(i % 24).padStart(2, "0")}:00`; +}); +const RESPONSE = { + timezone: "Europe/Paris", + current: { + time: "2026-10-28T10:15", + interval: 900, + temperature_2m: 12.4, + apparent_temperature: 10.1, + relative_humidity_2m: 81, + is_day: 1, + weather_code: 2, + wind_speed_10m: 14.8, + wind_direction_10m: 225, + wind_gusts_10m: 31.2, + precipitation: 0, + cloud_cover: 60, + pressure_msl: 1016.3, + }, + hourly: { + time: hours, + temperature_2m: hours.map((_, i) => 8 + (i % 24) / 2), + apparent_temperature: hours.map((_, i) => 6 + (i % 24) / 2), + precipitation_probability: hours.map((_, i) => (i * 7) % 100), + precipitation: hours.map(() => 0.2), + weather_code: hours.map((_, i) => (i % 2 ? 61 : 0)), + wind_speed_10m: hours.map(() => 12), + relative_humidity_2m: hours.map(() => 75), + uv_index: hours.map(() => 2), + is_day: hours.map((t) => (Number(t.slice(11, 13)) >= 8 && Number(t.slice(11, 13)) < 18 ? 1 : 0)), + }, + daily: { + time: ["2026-10-28", "2026-10-29"], + weather_code: [2, 95], + temperature_2m_max: [14.2, 11], + temperature_2m_min: [7.1, -1.4], + apparent_temperature_max: [12, 9], + apparent_temperature_min: [5, -4], + precipitation_probability_max: [20, 80], + precipitation_sum: [0.4, 12.3], + wind_speed_10m_max: [22, 40], + wind_direction_10m_dominant: [230, 270], + uv_index_max: [2.4, 6.5], + sunrise: ["2026-10-28T07:21", "2026-10-29T07:23"], + sunset: ["2026-10-28T17:45", "2026-10-29T17:43"], + }, +}; + +Deno.test("WMO codes map to glyphs, day and night", () => { + assertEquals(condition(0), { label: "Clear sky", icon: "bs-sun", tint: "yellow" }); + assertEquals(condition(0, false).icon, "bs-moon"); + assertEquals(condition(2).icon, "bs-cloud-sun"); + assertEquals(condition(2, false).icon, "bs-cloud"); + assertEquals(condition(3).icon, "bs-cloud"); + assertEquals(condition(45).label, "Fog"); + assertEquals(condition(53).icon, "bs-droplet"); + assertEquals(condition(63).icon, "bs-cloud-rain"); + assertEquals(condition(81).icon, "bs-cloud-rain"); + assertEquals(condition(75).icon, "bs-snowflake"); + assertEquals(condition(86).icon, "bs-snowflake"); + assertEquals(condition(95).icon, "bs-bolt"); + assertEquals(condition(99).label, "Thunderstorm with heavy hail"); + assertEquals(condition(1, true, 60), { label: "Mainly clear, windy", icon: "bs-wind", tint: "teal" }); + // Wind never hides rain. + assertEquals(condition(63, true, 80).icon, "bs-cloud-rain"); + assertEquals(condition(42).label, "Unknown"); +}); + +Deno.test("formatting helpers", () => { + assertEquals([compass(0), compass(225), compass(350), compass(-90)], ["N", "SW", "N", "W"]); + assertEquals([tempColor(-3), tempColor(5), tempColor(15), tempColor(25), tempColor(33)], [ + "blue", + "teal", + "green", + "orange", + "red", + ]); + assertEquals(uvLabel(6.5), "7 · high"); + assertEquals(daylight("2026-10-28T07:21", "2026-10-28T17:45"), "10 h 24 min"); + assertEquals(dayName("2026-10-28", "2026-10-28"), "Today"); + assertEquals(dayName("2026-10-29", "2026-10-28"), "Tomorrow"); + assertEquals(dayName("2026-10-31", "2026-10-28"), "Saturday"); + assertEquals([unitsOf("Imperial"), unitsOf(undefined), unitsOf("metric")], ["imperial", "metric", "metric"]); +}); + +Deno.test("city queries", () => { + assertEquals(splitQuery(" Lyon , France "), { name: "Lyon", hint: "France" }); + assertEquals(splitQuery("Paris"), { name: "Paris", hint: undefined }); + const texas = { ...paris, id: 1, country: "United States", country_code: "US", admin1: "Texas" }; + assertEquals(rankPlaces([texas, paris], "fr")[0].id, paris.id); + assertEquals(rankPlaces([texas, paris], "texas")[0].id, 1); + assertEquals(placeLabel(paris), "Paris, Île-de-France, France"); + assertEquals(placeLabel({ ...paris, admin1: "Paris" }), "Paris, France"); + assertStringIncludes(geocodingUrl("São Paulo"), "name=S%C3%A3o+Paulo"); +}); + +Deno.test("forecast URL asks for everything shown, in the right units", () => { + const metric = new URL(forecastUrl(48.85341, 2.3488, "metric")); + assertEquals(metric.searchParams.get("latitude"), "48.8534"); + assertEquals(metric.searchParams.get("timezone"), "auto"); + assertStringIncludes(metric.searchParams.get("daily")!, "sunrise"); + assertStringIncludes(metric.searchParams.get("hourly")!, "precipitation_probability"); + assertEquals(metric.searchParams.get("temperature_unit"), null); + const imperial = new URL(forecastUrl(1, 2, "imperial")); + assertEquals(imperial.searchParams.get("temperature_unit"), "fahrenheit"); + assertEquals(imperial.searchParams.get("wind_speed_unit"), "mph"); +}); + +Deno.test("response: 24 hours from the current one, 7 days", () => { + const f = toForecast(RESPONSE, "metric"); + assertEquals(f.hours.length, 24); + assertEquals(f.hours[0].time, "2026-10-28T10:00"); + assertEquals(f.hours[23].time, "2026-10-29T09:00"); + assertEquals(f.hours[0].isDay, true); + assertEquals(f.hours[13].isDay, false); + assertEquals(f.days.length, 2); + assertEquals(f.days[1].min, -1.4); + assertEquals(f.now.temp, 12.4); + assertEquals(summary(f, paris), "12° Paris"); + assertEquals(shareText(f, paris), "Paris: 12° Partly cloudy, feels 10°, high 14° / low 7°"); + // Missing blocks don't throw. + const empty = toForecast({ current: {}, hourly: {}, daily: {} }, "metric"); + assertEquals([empty.hours.length, empty.days.length, empty.now.isDay], [0, 0, true]); +}); + +Deno.test("items: current conditions as stats, hours and days with glyphs", () => { + const f = toForecast(RESPONSE, "metric"); + const now = nowItem(f, paris, [], "Now"); + assertEquals(now.title, "12° Partly cloudy"); + assertEquals(now.icon, { source: "bs-cloud-sun", tint: "yellow" }); + assertEquals(now.detail?.stats?.map((s) => s.label), ["Temperature", "Feels like", "Wind", "Humidity"]); + assertEquals(now.detail?.stats?.map((s) => s.value), ["12°", "10°", "15 km/h", "81 %"]); + const labels = now.detail?.metadata?.map((m) => m.label) ?? []; + assertEquals(labels.includes("Sunrise") && labels.includes("UV index"), true); + + const night = hourItem(f.hours[12], f, paris, []); + assertEquals([night.title, night.icon], ["22:00", { source: "bs-moon", tint: "purple" }]); + const rainy = hourItem(f.hours[1], f, paris, []); + assertEquals(rainy.icon, { source: "bs-cloud-rain", tint: "blue" }); + assertEquals(rainy.accessories?.at(-1), { tag: "14°", color: "green" }); + + const storm = dayItem(f.days[1], f, paris, []); + assertEquals([storm.title, storm.subtitle], ["Tomorrow", "Thunderstorm"]); + assertEquals(storm.icon, { source: "bs-bolt", tint: "orange" }); + assertEquals(storm.accessories?.[0].icon, { source: "bs-droplet", tint: "blue" }); + assertEquals(storm.detail?.stats?.find((s) => s.label === "Low")?.color, "blue"); +}); + +Deno.test("imperial units", () => { + const f = toForecast(RESPONSE, "imperial"); + const now = nowItem(f, paris, []); + assertEquals(now.detail?.stats?.[2].value, "15 mph"); + // 12.4 °F is below freezing. + assertEquals(now.detail?.stats?.[0].color, "blue"); +}); From 63f0627db53e18d81740b79d1abf9f8285f633bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:01:07 +0000 Subject: [PATCH 05/10] Screenshot, Text from Screen, Color Picker, Display, Caffeine and Power Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/caffeine/backslash.toml | 49 ++ extensions/caffeine/caffeine.ts | 53 ++ extensions/caffeine/commands/caffeine.ts | 165 +++++ extensions/caffeine/commands/keep-awake.ts | 10 + extensions/caffeine/commands/toggle.ts | 13 + extensions/caffeine/lib.ts | 108 +++ extensions/caffeine/lib_test.ts | 80 +++ extensions/color-picker/backslash.toml | 58 ++ extensions/color-picker/commands/colors.ts | 96 +++ extensions/color-picker/commands/convert.ts | 61 ++ extensions/color-picker/commands/pick.ts | 14 + extensions/color-picker/lib.ts | 617 ++++++++++++++++++ extensions/color-picker/lib_test.ts | 232 +++++++ extensions/color-picker/system.ts | 106 +++ extensions/color-picker/ui.ts | 88 +++ extensions/display/backslash.toml | 105 +++ .../display/commands/brightness-down.ts | 4 + extensions/display/commands/brightness-up.ts | 4 + extensions/display/commands/display.ts | 292 +++++++++ extensions/display/commands/night-light.ts | 4 + extensions/display/commands/set-brightness.ts | 4 + extensions/display/lib.ts | 497 ++++++++++++++ extensions/display/lib_test.ts | 321 +++++++++ extensions/display/quick.ts | 35 + extensions/display/system.ts | 292 +++++++++ extensions/ocr/backslash.toml | 49 ++ extensions/ocr/commands/ocr.ts | 23 + extensions/ocr/commands/screen-text.ts | 207 ++++++ extensions/ocr/lib.ts | 285 ++++++++ extensions/ocr/lib_test.ts | 122 ++++ extensions/ocr/system.ts | 135 ++++ extensions/power/backslash.toml | 41 ++ extensions/power/commands/next-profile.ts | 11 + extensions/power/commands/power.ts | 226 +++++++ extensions/power/commands/set-profile.ts | 14 + extensions/power/lib.ts | 251 +++++++ extensions/power/lib_test.ts | 204 ++++++ extensions/power/system.ts | 63 ++ extensions/screenshot/backslash.toml | 116 ++++ extensions/screenshot/capture.ts | 34 + .../screenshot/commands/capture-area.ts | 4 + .../screenshot/commands/capture-screen.ts | 4 + .../screenshot/commands/capture-window.ts | 4 + extensions/screenshot/commands/record-area.ts | 4 + extensions/screenshot/commands/record.ts | 4 + extensions/screenshot/commands/screenshots.ts | 343 ++++++++++ extensions/screenshot/lib.ts | 392 +++++++++++ extensions/screenshot/lib_test.ts | 257 ++++++++ extensions/screenshot/system.ts | 339 ++++++++++ 49 files changed, 6440 insertions(+) create mode 100644 extensions/caffeine/backslash.toml create mode 100644 extensions/caffeine/caffeine.ts create mode 100644 extensions/caffeine/commands/caffeine.ts create mode 100644 extensions/caffeine/commands/keep-awake.ts create mode 100644 extensions/caffeine/commands/toggle.ts create mode 100644 extensions/caffeine/lib.ts create mode 100644 extensions/caffeine/lib_test.ts create mode 100644 extensions/color-picker/backslash.toml create mode 100644 extensions/color-picker/commands/colors.ts create mode 100644 extensions/color-picker/commands/convert.ts create mode 100644 extensions/color-picker/commands/pick.ts create mode 100644 extensions/color-picker/lib.ts create mode 100644 extensions/color-picker/lib_test.ts create mode 100644 extensions/color-picker/system.ts create mode 100644 extensions/color-picker/ui.ts create mode 100644 extensions/display/backslash.toml create mode 100644 extensions/display/commands/brightness-down.ts create mode 100644 extensions/display/commands/brightness-up.ts create mode 100644 extensions/display/commands/display.ts create mode 100644 extensions/display/commands/night-light.ts create mode 100644 extensions/display/commands/set-brightness.ts create mode 100644 extensions/display/lib.ts create mode 100644 extensions/display/lib_test.ts create mode 100644 extensions/display/quick.ts create mode 100644 extensions/display/system.ts create mode 100644 extensions/ocr/backslash.toml create mode 100644 extensions/ocr/commands/ocr.ts create mode 100644 extensions/ocr/commands/screen-text.ts create mode 100644 extensions/ocr/lib.ts create mode 100644 extensions/ocr/lib_test.ts create mode 100644 extensions/ocr/system.ts create mode 100644 extensions/power/backslash.toml create mode 100644 extensions/power/commands/next-profile.ts create mode 100644 extensions/power/commands/power.ts create mode 100644 extensions/power/commands/set-profile.ts create mode 100644 extensions/power/lib.ts create mode 100644 extensions/power/lib_test.ts create mode 100644 extensions/power/system.ts create mode 100644 extensions/screenshot/backslash.toml create mode 100644 extensions/screenshot/capture.ts create mode 100644 extensions/screenshot/commands/capture-area.ts create mode 100644 extensions/screenshot/commands/capture-screen.ts create mode 100644 extensions/screenshot/commands/capture-window.ts create mode 100644 extensions/screenshot/commands/record-area.ts create mode 100644 extensions/screenshot/commands/record.ts create mode 100644 extensions/screenshot/commands/screenshots.ts create mode 100644 extensions/screenshot/lib.ts create mode 100644 extensions/screenshot/lib_test.ts create mode 100644 extensions/screenshot/system.ts diff --git a/extensions/caffeine/backslash.toml b/extensions/caffeine/backslash.toml new file mode 100644 index 0000000..f5a2273 --- /dev/null +++ b/extensions/caffeine/backslash.toml @@ -0,0 +1,49 @@ +[extension] +id = "caffeine" +title = "Caffeine" +description = "Keep the computer awake for a while, or until you turn it off, even with Backslash closed" +version = "1.0.0" +author = "Backslash" +icon = "bs-coffee" +license = "MIT" + +# A transient systemd user unit (systemd-run) holds a systemd-inhibit lock on +# idle and sleep, plus GNOME's or KDE's own inhibitor when available, so it +# survives the launcher; systemctl stops it. systemd-inhibit --list shows the +# other programs keeping the computer awake. +[permissions] +run = ["systemd-run", "systemctl", "systemd-inhibit"] +env = ["XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "caffeine" +title = "Caffeine" +description = "Keep the computer awake: 15 min, 30 min, 1 h, 2 h or until turned off" +icon = "bs-coffee" +keywords = ["caffeine", "awake", "keep awake", "sleep", "veille", "mise en veille", "réveil", "café", "inhibit", "amphetamine", "insomnia"] + +[[commands]] +name = "toggle" +title = "Toggle Caffeine" +description = "Keep awake, or let the computer sleep again" +icon = "bs-coffee" +mode = "no-view" +keywords = ["caffeine", "awake", "sleep", "veille", "café", "inhibit"] + +[[commands]] +name = "keep-awake" +title = "Keep Awake For…" +description = "e.g. 45m, 1h30, 3h" +icon = "bs-timer" +mode = "no-view" +keywords = ["caffeine", "awake", "veille", "durée", "duration"] +[[commands.arguments]] +name = "duration" +placeholder = "45m, 1h30, 3h" +required = true + +[[preferences]] +name = "duration" +title = "Toggle Caffeine keeps awake for" +description = "A duration like 1h or 30m; empty means until turned off" +default = "" diff --git a/extensions/caffeine/caffeine.ts b/extensions/caffeine/caffeine.ts new file mode 100644 index 0000000..0dc32fc --- /dev/null +++ b/extensions/caffeine/caffeine.ts @@ -0,0 +1,53 @@ +// The running inhibitor: a transient systemd user unit, so it survives the launcher. + +import { exec, storage } from "@backslash/api"; +import { type Session, systemdRunArgs, UNIT } from "./lib.ts"; + +const desktop = () => { + try { + return Deno.env.get("XDG_CURRENT_DESKTOP"); + } catch { + return undefined; + } +}; + +async function active(): Promise { + try { + const state = await exec("systemctl", ["--user", "is-active", UNIT]); + return state === "active" || state === "activating"; + } catch { + return false; // is-active exits non-zero when inactive + } +} + +/** The current session, if the computer is kept awake. */ +export async function current(): Promise { + const saved = (await storage.get("session")) as unknown as Session | undefined; + if (!(await active())) { + if (saved) await storage.delete("session"); + return undefined; + } + if (saved && (saved.until === null || saved.until > Date.now())) return saved; + // Started elsewhere, or our record is gone: awake, end unknown. + return { started: Date.now(), until: null }; +} + +export async function start(seconds?: number): Promise { + await stop(); + try { + await exec("systemd-run", systemdRunArgs(seconds, desktop())); + } catch (e) { + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: Caffeine runs with systemd-run --user"); + } + throw e; + } + const session = { started: Date.now(), until: seconds ? Date.now() + seconds * 1000 : null }; + await storage.set("session", session as any); + return session; +} + +export async function stop(): Promise { + await exec("systemctl", ["--user", "stop", UNIT]).catch(() => {}); + await storage.delete("session"); +} diff --git a/extensions/caffeine/commands/caffeine.ts b/extensions/caffeine/commands/caffeine.ts new file mode 100644 index 0000000..d2ed48b --- /dev/null +++ b/extensions/caffeine/commands/caffeine.ts @@ -0,0 +1,165 @@ +import { + action, + close, + type Color, + exec, + field, + form, + Icons, + type Item, + list, + meta, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { countdown, humanize, parseDuration, parseInhibitors, type Session, subtitle } from "../lib.ts"; +import { current, start, stop } from "../caffeine.ts"; + +const PRESETS: [string, number | undefined, Color][] = [ + ["15 Minutes", 15 * 60, "teal"], + ["30 Minutes", 30 * 60, "green"], + ["1 Hour", 60 * 60, "yellow"], + ["2 Hours", 2 * 60 * 60, "orange"], + ["Until Turned Off", undefined, "red"], +]; + +const clockTime = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + +const begin = (seconds: number | undefined) => async () => { + await start(seconds); + return close(seconds ? `Awake for ${humanize(seconds)}` : "Awake until turned off"); +}; + +const custom = form({ + title: "Keep Awake", + submitTitle: "Start", + fields: [field.text("duration", "For how long", { placeholder: "45m, 1h30, 3h", required: true })], + async submit(values) { + const seconds = parseDuration(String(values.duration)); + if (!seconds) { + await toast("Not a duration: try 45m, 1h30 or 3h", { style: "failure" }); + return; + } + return begin(seconds)(); + }, +}); + +const PREVENTS = meta.tags("Prevents", [ + { text: "Screen dimming", color: "blue" }, + { text: "Screen lock", color: "blue" }, + { text: "Sleep", color: "blue" }, +]); + +function awakeItem(s: Session, others: string[]): Item { + const left = s.until === null ? undefined : Math.max(0, (s.until - Date.now()) / 1000); + const total = s.until === null ? undefined : (s.until - s.started) / 1000; + const extend = (minutes: number) => + action( + `${minutes >= 60 ? `${minutes / 60} Hour${minutes > 60 ? "s" : ""}` : `${minutes} Minutes`} More`, + async () => { + await start((left ?? 0) + minutes * 60); + return refresh(); + }, + { icon: Icons.plus }, + ); + return { + id: "awake", + title: s.until === null ? "Awake until turned off" : `Awake until ${clockTime(s.until)}`, + subtitle: left === undefined ? "Keeping the computer awake" : `${humanize(left)} left`, + icon: { source: Icons.coffee, tint: "orange" }, + section: "Now", + accessories: [left === undefined ? { tag: "On", color: "orange" } : { tag: countdown(left), color: "orange" }], + detail: { + stats: [ + { + label: left === undefined ? "awake" : "left", + value: left === undefined ? "∞" : countdown(left), + color: "orange", + }, + { label: "since", value: clockTime(s.started) }, + ...(s.until !== null ? [{ label: "until", value: clockTime(s.until) }] : []), + ], + metadata: [ + ...(left !== undefined && total ? [meta.progress("Elapsed", 1 - left / total)] : []), + PREVENTS, + meta.text("Keeps running", "Even when Backslash is closed (systemd user unit)", { icon: Icons.info }), + ...(others.length ? [meta.tags("Also keeping awake", others)] : []), + ], + }, + actions: [ + action("Turn Off", async () => { + await stop(); + await updateCommandMetadata({ subtitle: null }).catch(() => {}); + return close("Caffeine off: the computer may sleep again"); + }, { icon: Icons.stop, destructive: true, shortcut: "k" }), + ...(left !== undefined + ? [ + submenu("Extend", [extend(15), extend(30), extend(60)], { icon: Icons.plus, shortcut: "e" }), + action("Keep Awake Until Turned Off", begin(undefined), { icon: Icons.coffee, section: "Change" }), + ] + : []), + ], + }; +} + +function presetItems(s: Session | undefined): Item[] { + return [ + ...PRESETS.map(([title, seconds, tint]) => ({ + id: `preset:${title}`, + title, + subtitle: seconds ? `until ${clockTime(Date.now() + seconds * 1000)}` : "until you turn it off", + icon: { source: seconds ? Icons.timer : Icons.coffee, tint }, + section: s ? "Change" : "Keep Awake", + keywords: ["awake", "réveillé", "veille", "sleep", "inhibit", "caffeine", "café"], + detail: { + stats: [{ label: "for", value: seconds ? humanize(seconds) : "∞" }], + markdown: s ? "Replaces the current session." : undefined, + metadata: [ + ...(seconds ? [meta.text("Ends at", clockTime(Date.now() + seconds * 1000), { icon: Icons.clock })] : []), + PREVENTS, + ], + }, + actions: [ + action(seconds ? `Keep Awake for ${humanize(seconds)}` : "Keep Awake", begin(seconds), { icon: Icons.coffee }), + ], + })), + { + id: "custom", + title: "Custom Duration…", + subtitle: "45m, 1h30, 3h", + icon: { source: Icons.plus, tint: "gray" }, + section: s ? "Change" : "Keep Awake", + actions: [action("Custom Duration…", () => custom, { icon: Icons.plus })], + }, + ]; +} + +/** Other programs holding an idle or sleep inhibitor (a video, a download…). */ +async function otherInhibitors(): Promise { + const out = await exec("systemd-inhibit", ["--list", "--no-legend", "--no-pager"]).catch(() => ""); + return [...new Set(parseInhibitors(out).map((i) => i.who))]; +} + +export default list({ + placeholder: "Keep the computer awake…", + empty: { title: "Caffeine", icon: Icons.coffee }, + status: (items) => items.find((i) => i.id === "awake")?.subtitle ?? "The computer sleeps as usual", + // Live while awake: the countdown ticks every second. + async *items({ signal }) { + const others = await otherInhibitors(); + let last: string | null | undefined; + while (!signal.aborted) { + const s = await current(); + const sub = subtitle(s); + if (sub !== last) { + last = sub; + updateCommandMetadata({ subtitle: sub }).catch(() => {}); + } + yield [...(s ? [awakeItem(s, others)] : []), ...presetItems(s)]; + if (!s) return; + await new Promise((r) => setTimeout(r, 1000)); + } + }, +}); diff --git a/extensions/caffeine/commands/keep-awake.ts b/extensions/caffeine/commands/keep-awake.ts new file mode 100644 index 0000000..1968e2a --- /dev/null +++ b/extensions/caffeine/commands/keep-awake.ts @@ -0,0 +1,10 @@ +import { run } from "@backslash/api"; +import { humanize, parseDuration } from "../lib.ts"; +import { start } from "../caffeine.ts"; + +export default run<{ duration: string }>(async ({ args }) => { + const seconds = parseDuration(args.duration); + if (!seconds) throw new Error(`“${args.duration}” is not a duration: try 45m, 1h30 or 3h`); + await start(seconds); + return `Awake for ${humanize(seconds)}`; +}); diff --git a/extensions/caffeine/commands/toggle.ts b/extensions/caffeine/commands/toggle.ts new file mode 100644 index 0000000..3d4e8c8 --- /dev/null +++ b/extensions/caffeine/commands/toggle.ts @@ -0,0 +1,13 @@ +import { run } from "@backslash/api"; +import { humanize, parseDuration } from "../lib.ts"; +import { current, start, stop } from "../caffeine.ts"; + +export default run(async ({ preferences }) => { + if (await current()) { + await stop(); + return "Caffeine off"; + } + const seconds = parseDuration(String(preferences.duration ?? "")); + await start(seconds); + return seconds ? `Awake for ${humanize(seconds)}` : "Awake until turned off"; +}); diff --git a/extensions/caffeine/lib.ts b/extensions/caffeine/lib.ts new file mode 100644 index 0000000..088382f --- /dev/null +++ b/extensions/caffeine/lib.ts @@ -0,0 +1,108 @@ +// Durations and the inhibitor's command line. Pure, tested in lib_test.ts. + +/** + * "25m", "1h30", "1h 30m", "90s", "10:00", "2" (hours?) → seconds. + * A bare number is minutes. Undefined when it can't be read. + */ +export function parseDuration(text: string): number | undefined { + const t = text.trim().toLowerCase().replace(/,/g, "."); + if (!t) return undefined; + if (/^\d+(\.\d+)?$/.test(t)) return Number(t) > 0 ? Math.round(Number(t) * 60) : undefined; + const clock = t.match(/^(\d+):(\d{1,2})$/); + if (clock) return Number(clock[1]) * 3600 + Number(clock[2]) * 60 || undefined; + let total = 0; + let matched = ""; + const unit = /(\d+(?:\.\d+)?)\s*(hours?|heures?|hrs|hr|h|minutes?|mins|min|mn|m|secondes?|seconds?|secs|sec|s)?/g; + for (const m of t.matchAll(unit)) { + if (!m[0].trim()) continue; + matched += m[0]; + const n = Number(m[1]); + const u = m[2] ?? ""; + if (u.startsWith("h")) total += n * 3600; + else if (u.startsWith("s")) total += n; + else total += n * 60; // "1h30": a bare number after hours is minutes + } + if (matched.replace(/\s/g, "") !== t.replace(/\s/g, "")) return undefined; + return total > 0 ? Math.round(total) : undefined; +} + +/** 1500 → "25 min", 5400 → "1 h 30", 7200 → "2 h", 45 → "1 min" (rounded up to the minute). */ +export function humanize(seconds: number): string { + const total = Math.max(1, Math.ceil(seconds / 60)); + const h = Math.floor(total / 60); + const m = total % 60; + if (h) return m ? `${h} h ${String(m).padStart(2, "0")}` : `${h} h`; + return `${m} min`; +} + +/** Remaining time as a clock: "24:59", "1:02:03". */ +export function countdown(seconds: number): string { + const s = Math.max(0, Math.round(seconds)); + const h = Math.floor(s / 3600); + const mm = String(Math.floor((s % 3600) / 60)).padStart(h ? 2 : 1, "0"); + const ss = String(s % 60).padStart(2, "0"); + return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; +} + +export interface Session { + /** Unix ms. */ + started: number; + /** Unix ms; null until turned off. */ + until: number | null; +} + +/** The root search line: "Awake for 23 min", "Awake until turned off". */ +export function subtitle(s: Session | undefined, now = Date.now()): string | null { + if (!s) return null; + return s.until === null ? "Awake until turned off" : `Awake for ${humanize((s.until - now) / 1000)}`; +} + +/** Single-quotes a string for sh. */ +export const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +export const UNIT = "backslash-caffeine"; + +/** + * The unit's script: systemd-inhibit holds idle and sleep for as long as + * `sleep` runs. GNOME and KDE also get their own session inhibitor, which + * their screen blanking listens to. + */ +export function inhibitScript(seconds: number | undefined, why = "Caffeine: keeping the computer awake"): string { + const sleep = seconds ? `sleep ${Math.round(seconds)}` : "sleep infinity"; + return [ + `set -- systemd-inhibit --what=idle:sleep --who=Backslash --why=${q(why)} --mode=block ${sleep}`, + `case "\${XDG_CURRENT_DESKTOP:-}" in`, + ` *GNOME*) command -v gnome-session-inhibit >/dev/null 2>&1 && set -- gnome-session-inhibit --inhibit idle:suspend --reason ${ + q(why) + } "$@" ;;`, + ` *KDE*) command -v kde-inhibit >/dev/null 2>&1 && set -- kde-inhibit --power --screenSaver "$@" ;;`, + "esac", + `exec "$@"`, + ].join("\n"); +} + +export function systemdRunArgs(seconds: number | undefined, desktop?: string): string[] { + return [ + "--user", + "--quiet", + `--unit=${UNIT}`, + `--description=Backslash: Caffeine${seconds ? ` (${humanize(seconds)})` : ""}`, + "--collect", + ...(desktop ? [`--setenv=XDG_CURRENT_DESKTOP=${desktop}`] : []), + "/bin/sh", + "-c", + inhibitScript(seconds), + ]; +} + +/** `systemd-inhibit --list --no-legend --no-pager`: other programs keeping the computer awake. */ +export function parseInhibitors(out: string): { who: string; what: string; why: string }[] { + return out.split("\n").flatMap((line) => { + // WHO UID USER PID COMM WHAT WHY MODE; WHO and WHY may contain spaces. + const m = line.trim().match(/^(.+?)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*?)\s+(block|delay)$/); + if (!m) return []; + const [, who, , , , , what, why, mode] = m; + if (mode !== "block" || who === "Backslash" || !/idle|sleep/.test(what)) return []; + return [{ who, what, why }]; + }); +} diff --git a/extensions/caffeine/lib_test.ts b/extensions/caffeine/lib_test.ts new file mode 100644 index 0000000..a8f50d0 --- /dev/null +++ b/extensions/caffeine/lib_test.ts @@ -0,0 +1,80 @@ +import { assert, assertEquals } from "@std/assert"; +import { countdown, humanize, inhibitScript, parseDuration, parseInhibitors, subtitle, systemdRunArgs } from "./lib.ts"; + +Deno.test("durations", () => { + const cases: [string, number | undefined][] = [ + ["15", 900], + ["15m", 900], + ["15 min", 900], + ["15mn", 900], + ["1h", 3600], + ["1h30", 5400], + ["1h 30m", 5400], + ["2 heures", 7200], + ["1,5h", 5400], + ["2:30", 9000], + ["90s", 90], + ["later", undefined], + ["0", undefined], + ["", undefined], + ]; + for (const [text, seconds] of cases) assertEquals(parseDuration(text), seconds, text); +}); + +Deno.test("formatting", () => { + assertEquals([humanize(900), humanize(5400), humanize(7200), humanize(45), humanize(1381)], [ + "15 min", + "1 h 30", + "2 h", + "1 min", + "24 min", + ]); + assertEquals([countdown(1499), countdown(3723), countdown(-1)], ["24:59", "1:02:03", "0:00"]); + const now = 1_000_000; + assertEquals(subtitle({ started: now, until: now + 23 * 60_000 }, now), "Awake for 23 min"); + assertEquals(subtitle({ started: now, until: now + 22.5 * 60_000 }, now), "Awake for 23 min"); + assertEquals(subtitle({ started: now, until: null }, now), "Awake until turned off"); + assertEquals(subtitle(undefined), null); +}); + +Deno.test("the unit holds an inhibitor for as long as sleep runs", () => { + const args = systemdRunArgs(1800, "GNOME"); + assertEquals(args.slice(0, 6), [ + "--user", + "--quiet", + "--unit=backslash-caffeine", + "--description=Backslash: Caffeine (30 min)", + "--collect", + "--setenv=XDG_CURRENT_DESKTOP=GNOME", + ]); + assertEquals(args.slice(6, 8), ["/bin/sh", "-c"]); + const script = args[8]; + assert(script.startsWith("set -- systemd-inhibit --what=idle:sleep --who=Backslash")); + assert(script.includes("--mode=block sleep 1800")); + assert(script.includes("gnome-session-inhibit --inhibit idle:suspend")); + assert(script.endsWith(`exec "$@"`)); + assert(inhibitScript(undefined).includes("sleep infinity")); + assert(inhibitScript(60, "it's").includes(`--why='it'\\''s'`)); + assertEquals(systemdRunArgs(undefined)[3], "--description=Backslash: Caffeine"); +}); + +Deno.test("other inhibitors", () => { + const out = [ + "ModemManager 0 root 1043 ModemManager sleep ModemManager needs to reset devices delay", + "Firefox 1000 ada 4242 firefox idle Playing video block", + "Backslash 1000 ada 5000 systemd-inhibit idle:sleep Caffeine: keeping the computer awake block", + "GNOME Shell 1000 ada 2100 gnome-shell handle-power-key GNOME handling keypresses block", + ].join("\n"); + assertEquals(parseInhibitors(out), [{ who: "Firefox", what: "idle", why: "Playing video" }]); +}); + +Deno.test("the unit script is valid sh", async () => { + for (const seconds of [900, undefined]) { + const { success, stderr } = await new Deno.Command("sh", { + args: ["-n", "-c", inhibitScript(seconds)], + stderr: "piped", + }) + .output(); + assert(success, new TextDecoder().decode(stderr)); + } +}); diff --git a/extensions/color-picker/backslash.toml b/extensions/color-picker/backslash.toml new file mode 100644 index 0000000..dfbcaa3 --- /dev/null +++ b/extensions/color-picker/backslash.toml @@ -0,0 +1,58 @@ +[extension] +id = "color-picker" +title = "Color Picker" +description = "Pick a color anywhere on the screen, keep a history, and convert colors between HEX, RGB, HSL and OKLCH" +version = "1.0.0" +author = "Backslash" +icon = "bs-pipette" +license = "MIT" + +# Picking runs in the background (systemd-run) once the launcher is hidden, +# with the first picker installed: hyprpicker or grim + slurp (Hyprland, Sway, +# wlroots), xdg-desktop-portal's PickColor through gdbus (GNOME, KDE), xcolor +# or Gpick (X11). Here they are only started to check that they are installed. +# Picked colors are appended to ~/.cache/backslash/color-picker/picks, then +# moved to the extension's storage. +[permissions] +run = ["systemd-run", "hyprpicker", "grim", "slurp", "gpick", "xcolor", "gdbus"] +read = ["~/.cache/backslash/color-picker", "/etc/os-release"] +write = ["~/.cache/backslash/color-picker"] +env = [ + "HOME", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] + +[[commands]] +name = "pick" +title = "Pick Color" +description = "Click anywhere on the screen: the color is copied" +icon = "bs-pipette" +mode = "no-view" +keywords = ["color", "colour", "couleur", "pipette", "eyedropper", "picker", "pick", "hex", "screen", "écran"] + +[[commands]] +name = "colors" +title = "Color History" +description = "Colors you picked, in every format" +icon = "bs-palette" +keywords = ["color", "colour", "couleur", "history", "historique", "palette", "hex", "rgb", "hsl"] + +[[commands]] +name = "convert" +title = "Convert Color" +description = "Type any color to get its HEX, RGB, HSL, OKLCH and more" +icon = "bs-palette" +keywords = ["color", "couleur", "convert", "convertir", "hex", "rgb", "hsl", "oklch", "css", "contrast", "contraste"] + +[[preferences]] +name = "uppercase" +title = "Uppercase HEX" +description = "#FF6347 instead of #ff6347" +type = "boolean" +default = false diff --git a/extensions/color-picker/commands/colors.ts b/extensions/color-picker/commands/colors.ts new file mode 100644 index 0000000..8558ac8 --- /dev/null +++ b/extensions/color-picker/commands/colors.ts @@ -0,0 +1,96 @@ +import { action, Icons, type Item, list, meta, refresh } from "@backslash/api"; +import { + describeName, + type Family, + FAMILY_NAMES, + parseColor, + type Picked, + PICKER_NAMES, + pickerInstall, + toHex, +} from "../lib.ts"; +import { family, history, picker, saveHistory } from "../system.ts"; +import { colorActions, colorMetadata, pickAction, swatch } from "../ui.ts"; + +const when = (ms: number) => new Date(ms).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); + +function colorItem(p: Picked, upper: boolean): Item | undefined { + const c = parseColor(p.hex); + if (!c) return undefined; + const hex = toHex(c, { upper }); + return { + id: p.hex, + title: hex, + subtitle: describeName(c), + icon: swatch(c), + section: "History", + keywords: [describeName(c).replace("≈ ", ""), p.hex.slice(1)], + accessories: [{ + text: new Date(p.at).toLocaleDateString([], { month: "short", day: "numeric" }), + tooltip: when(p.at), + }], + detail: { metadata: colorMetadata(c, upper, [meta.text("Picked", when(p.at), { icon: Icons.clock })]) }, + actions: [ + ...colorActions(c, upper), + pickAction(upper), + action("Remove from History", async () => { + await saveHistory((await history()).filter((h) => h.hex !== p.hex)); + return refresh(); + }, { icon: Icons.trash, destructive: true, shortcut: "Delete", section: "Danger" }), + action("Clear History", async () => { + await saveHistory([]); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: "Forget every picked color?", section: "Danger" }), + ], + }; +} + +function pickItem(tool: string | undefined, install: string, fam: Family | undefined, upper: boolean): Item { + if (!tool) { + return { + id: "missing", + title: "No color picker found", + subtitle: "install one to pick from the screen", + icon: { source: Icons.alert, tint: "orange" }, + section: "Pick", + detail: { + markdown: [ + "Backslash picks colors with **hyprpicker** or **grim + slurp** (Hyprland, Sway, wlroots), the **desktop portal** (GNOME, KDE), or **xcolor** / **Gpick** (X11).", + `**${FAMILY_NAMES[fam ?? "debian"]}**\n\n\`${install}\``, + "You can still convert colors with **Convert Color**.", + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; + } + return { + id: "pick", + title: "Pick Color from Screen", + subtitle: "copied and kept here", + icon: { source: Icons.pipette, tint: "pink" }, + section: "Pick", + keywords: ["pipette", "eyedropper", "pipette", "couleur"], + detail: { + markdown: "Click anywhere on the screen: the color is copied as hex and added to this history.", + metadata: [meta.text("Picker", tool, { icon: Icons.pipette })], + }, + actions: [pickAction(upper)], + }; +} + +export default list({ + placeholder: "Search picked colors…", + empty: { title: "No colors yet", description: "Pick a color from the screen", icon: Icons.palette }, + status: (items) => { + const n = items.filter((i) => i.section === "History").length; + return n ? `${n} color${n > 1 ? "s" : ""}` : "Pick a color to start your history"; + }, + items: async ({ preferences }) => { + const upper = !!preferences.uppercase; + const [p, fam, colors] = await Promise.all([picker(), family(), history()]); + return [ + pickItem(p.tool && PICKER_NAMES[p.tool], pickerInstall(p.session, fam ?? "debian"), fam, upper), + ...colors.map((c) => colorItem(c, upper)).filter((i): i is Item => !!i), + ]; + }, +}); diff --git a/extensions/color-picker/commands/convert.ts b/extensions/color-picker/commands/convert.ts new file mode 100644 index 0000000..e882b12 --- /dev/null +++ b/extensions/color-picker/commands/convert.ts @@ -0,0 +1,61 @@ +import { action, copy, Icons, type Item, list, meta, paste, toast } from "@backslash/api"; +import { describeName, formats, parseColor, type RGB, toHex } from "../lib.ts"; +import { history, remember } from "../system.ts"; +import { colorActions, colorMetadata, swatch } from "../ui.ts"; + +const save = (c: RGB) => + action("Save to History", async () => { + await remember(toHex({ ...c, a: 1 })); + await toast("Saved to Color History", { style: "success" }); + }, { icon: Icons.history, shortcut: "s", section: "History" }); + +function formatItems(c: RGB, upper: boolean): Item[] { + const hex = toHex(c, { upper }); + return formats(c, { upper }).map((f) => ({ + id: f.id, + title: f.value, + subtitle: f.title, + icon: swatch(c), + section: describeName(c), + detail: { metadata: colorMetadata(c, upper) }, + actions: [ + copy(f.value, `Copy ${f.title}`), + paste(f.value, `Paste ${f.title}`), + ...colorActions(c, upper).slice(2), + save(c), + ], + keywords: [hex], + })); +} + +export default list({ + placeholder: "Type a color: #ff6347, rgb(255 99 71), hsl(9 100% 64%), oklch(70% 0.2 32), tomato…", + empty: { + title: "Not a color I can read", + description: + "Try #ff6347, rgb(255, 99, 71), hsl(9, 100%, 64%), hwb(9 28% 0%), oklch(69.6% 0.196 32.3) or a CSS name", + icon: Icons.palette, + }, + status: "HEX · RGB · HSL · OKLCH · HSV · HWB · CSS names", + async search(query, { preferences }) { + const upper = !!preferences.uppercase; + const c = parseColor(query); + if (c) return formatItems(c, upper); + if (query.trim()) return []; + // Nothing typed yet: the recent picks, to convert in one keystroke. + return (await history()).slice(0, 8).flatMap((p) => { + const rc = parseColor(p.hex); + return rc + ? [{ + id: p.hex, + title: toHex(rc, { upper }), + subtitle: describeName(rc), + icon: swatch(rc), + section: "Recent Colors", + detail: { metadata: colorMetadata(rc, upper, [meta.text("Tip", "Type any color to convert it")]) }, + actions: colorActions(rc, upper), + }] + : []; + }); + }, +}); diff --git a/extensions/color-picker/commands/pick.ts b/extensions/color-picker/commands/pick.ts new file mode 100644 index 0000000..a530cea --- /dev/null +++ b/extensions/color-picker/commands/pick.ts @@ -0,0 +1,14 @@ +import { run } from "@backslash/api"; +import { pickerInstall } from "../lib.ts"; +import { family, Missing, pick, session } from "../system.ts"; + +// Hides the launcher right away; the color lands in the clipboard and in +// Color History. +export default run(async ({ preferences }) => { + try { + await pick({ upper: !!preferences.uppercase }); + } catch (e) { + if (!(e instanceof Missing)) throw e; + throw new Error(`${e.message}. Install one: ${pickerInstall(session(), (await family()) ?? "debian")}`); + } +}); diff --git a/extensions/color-picker/lib.ts b/extensions/color-picker/lib.ts new file mode 100644 index 0000000..582cde3 --- /dev/null +++ b/extensions/color-picker/lib.ts @@ -0,0 +1,617 @@ +// Color math: parsing any CSS-ish color, converting between sRGB, HSL, HSV, +// HWB and OKLCH, names, contrast; plus the picker's background script. +// Pure, tested in lib_test.ts. + +/** sRGB, channels 0–255 (not rounded), alpha 0–1. */ +export interface RGB { + r: number; + g: number; + b: number; + a: number; +} + +const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v)); +const round = (v: number, digits = 0) => { + const f = 10 ** digits; + const r = Math.round(v * f) / f; + return Object.is(r, -0) ? 0 : r; +}; +/** A number without trailing zeros: 0.5, 32.3, 100. */ +const num = (v: number, digits = 0) => String(round(v, digits)); + +// --------------------------------------------------------------------------- +// Named colors (CSS Color Module Level 4) + +const CSS_NAMES = `aliceblue f0f8ff antiquewhite faebd7 aqua 00ffff aquamarine 7fffd4 azure f0ffff beige f5f5dc +bisque ffe4c4 black 000000 blanchedalmond ffebcd blue 0000ff blueviolet 8a2be2 brown a52a2a burlywood deb887 +cadetblue 5f9ea0 chartreuse 7fff00 chocolate d2691e coral ff7f50 cornflowerblue 6495ed cornsilk fff8dc crimson dc143c +cyan 00ffff darkblue 00008b darkcyan 008b8b darkgoldenrod b8860b darkgray a9a9a9 darkgreen 006400 darkgrey a9a9a9 +darkkhaki bdb76b darkmagenta 8b008b darkolivegreen 556b2f darkorange ff8c00 darkorchid 9932cc darkred 8b0000 +darksalmon e9967a darkseagreen 8fbc8f darkslateblue 483d8b darkslategray 2f4f4f darkslategrey 2f4f4f +darkturquoise 00ced1 darkviolet 9400d3 deeppink ff1493 deepskyblue 00bfff dimgray 696969 dimgrey 696969 +dodgerblue 1e90ff firebrick b22222 floralwhite fffaf0 forestgreen 228b22 fuchsia ff00ff gainsboro dcdcdc +ghostwhite f8f8ff gold ffd700 goldenrod daa520 gray 808080 green 008000 greenyellow adff2f grey 808080 +honeydew f0fff0 hotpink ff69b4 indianred cd5c5c indigo 4b0082 ivory fffff0 khaki f0e68c lavender e6e6fa +lavenderblush fff0f5 lawngreen 7cfc00 lemonchiffon fffacd lightblue add8e6 lightcoral f08080 lightcyan e0ffff +lightgoldenrodyellow fafad2 lightgray d3d3d3 lightgreen 90ee90 lightgrey d3d3d3 lightpink ffb6c1 lightsalmon ffa07a +lightseagreen 20b2aa lightskyblue 87cefa lightslategray 778899 lightslategrey 778899 lightsteelblue b0c4de +lightyellow ffffe0 lime 00ff00 limegreen 32cd32 linen faf0e6 magenta ff00ff maroon 800000 mediumaquamarine 66cdaa +mediumblue 0000cd mediumorchid ba55d3 mediumpurple 9370db mediumseagreen 3cb371 mediumslateblue 7b68ee +mediumspringgreen 00fa9a mediumturquoise 48d1cc mediumvioletred c71585 midnightblue 191970 mintcream f5fffa +mistyrose ffe4e1 moccasin ffe4b5 navajowhite ffdead navy 000080 oldlace fdf5e6 olive 808000 olivedrab 6b8e23 +orange ffa500 orangered ff4500 orchid da70d6 palegoldenrod eee8aa palegreen 98fb98 paleturquoise afeeee +palevioletred db7093 papayawhip ffefd5 peachpuff ffdab9 peru cd853f pink ffc0cb plum dda0dd powderblue b0e0e6 +purple 800080 rebeccapurple 663399 red ff0000 rosybrown bc8f8f royalblue 4169e1 saddlebrown 8b4513 salmon fa8072 +sandybrown f4a460 seagreen 2e8b57 seashell fff5ee sienna a0522d silver c0c0c0 skyblue 87ceeb slateblue 6a5acd +slategray 708090 slategrey 708090 snow fffafa springgreen 00ff7f steelblue 4682b4 tan d2b48c teal 008080 +thistle d8bfd8 tomato ff6347 turquoise 40e0d0 violet ee82ee wheat f5deb3 white ffffff whitesmoke f5f5f5 +yellow ffff00 yellowgreen 9acd32`; + +export const NAMED: Record = Object.fromEntries( + CSS_NAMES.split(/\s+/).reduce<[string, string][]>((acc, w, i, all) => { + if (i % 2 === 0) acc.push([w, `#${all[i + 1]}`]); + return acc; + }, []), +); + +/** A few everyday French names, mapped to CSS ones. */ +const FRENCH: Record = { + rouge: "red", + vert: "green", + bleu: "blue", + jaune: "yellow", + noir: "black", + blanc: "white", + gris: "gray", + rose: "pink", + marron: "brown", + violet: "violet", + argent: "silver", + "or": "gold", + "bleu marine": "navy", + turquoise: "turquoise", +}; + +/** Hex → its CSS name, the first spelling (gray before grey). */ +const BY_HEX: Record = Object.entries(NAMED).reduce>((acc, [name, hex]) => { + acc[hex] ??= name; + return acc; +}, {}); + +// --------------------------------------------------------------------------- +// Conversions + +const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const fromLinear = (c: number) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055); + +export interface OKLab { + l: number; + a: number; + b: number; +} +export interface OKLCH { + l: number; + c: number; + h: number; +} + +/** sRGB → OKLab (Björn Ottosson). */ +export function rgbToOklab({ r, g, b }: RGB): OKLab { + const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + return { + l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + }; +} + +/** OKLab → sRGB, clipped to the sRGB gamut. */ +export function oklabToRgb({ l: L, a, b }: OKLab, alpha = 1): RGB { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + const out = [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ].map((v) => clamp(fromLinear(v)) * 255); + return { r: out[0], g: out[1], b: out[2], a: alpha }; +} + +export function rgbToOklch(c: RGB): OKLCH { + const { l, a, b } = rgbToOklab(c); + const chroma = Math.hypot(a, b); + const h = chroma < 1e-4 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360; + return { l, c: chroma, h }; +} + +export const oklchToRgb = ({ l, c, h }: OKLCH, alpha = 1): RGB => + oklabToRgb({ l, a: c * Math.cos(h * Math.PI / 180), b: c * Math.sin(h * Math.PI / 180) }, alpha); + +/** Hue (degrees), and max/min channels, shared by HSL, HSV and HWB. */ +function hue({ r, g, b }: RGB): { h: number; max: number; min: number } { + const [R, G, B] = [r / 255, g / 255, b / 255]; + const max = Math.max(R, G, B); + const min = Math.min(R, G, B); + const d = max - min; + let h = 0; + if (d > 1e-9) { + if (max === R) h = ((G - B) / d) % 6; + else if (max === G) h = (B - R) / d + 2; + else h = (R - G) / d + 4; + } + return { h: (h * 60 + 360) % 360, max, min }; +} + +/** h 0–360, s and l 0–1. */ +export function rgbToHsl(c: RGB): { h: number; s: number; l: number } { + const { h, max, min } = hue(c); + const l = (max + min) / 2; + const d = max - min; + const s = d < 1e-9 ? 0 : d / (1 - Math.abs(2 * l - 1)); + return { h, s, l }; +} + +export function hslToRgb(h: number, s: number, l: number, a = 1): RGB { + const k = (n: number) => (n + h / 30) % 12; + const f = (n: number) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + return { r: f(0) * 255, g: f(8) * 255, b: f(4) * 255, a }; +} + +/** h 0–360, s and v 0–1. */ +export function rgbToHsv(c: RGB): { h: number; s: number; v: number } { + const { h, max, min } = hue(c); + return { h, s: max === 0 ? 0 : (max - min) / max, v: max }; +} + +export function hsvToRgb(h: number, s: number, v: number, a = 1): RGB { + const l = v * (1 - s / 2); + const sl = l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l); + return hslToRgb(h, sl, l, a); +} + +export function hwbToRgb(h: number, w: number, bl: number, a = 1): RGB { + if (w + bl >= 1) { + const gray = (w / (w + bl)) * 255; + return { r: gray, g: gray, b: gray, a }; + } + const base = hslToRgb(h, 1, 0.5); + const f = (v: number) => (v / 255) * (1 - w - bl) * 255 + w * 255; + return { r: f(base.r), g: f(base.g), b: f(base.b), a }; +} + +export function rgbToHwb(c: RGB): { h: number; w: number; b: number } { + const { h, max, min } = hue(c); + return { h, w: min, b: 1 - max }; +} + +// --------------------------------------------------------------------------- +// Parsing + +/** "120", "120deg", "0.5turn", "3.14rad", "200grad" → degrees. */ +function angle(s: string): number { + const m = s.match(/^(-?[\d.]+(?:e-?\d+)?)(deg|turn|rad|grad)?$/i); + if (!m) return NaN; + const v = Number(m[1]); + const unit = (m[2] ?? "deg").toLowerCase(); + const deg = unit === "turn" ? v * 360 : unit === "rad" ? (v * 180) / Math.PI : unit === "grad" ? v * 0.9 : v; + return ((deg % 360) + 360) % 360; +} + +/** "50%" → 0.5 (or `whole` for a bare number on a 0–`whole` scale). */ +function ratio(s: string, whole: number): number { + if (s === "none") return 0; + if (s.endsWith("%")) return Number(s.slice(0, -1)) / 100; + return Number(s) / whole; +} + +const alphaOf = (s: string | undefined) => (s === undefined ? 1 : clamp(ratio(s, 1))); + +/** "#f63", "#ff6347", "ff6347", "#ff634780", "0xff6347". */ +function parseHex(s: string): RGB | undefined { + const m = s.match(/^(?:#|0x)?([0-9a-f]{3,8})$/i); + if (!m || ![3, 4, 6, 8].includes(m[1].length)) return undefined; + // Bare 3- or 4-digit numbers are more likely something else. + if (!/^(#|0x)/i.test(s) && m[1].length < 6) return undefined; + let h = m[1]; + if (h.length <= 4) h = [...h].map((c) => c + c).join(""); + const n = (i: number) => parseInt(h.slice(i, i + 2), 16); + return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? round(n(6) / 255, 3) : 1 }; +} + +/** Splits "a, b, c / d" or "a b c / d" into the values and the alpha. */ +function args(inner: string): { values: string[]; alpha?: string } { + const [main, slash] = inner.split("/"); + const values = main.trim().split(/\s*,\s*|\s+/).filter(Boolean); + if (slash !== undefined) return { values, alpha: slash.trim() }; + if (values.length === 4) return { values: values.slice(0, 3), alpha: values[3] }; + return { values }; +} + +/** + * Reads hex, `rgb()`, `hsl()`, `hwb()`, `hsv()`, `oklch()`, `oklab()`, CSS + * names (and a few French ones), or bare "255, 99, 71". Undefined otherwise. + */ +export function parseColor(input: string): RGB | undefined { + const s = input.trim().toLowerCase().replace(/;$/, ""); + if (!s) return undefined; + const named = NAMED[s] ?? NAMED[FRENCH[s] ?? ""] ?? NAMED[s.replace(/\s+/g, "")]; + if (named) return parseHex(named); + if (s === "transparent") return { r: 0, g: 0, b: 0, a: 0 }; + const hex = parseHex(s.replace(/\s+/g, "")); + if (hex) return hex; + + const fn = s.match(/^([a-z]+)\s*\((.*)\)$/); + const [name, inner] = fn ? [fn[1], fn[2]] : ["rgb", s]; + const { values, alpha } = args(inner); + if (values.length !== 3) return undefined; + const a = alphaOf(alpha); + let rgb: RGB | undefined; + switch (name) { + case "rgb": + case "rgba": { + const [r, g, b] = values.map((v) => clamp(ratio(v, 255)) * 255); + rgb = { r, g, b, a }; + break; + } + case "hsl": + case "hsla": + rgb = hslToRgb(angle(values[0]), clamp(ratio(values[1], 100)), clamp(ratio(values[2], 100)), a); + break; + case "hsv": + case "hsb": + rgb = hsvToRgb(angle(values[0]), clamp(ratio(values[1], 100)), clamp(ratio(values[2], 100)), a); + break; + case "hwb": + rgb = hwbToRgb(angle(values[0]), clamp(ratio(values[1], 100)), clamp(ratio(values[2], 100)), a); + break; + case "oklch": { + const l = clamp(ratio(values[0], 1)); + // In CSS, 100% chroma is 0.4. + const c = Math.max(0, values[1].endsWith("%") ? ratio(values[1], 1) * 0.4 : Number(values[1])); + rgb = oklchToRgb({ l, c, h: angle(values[2] === "none" ? "0" : values[2]) }, a); + break; + } + case "oklab": { + const ab = (v: string) => (v.endsWith("%") ? ratio(v, 1) * 0.4 : Number(v)); + rgb = oklabToRgb({ l: clamp(ratio(values[0], 1)), a: ab(values[1]), b: ab(values[2]) }, a); + break; + } + } + if (!rgb || [rgb.r, rgb.g, rgb.b, rgb.a].some((v) => !Number.isFinite(v))) return undefined; + return rgb; +} + +// --------------------------------------------------------------------------- +// Formatting + +const byte = (v: number) => Math.round(clamp(v, 0, 255)); +const hex2 = (v: number) => byte(v).toString(16).padStart(2, "0"); +const pct = (v: number) => `${num(v * 100)}%`; +const withAlpha = (c: RGB, sep: string) => (c.a < 1 ? `${sep}${num(c.a, 2)}` : ""); + +export function toHex(c: RGB, o: { upper?: boolean } = {}): string { + const h = `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${c.a < 1 ? hex2(c.a * 255) : ""}`; + return o.upper ? h.toUpperCase() : h; +} + +export const toRgb = (c: RGB) => + c.a < 1 + ? `rgba(${byte(c.r)}, ${byte(c.g)}, ${byte(c.b)}${withAlpha(c, ", ")})` + : `rgb(${byte(c.r)}, ${byte(c.g)}, ${byte(c.b)})`; + +export function toHsl(c: RGB): string { + const { h, s, l } = rgbToHsl(c); + return `${c.a < 1 ? "hsla" : "hsl"}(${num(h) === "360" ? 0 : num(h)}, ${pct(s)}, ${pct(l)}${withAlpha(c, ", ")})`; +} + +export function toHsv(c: RGB): string { + const { h, s, v } = rgbToHsv(c); + return `hsv(${num(h) === "360" ? 0 : num(h)}, ${pct(s)}, ${pct(v)})`; +} + +export function toHwb(c: RGB): string { + const { h, w, b } = rgbToHwb(c); + return `hwb(${num(h) === "360" ? 0 : num(h)} ${pct(w)} ${pct(b)}${withAlpha(c, " / ")})`; +} + +export function toOklch(c: RGB): string { + const { l, c: chroma, h } = rgbToOklch(c); + const hue = chroma < 0.0005 ? "0" : num(h, 1) === "360" ? "0" : num(h, 1); + return `oklch(${num(l * 100, 1)}% ${num(chroma, 3)} ${hue}${withAlpha(c, " / ")})`; +} + +/** For shaders and design tools: "1.000, 0.388, 0.278". */ +export const toFloats = (c: RGB) => [c.r, c.g, c.b].map((v) => (byte(v) / 255).toFixed(3)).join(", "); + +export interface Format { + id: string; + title: string; + value: string; +} + +/** Every way to write a color, the most common first. */ +export function formats(c: RGB, o: { upper?: boolean } = {}): Format[] { + const name = exactName(c); + return [ + { id: "hex", title: "HEX", value: toHex(c, o) }, + { id: "rgb", title: "RGB", value: toRgb(c) }, + { id: "hsl", title: "HSL", value: toHsl(c) }, + { id: "oklch", title: "OKLCH", value: toOklch(c) }, + { id: "hsv", title: "HSV", value: toHsv(c) }, + { id: "hwb", title: "HWB", value: toHwb(c) }, + { id: "floats", title: "RGB 0–1", value: toFloats(c) }, + ...(name ? [{ id: "name", title: "CSS Name", value: name }] : []), + ]; +} + +// --------------------------------------------------------------------------- +// Names, contrast + +export const exactName = (c: RGB) => (c.a === 1 ? BY_HEX[toHex(c)] : undefined); + +/** The closest CSS name (in OKLab) and how far it is (0 = exact). */ +export function nearestName(c: RGB): { name: string; distance: number } { + const lab = rgbToOklab(c); + let best = { name: "black", distance: Infinity }; + for (const [hex, name] of Object.entries(BY_HEX)) { + const o = rgbToOklab(parseHex(hex)!); + const d = Math.hypot(lab.l - o.l, lab.a - o.a, lab.b - o.b); + if (d < best.distance) best = { name, distance: d }; + } + return best; +} + +/** "Tomato" or "≈ Light Salmon". */ +export function describeName(c: RGB): string { + const { name, distance } = nearestName(c); + return `${distance < 0.002 ? "" : "≈ "}${prettyName(name)}`; +} + +const WORDS = ["light", "dark", "medium", "pale", "deep", "dim", "hot", "lawn", "sky", "slate", "steel", "rebecca"]; +/** "lightgoldenrodyellow" → "Light Goldenrod Yellow". */ +export function prettyName(name: string): string { + let rest = name; + const parts: string[] = []; + for (let again = true; again;) { + again = false; + for (const w of WORDS) { + if (rest.startsWith(w) && rest.length > w.length) { + parts.push(w); + rest = rest.slice(w.length); + again = true; + } + } + } + const tail = rest.match( + /^(.+?)(blue|green|red|gray|grey|yellow|white|violet|purple|pink|brown|cyan|wood|smoke|rose)$/, + ); + if (tail && tail[1].length > 2) parts.push(tail[1], tail[2]); + else parts.push(rest); + return parts.map((p) => p[0].toUpperCase() + p.slice(1)).join(" "); +} + +/** WCAG relative luminance, 0–1. */ +export function luminance({ r, g, b }: RGB): number { + const [R, G, B] = [r, g, b].map((v) => toLinear(byte(v) / 255)); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; +} + +export function contrast(a: RGB, b: RGB): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** "AAA", "AA", "AA Large" or "Fail", for normal text. */ +export const wcag = (ratio: number) => (ratio >= 7 ? "AAA" : ratio >= 4.5 ? "AA" : ratio >= 3 ? "AA Large" : "Fail"); + +export const WHITE: RGB = { r: 255, g: 255, b: 255, a: 1 }; +export const BLACK: RGB = { r: 0, g: 0, b: 0, a: 1 }; + +/** The swatch icon of a color (alpha is dropped). */ +export const swatch = (c: RGB) => `color:${toHex({ ...c, a: 1 })}`; + +// --------------------------------------------------------------------------- +// History + +export interface Picked { + hex: string; + /** Unix ms. */ + at: number; +} + +/** Lines "#rrggbb 1730000000" written by the picker script. */ +export function parsePicks(text: string): Picked[] { + return text.split("\n").flatMap((line) => { + const m = line.trim().match(/^#?([0-9a-f]{6})\s+(\d+)$/i); + return m ? [{ hex: `#${m[1].toLowerCase()}`, at: Number(m[2]) * 1000 }] : []; + }); +} + +/** Newest first, one entry per color, at most `max`. */ +export function mergeHistory(history: Picked[], added: Picked[], max = 100): Picked[] { + const all = [...added, ...history].sort((a, b) => b.at - a.at); + const seen = new Set(); + return all.filter((p) => !seen.has(p.hex) && seen.add(p.hex)).slice(0, max); +} + +// --------------------------------------------------------------------------- +// Picking from the screen + +export type Desktop = "gnome" | "kde" | "hyprland" | "sway" | "other"; +export interface Session { + wayland: boolean; + desktop: Desktop; +} +export type Env = Partial>; + +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] as const; + +export function detectSession(env: Env): Session { + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const current = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const desktop: Desktop = env.HYPRLAND_INSTANCE_SIGNATURE || current.includes("hyprland") + ? "hyprland" + : env.SWAYSOCK || current.includes("sway") + ? "sway" + : current.some((d) => ["gnome", "ubuntu", "unity", "pop"].includes(d)) + ? "gnome" + : current.includes("kde") + ? "kde" + : "other"; + return { wayland, desktop }; +} + +/** `portal` is xdg-desktop-portal's PickColor, called with gdbus. */ +export type Picker = "hyprpicker" | "grim" | "gpick" | "xcolor" | "portal"; + +export function pickerCandidates(s: Session): Picker[] { + if (s.wayland) { + if (s.desktop === "gnome" || s.desktop === "kde") return ["portal"]; + return ["hyprpicker", "grim", "portal"]; + } + return ["xcolor", "gpick", "portal"]; +} + +export const PICKER_NAMES: Record = { + hyprpicker: "hyprpicker", + grim: "grim + slurp", + gpick: "Gpick", + xcolor: "xcolor", + portal: "Desktop portal", +}; + +/** Single-quotes a string for sh. */ +export const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +/** Sets `$color` to "#rrggbb" (or leaves it empty when cancelled). */ +export function pickCommand(tool: Picker): string { + const firstHex = `grep -oiE '#[0-9a-f]{6}' | head -n 1`; + switch (tool) { + case "hyprpicker": + return `color=$(hyprpicker -f hex 2>/dev/null | ${firstHex})`; + case "xcolor": + return `color=$(xcolor 2>/dev/null | ${firstHex})`; + case "gpick": + return `color=$(gpick -s -o 2>/dev/null | ${firstHex})`; + case "grim": + // One pixel as PPM: the last three bytes are its red, green and blue. + return [ + `point=$(slurp -p) || exit 0`, + `color="#$(grim -g "$point" -t ppm - | tail -c 3 | od -An -tx1 | tr -d ' \\n')"`, + ].join("\n"); + case "portal": + return [ + `out="\${XDG_RUNTIME_DIR:-/tmp}/backslash-pick-$$"; token="backslash$$"`, + `buffer=; command -v stdbuf >/dev/null && buffer="stdbuf -oL"`, + `$buffer gdbus monitor --session --dest org.freedesktop.portal.Desktop > "$out" 2>/dev/null & monitor=$!`, + `trap 'kill $monitor 2>/dev/null; rm -f "$out"' EXIT`, + `sleep 0.3`, + `gdbus call --session --dest org.freedesktop.portal.Desktop --object-path /org/freedesktop/portal/desktop --method org.freedesktop.portal.Screenshot.PickColor '' "{'handle_token': <'$token'>}" >/dev/null || exit 0`, + `i=0; while [ $i -lt 1200 ] && ! grep -q "$token: org.freedesktop.portal.Request.Response" "$out"; do sleep 0.1; i=$((i + 1)); done`, + `line=$(grep -m 1 "$token: org.freedesktop.portal.Request.Response" "$out")`, + `case "$line" in *"(uint32 0,"*) ;; *) exit 0 ;; esac`, + `color=$(printf '%s' "$line" | sed -n "s/.*'color': <(\\([^)]*\\))>.*/\\1/p" | awk -F', *' '{ printf "#%02x%02x%02x", $1 * 255 + 0.5, $2 * 255 + 0.5, $3 * 255 + 0.5 }')`, + ].join("\n"); + } +} + +export interface PickOptions { + tool: Picker; + /** The file new colors are appended to; the extension reads it into its history. */ + log: string; + wayland: boolean; + upper?: boolean; + wait?: number; +} + +/** The background script: pick, copy, remember, notify. */ +export function pickScript(o: PickOptions): string { + const copy = o.wayland + ? `printf '%s' "$text" | wl-copy 2>/dev/null || printf '%s' "$text" | xclip -selection clipboard 2>/dev/null` + : `printf '%s' "$text" | xclip -selection clipboard 2>/dev/null || printf '%s' "$text" | wl-copy 2>/dev/null`; + const dir = o.log.split("/").slice(0, -1).join("/"); + return [ + `sleep ${o.wait ?? 0.3}`, + pickCommand(o.tool), + `color=$(printf '%s' "$color" | tr 'A-F' 'a-f')`, + `case "$color" in \\#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; *) exit 0 ;; esac`, + `mkdir -p ${q(dir)} && printf '%s %s\\n' "$color" "$(date +%s)" >> ${q(o.log)}`, + o.upper ? `text=$(printf '%s' "$color" | tr 'a-f' 'A-F')` : `text="$color"`, + `if ${copy}; then note='Copied to the clipboard'; else note='Saved in Color History'; fi`, + `notify-send -a Backslash -i color-select "Color $text" "$note" 2>/dev/null`, + "true", + ].join("\n"); +} + +/** `systemd-run` arguments to run the script outside the launcher. */ +export function systemdRunArgs(unit: string, script: string, env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: color picker", + "--collect", + // wl-copy and xclip stay in the background to serve the clipboard. + "--property=KillMode=process", + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + "/bin/sh", + "-c", + script, + ]; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +export const FAMILY_NAMES: Record = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +/** What to install to pick colors in this session. */ +export function pickerInstall(s: Session, family: Family): string { + if (s.wayland && (s.desktop === "gnome" || s.desktop === "kde")) { + const portal = s.desktop === "gnome" ? "xdg-desktop-portal-gnome" : "xdg-desktop-portal-kde"; + return `${MANAGERS[family]} ${portal}`; + } + if (s.wayland) { + return s.desktop === "hyprland" && family === "arch" + ? "sudo pacman -S hyprpicker" + : `${MANAGERS[family]} grim slurp`; + } + return `${MANAGERS[family]} gpick`; +} diff --git a/extensions/color-picker/lib_test.ts b/extensions/color-picker/lib_test.ts new file mode 100644 index 0000000..4f614ba --- /dev/null +++ b/extensions/color-picker/lib_test.ts @@ -0,0 +1,232 @@ +import { assert, assertAlmostEquals, assertEquals } from "@std/assert"; +import { + BLACK, + contrast, + describeName, + detectSession, + exactName, + formats, + hslToRgb, + luminance, + mergeHistory, + NAMED, + nearestName, + oklchToRgb, + parseColor, + parsePicks, + pickCommand, + pickerCandidates, + pickerInstall, + pickScript, + prettyName, + type RGB, + rgbToHsl, + rgbToHsv, + rgbToOklch, + swatch, + systemdRunArgs, + toFloats, + toHex, + toHsl, + toHsv, + toHwb, + toOklch, + toRgb, + wcag, + WHITE, +} from "./lib.ts"; + +const rgb = (r: number, g: number, b: number, a = 1): RGB => ({ r, g, b, a }); +const hexOf = (s: string) => { + const c = parseColor(s); + return c && toHex(c); +}; + +Deno.test("hex", () => { + assertEquals(parseColor("#ff6347"), rgb(255, 99, 71)); + assertEquals(parseColor("FF6347"), rgb(255, 99, 71)); + assertEquals(parseColor("0xff6347"), rgb(255, 99, 71)); + assertEquals(parseColor("#f63"), rgb(255, 102, 51)); + assertEquals(parseColor("#f638")?.a, 0.533); + assertEquals(parseColor("#ff634780")?.a, 0.502); + assertEquals(parseColor(" #FF6347; "), rgb(255, 99, 71)); + assertEquals(parseColor("fff"), undefined); // bare 3 digits: ambiguous + assertEquals(parseColor("#ff634"), undefined); + assertEquals(parseColor("#gggggg"), undefined); +}); + +Deno.test("rgb()", () => { + assertEquals(hexOf("rgb(255, 99, 71)"), "#ff6347"); + assertEquals(hexOf("rgb(255 99 71)"), "#ff6347"); + assertEquals(hexOf("RGB(100%, 0%, 0%)"), "#ff0000"); + assertEquals(hexOf("255, 99, 71"), "#ff6347"); + assertEquals(hexOf("255 99 71"), "#ff6347"); + assertEquals(parseColor("rgba(255, 99, 71, 0.5)")?.a, 0.5); + assertEquals(parseColor("rgb(255 99 71 / 25%)")?.a, 0.25); + assertEquals(hexOf("rgb(300, -5, 71)"), "#ff0047"); // clamped + assertEquals(parseColor("rgb(1, 2)"), undefined); + assertEquals(parseColor("rgb(a, b, c)"), undefined); +}); + +Deno.test("hsl(), hsv(), hwb()", () => { + assertEquals(hexOf("hsl(9, 100%, 64%)"), "#ff6347"); + assertEquals(hexOf("hsl(0 100% 50%)"), "#ff0000"); + assertEquals(hexOf("hsl(120deg 100% 25%)"), "#008000"); + assertEquals(hexOf("hsl(0.5turn, 100%, 50%)"), "#00ffff"); + assertEquals(hexOf("hsl(3.14159rad 100% 50%)"), "#00ffff"); + assertEquals(hexOf("hsl(-120, 100%, 50%)"), "#0000ff"); + assertEquals(hexOf("hsl(270 50 40)"), "#663399"); // bare numbers as percentages + assertEquals(parseColor("hsla(0, 0%, 0%, .3)")?.a, 0.3); + assertEquals(hexOf("hsv(9, 72%, 100%)"), "#ff6347"); + assertEquals(hexOf("hsb(0 0% 100%)"), "#ffffff"); + assertEquals(hexOf("hwb(9 28% 0%)"), "#ff6347"); + assertEquals(hexOf("hwb(0 60% 60%)"), "#808080"); // w + b > 1: gray +}); + +Deno.test("oklch() and oklab()", () => { + assertEquals(hexOf("oklch(62.8% 0.2577 29.23)"), "#ff0000"); + assertEquals(hexOf("oklch(0.628 0.2577 29.23)"), "#ff0000"); + assertEquals(hexOf("oklch(100% 0 0)"), "#ffffff"); + assertEquals(hexOf("oklch(0% 0 none)"), "#000000"); + assertEquals(hexOf("oklch(69.6% 0.196 32.3)"), "#ff6347"); + assertEquals(hexOf("oklch(70% 49% 32)"), hexOf("oklch(70% 0.196 32)")); + assertEquals(hexOf("oklab(62.8% 0.2249 0.1258)"), "#ff0000"); + assertEquals(hexOf("oklch(90% 0.4 140)")?.length, 7); // out of gamut: clipped +}); + +Deno.test("names", () => { + assertEquals(Object.keys(NAMED).length, 148); + assertEquals(hexOf("tomato"), "#ff6347"); + assertEquals(hexOf("RebeccaPurple"), "#663399"); + assertEquals(hexOf("light blue"), "#add8e6"); + assertEquals(hexOf("rouge"), "#ff0000"); + assertEquals(hexOf("bleu marine"), "#000080"); + assertEquals(parseColor("transparent")?.a, 0); + assertEquals(parseColor("notacolor"), undefined); + assertEquals(exactName(rgb(255, 99, 71)), "tomato"); + assertEquals(exactName(rgb(128, 128, 128)), "gray"); + assertEquals(exactName(rgb(0, 255, 255)), "aqua"); + assertEquals(exactName(rgb(255, 99, 72)), undefined); + assertEquals(nearestName(rgb(254, 100, 70)).name, "tomato"); + assertEquals(describeName(rgb(255, 99, 71)), "Tomato"); + assertEquals(describeName(rgb(250, 100, 72)), "≈ Tomato"); + assertEquals( + ["lightgoldenrodyellow", "cornflowerblue", "darkslategray", "rebeccapurple", "palevioletred", "whitesmoke"].map( + prettyName, + ), + [ + "Light Goldenrod Yellow", + "Cornflower Blue", + "Dark Slate Gray", + "Rebecca Purple", + "Pale Violet Red", + "White Smoke", + ], + ); +}); + +Deno.test("conversions round-trip", () => { + const samples = ["#ff6347", "#663399", "#000000", "#ffffff", "#1e90ff", "#7fff00", "#808080", "#c71585", "#0a0b0c"]; + for (const hex of samples) { + const c = parseColor(hex)!; + for (const written of [toRgb(c), toHsl(c), toHsv(c), toHwb(c), toOklch(c)]) { + const back = parseColor(written); + assert(back, `${written} should parse`); + const err = Math.max(Math.abs(back.r - c.r), Math.abs(back.g - c.g), Math.abs(back.b - c.b)); + // HSL/HSV are rounded to whole percents: up to ~1.3 % of 255. + assert(err <= 3.5, `${hex} → ${written} → ${toHex(back)} (off by ${err.toFixed(2)})`); + } + } + const red = rgbToOklch(rgb(255, 0, 0)); + assertAlmostEquals(red.l, 0.628, 0.001); + assertAlmostEquals(red.c, 0.2577, 0.001); + assertAlmostEquals(red.h, 29.23, 0.05); + assertEquals(rgbToOklch(rgb(128, 128, 128)).h, 0); + assertAlmostEquals(rgbToOklch(WHITE).l, 1, 1e-4); + assertEquals(rgbToHsl(rgb(255, 255, 255)), { h: 0, s: 0, l: 1 }); + assertEquals(rgbToHsv(rgb(0, 0, 0)), { h: 0, s: 0, v: 0 }); + assertEquals(toHex(hslToRgb(210, 1, 0.56)), "#1f8fff"); + assertEquals(toHex(oklchToRgb({ l: 0.5, c: 0, h: 0 })), "#636363"); +}); + +Deno.test("formats", () => { + const tomato = rgb(255, 99, 71); + assertEquals(formats(tomato).map((f) => [f.title, f.value]), [ + ["HEX", "#ff6347"], + ["RGB", "rgb(255, 99, 71)"], + ["HSL", "hsl(9, 100%, 64%)"], + ["OKLCH", "oklch(69.6% 0.196 32.3)"], + ["HSV", "hsv(9, 72%, 100%)"], + ["HWB", "hwb(9 28% 0%)"], + ["RGB 0–1", "1.000, 0.388, 0.278"], + ["CSS Name", "tomato"], + ]); + assertEquals(formats(tomato, { upper: true })[0].value, "#FF6347"); + const half = rgb(255, 99, 71, 0.5); + assertEquals(toHex(half), "#ff634780"); + assertEquals(toRgb(half), "rgba(255, 99, 71, 0.5)"); + assertEquals(toHsl(half), "hsla(9, 100%, 64%, 0.5)"); + assertEquals(toOklch(half), "oklch(69.6% 0.196 32.3 / 0.5)"); + assertEquals(toOklch(rgb(128, 128, 128)), "oklch(60% 0 0)"); + assertEquals(toHsl(rgb(255, 0, 1)), "hsl(0, 100%, 50%)"); // 359.8° rounds to 0, not 360 + assertEquals(toFloats(BLACK), "0.000, 0.000, 0.000"); + assertEquals(formats(half).some((f) => f.id === "name"), false); + assertEquals(swatch(half), "color:#ff6347"); +}); + +Deno.test("contrast", () => { + assertAlmostEquals(luminance(WHITE), 1, 1e-9); + assertEquals(luminance(BLACK), 0); + assertAlmostEquals(contrast(WHITE, BLACK), 21, 1e-9); + assertAlmostEquals(contrast(rgb(118, 118, 118), WHITE), 4.54, 0.01); + assertEquals([wcag(21), wcag(4.54), wcag(3.2), wcag(1.5)], ["AAA", "AA", "AA Large", "Fail"]); +}); + +Deno.test("history", () => { + const picks = parsePicks("#FF6347 1761645600\ngarbage\n#663399 1761645700\n\n"); + assertEquals(picks, [{ hex: "#ff6347", at: 1761645600000 }, { hex: "#663399", at: 1761645700000 }]); + const merged = mergeHistory([{ hex: "#ff6347", at: 1000 }, { hex: "#000000", at: 500 }], picks); + assertEquals(merged.map((p) => p.hex), ["#663399", "#ff6347", "#000000"]); + assertEquals(merged[1].at, 1761645600000); + assertEquals(mergeHistory([], picks, 1).length, 1); +}); + +Deno.test("pickers and scripts", () => { + assertEquals(pickerCandidates(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "GNOME" })), [ + "portal", + ]); + assertEquals(pickerCandidates(detectSession({ WAYLAND_DISPLAY: "w", HYPRLAND_INSTANCE_SIGNATURE: "x" })), [ + "hyprpicker", + "grim", + "portal", + ]); + assertEquals(pickerCandidates(detectSession({ XDG_SESSION_TYPE: "x11" })), ["xcolor", "gpick", "portal"]); + assert(pickCommand("grim").includes(`grim -g "$point" -t ppm - | tail -c 3 | od -An -tx1`)); + assert(pickCommand("portal").includes("org.freedesktop.portal.Screenshot.PickColor")); + const script = pickScript({ + tool: "hyprpicker", + log: "/home/ada/.cache/backslash/color-picker/picks", + wayland: true, + upper: true, + }); + assert(script.startsWith("sleep 0.3\ncolor=$(hyprpicker -f hex")); + assert(script.includes(`>> '/home/ada/.cache/backslash/color-picker/picks'`)); + assert(script.includes(`text=$(printf '%s' "$color" | tr 'a-f' 'A-F')`)); + assertEquals(systemdRunArgs("u", "s", { DISPLAY: ":0" }).slice(-4), ["--setenv=DISPLAY=:0", "/bin/sh", "-c", "s"]); + assertEquals( + pickerInstall({ wayland: true, desktop: "gnome" }, "fedora"), + "sudo dnf install xdg-desktop-portal-gnome", + ); + assertEquals(pickerInstall({ wayland: false, desktop: "other" }, "debian"), "sudo apt install gpick"); +}); + +Deno.test("every generated script is valid sh", async () => { + for (const tool of ["hyprpicker", "grim", "gpick", "xcolor", "portal"] as const) { + for (const upper of [true, false]) { + const script = pickScript({ tool, log: "/h/it's/picks", wayland: !upper, upper }); + const { success, stderr } = await new Deno.Command("sh", { args: ["-n", "-c", script], stderr: "piped" }) + .output(); + assert(success, new TextDecoder().decode(stderr)); + } + } +}); diff --git a/extensions/color-picker/system.ts b/extensions/color-picker/system.ts new file mode 100644 index 0000000..2da24a2 --- /dev/null +++ b/extensions/color-picker/system.ts @@ -0,0 +1,106 @@ +// The session, installed pickers, the background job and the history. + +import { exec, storage } from "@backslash/api"; +import { + detectSession, + type Env, + type Family, + mergeHistory, + osFamily, + parsePicks, + type Picked, + type Picker, + pickerCandidates, + pickScript, + type Session, + SESSION_ENV, + systemdRunArgs, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key); + } catch { + return undefined; + } +}; + +const HOME = env("HOME") ?? ""; +/** Colors picked in the background land here until a view imports them. */ +export const PICKS = `${HOME}/.cache/backslash/color-picker/picks`; +const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +export const session = (): Session => detectSession(sessionEnv()); + +async function installed(program: string, args: string[]): Promise { + try { + await new Deno.Command(program, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }).output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +const PROBES: Record = { + hyprpicker: [["hyprpicker", ["--help"]]], + grim: [["grim", ["-h"]], ["slurp", ["-h"]]], + gpick: [["gpick", ["--version"]]], + xcolor: [["xcolor", ["--version"]]], + portal: [["gdbus", ["help"]]], +}; + +/** The first picker that works here. */ +export async function picker(): Promise<{ session: Session; tool?: Picker }> { + const s = session(); + for (const tool of pickerCandidates(s)) { + const ok = await Promise.all(PROBES[tool].map(([p, a]) => installed(p, a))); + if (ok.every(Boolean)) return { session: s, tool }; + } + return { session: s }; +} + +export async function family(): Promise { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +export class Missing extends Error {} + +/** Picks in the background, once the launcher is hidden. */ +export async function pick(o: { upper?: boolean; wait?: number } = {}): Promise { + const p = await picker(); + if (!p.tool) throw new Missing("No color picker found"); + const script = pickScript({ tool: p.tool, log: PICKS, wayland: p.session.wayland, ...o }); + try { + await exec("systemd-run", systemdRunArgs(`backslash-color-picker-${Date.now()}`, script, sessionEnv())); + } catch (e) { + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: the picker runs in the background with systemd-run --user"); + } + throw e; + } +} + +/** The history, with the colors picked since the last time imported. */ +export async function history(): Promise { + const saved = ((await storage.get("history")) ?? []) as unknown as Picked[]; + const added = parsePicks(await Deno.readTextFile(PICKS).catch(() => "")); + if (!added.length) return saved; + const merged = mergeHistory(saved, added); + await storage.set("history", merged as any); + await Deno.writeTextFile(PICKS, "").catch(() => {}); + return merged; +} + +export async function saveHistory(list: Picked[]): Promise { + await storage.set("history", list as any); +} + +export async function remember(hex: string): Promise { + await saveHistory(mergeHistory(await history(), [{ hex, at: Date.now() }])); +} diff --git a/extensions/color-picker/ui.ts b/extensions/color-picker/ui.ts new file mode 100644 index 0000000..bdb63dc --- /dev/null +++ b/extensions/color-picker/ui.ts @@ -0,0 +1,88 @@ +// Items and actions shared by Color History and Convert Color. + +import { + type Action, + action, + close, + type Color, + copy, + Icons, + meta, + type Metadata, + paste, + submenu, + toast, +} from "@backslash/api"; +import { + BLACK, + contrast, + describeName, + type Format, + formats, + pickerInstall, + type RGB, + rgbToOklch, + swatch, + toHex, + wcag, + WHITE, +} from "./lib.ts"; +import { family, Missing, pick, session } from "./system.ts"; + +const levelColor = (level: string): Color => (level === "Fail" ? "red" : level === "AA Large" ? "orange" : "green"); + +function contrastLine(label: string, c: RGB, other: RGB): Metadata { + const ratio = contrast({ ...c, a: 1 }, other); + const level = wcag(ratio); + return meta.tags(label, [{ text: `${ratio.toFixed(2)} : 1` }, { text: level, color: levelColor(level) }]); +} + +/** The side panel of a color: every format, its name and contrast. */ +export function colorMetadata(c: RGB, upper: boolean, extra: Metadata[] = []): Metadata[] { + const all = formats(c, { upper }); + return [ + ...all.filter((f) => f.id !== "name").slice(0, 5).map((f) => meta.text(f.title, f.value)), + meta.separator(), + meta.text("Name", describeName(c), { icon: Icons.tag }), + meta.progress("Lightness", rgbToOklch(c).l, `${Math.round(rgbToOklch(c).l * 100)} %`), + contrastLine("On white", c, WHITE), + contrastLine("On black", c, BLACK), + ...extra, + ]; +} + +/** Copy (Enter), Paste (Ctrl+Enter), then every format as a submenu. */ +export function colorActions(c: RGB, upper: boolean): Action[] { + const all: Format[] = formats(c, { upper }); + const hex = toHex(c, { upper }); + return [ + copy(hex, `Copy ${hex}`), + paste(hex, `Paste ${hex}`), + submenu( + "Copy As", + all.map((f) => copy(f.value, `${f.title} ${f.value}`, { icon: Icons.copy })), + { icon: Icons.copy, shortcut: "c", section: "Copy" }, + ), + submenu( + "Paste As", + all.map((f) => paste(f.value, `${f.title} ${f.value}`, { icon: Icons.paste })), + { icon: Icons.paste, shortcut: "v", section: "Copy" }, + ), + ]; +} + +export const pickAction = (upper: boolean) => + action("Pick Color from Screen", async () => { + try { + await pick({ upper }); + } catch (e) { + if (e instanceof Missing) { + await toast(`${e.message}: ${pickerInstall(session(), (await family()) ?? "debian")}`, { style: "failure" }); + return; + } + throw e; + } + return close(); + }, { icon: Icons.pipette, shortcut: "p" }); + +export { swatch }; diff --git a/extensions/display/backslash.toml b/extensions/display/backslash.toml new file mode 100644 index 0000000..1992eea --- /dev/null +++ b/extensions/display/backslash.toml @@ -0,0 +1,105 @@ +[extension] +id = "display" +title = "Display" +description = "Screen brightness with presets, night light, and your monitors' resolution, refresh rate and scale" +version = "1.0.0" +author = "Backslash" +icon = "bs-monitor" +license = "MIT" + +# Brightness: brightnessctl, or the kernel's backlight read through udevadm +# and written through systemd-logind (busctl, no root needed); ddcutil for +# external monitors when enabled. Night light: GNOME (gsettings), KDE Plasma +# (busctl, kwriteconfig6/5), or hyprsunset, wlsunset, gammastep or Redshift +# run as a systemd user unit (systemd-run, systemctl). Monitors: hyprctl, +# swaymsg, kscreen-doctor, GNOME's Mutter (gdbus), wlr-randr or xrandr. +[permissions] +run = [ + "brightnessctl", + "udevadm", + "busctl", + "ddcutil", + "gsettings", + "kwriteconfig6", + "kwriteconfig5", + "hyprsunset", + "wlsunset", + "gammastep", + "redshift", + "systemd-run", + "systemctl", + "hyprctl", + "swaymsg", + "kscreen-doctor", + "gdbus", + "wlr-randr", + "xrandr", +] +read = ["/etc/os-release"] +env = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] + +[[commands]] +name = "display" +title = "Display" +description = "Brightness, night light and monitors" +icon = "bs-monitor" +keywords = ["display", "screen", "écran", "brightness", "luminosité", "monitor", "moniteur", "night light", "résolution", "resolution"] + +[[commands]] +name = "set-brightness" +title = "Set Brightness" +description = "e.g. 50 for half" +icon = "bs-sun" +mode = "no-view" +keywords = ["brightness", "luminosité", "backlight", "screen", "écran"] +[[commands.arguments]] +name = "level" +placeholder = "0–100" +type = "number" +min = 0 +max = 100 +required = true + +[[commands]] +name = "brightness-up" +title = "Increase Brightness" +icon = "bs-sun" +mode = "no-view" +keywords = ["brightness", "brighter", "luminosité", "plus lumineux", "augmenter"] + +[[commands]] +name = "brightness-down" +title = "Decrease Brightness" +icon = "bs-sun" +mode = "no-view" +keywords = ["brightness", "dimmer", "dim", "luminosité", "baisser", "diminuer"] + +[[commands]] +name = "night-light" +title = "Toggle Night Light" +description = "Warmer colors in the evening" +icon = "bs-moon" +mode = "no-view" +keywords = ["night light", "lumière nocturne", "veilleuse", "blue light", "lumière bleue", "redshift", "gammastep"] + +[[preferences]] +name = "ddc" +title = "External monitors (DDC/CI)" +description = "Adjust desktop monitors with ddcutil (needs access to /dev/i2c-*)" +type = "boolean" +default = false + +[[preferences]] +name = "temperature" +title = "Night light temperature (K)" +description = "For hyprsunset, wlsunset, gammastep and Redshift; GNOME and KDE use their own setting" +type = "number" +default = 4000 diff --git a/extensions/display/commands/brightness-down.ts b/extensions/display/commands/brightness-down.ts new file mode 100644 index 0000000..4e0d1f7 --- /dev/null +++ b/extensions/display/commands/brightness-down.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { brightness } from "../quick.ts"; + +export default run(({ preferences }) => brightness({ step: -10 }, !!preferences.ddc)); diff --git a/extensions/display/commands/brightness-up.ts b/extensions/display/commands/brightness-up.ts new file mode 100644 index 0000000..3eb90c4 --- /dev/null +++ b/extensions/display/commands/brightness-up.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { brightness } from "../quick.ts"; + +export default run(({ preferences }) => brightness({ step: 10 }, !!preferences.ddc)); diff --git a/extensions/display/commands/display.ts b/extensions/display/commands/display.ts new file mode 100644 index 0000000..48b987e --- /dev/null +++ b/extensions/display/commands/display.ts @@ -0,0 +1,292 @@ +import { + action, + type Color, + copy, + Icons, + type Item, + list, + meta, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + aspect, + type Backlight, + type Family, + install, + logicalSize, + type Monitor, + percent, + PRESETS, + refreshText, + resolution, + scaleText, + stepped, +} from "../lib.ts"; +import { + type Backlights, + backlights, + ddcDisplays, + family, + monitors, + type NightLight, + nightLight, + session, + setBacklight, + setNightLight, +} from "../system.ts"; + +interface Prefs { + ddc?: boolean; + temperature?: number; +} + +const set = (b: Backlight, pct: number, via: Backlights["via"]) => async () => { + await setBacklight(b, pct, via); + return refresh(); +}; + +function brightnessItem(b: Backlight, via: Backlights["via"]): Item { + const pct = percent(b); + const ddc = b.kind === "ddc"; + const tint: Color = pct >= 75 ? "yellow" : pct >= 35 ? "orange" : "gray"; + return { + id: `brightness:${b.id}`, + title: ddc ? b.name : "Brightness", + subtitle: ddc ? "External monitor (DDC/CI)" : b.name, + icon: { source: Icons.sun, tint }, + section: "Brightness", + keywords: ["brightness", "luminosité", "backlight", "dim", b.name], + accessories: [{ tag: `${pct} %`, color: tint === "gray" ? undefined : tint }], + detail: { + stats: [{ label: "brightness", value: `${pct} %` }], + metadata: [ + meta.progress("Level", pct / 100), + meta.text("Device", b.name, { icon: ddc ? Icons.monitor : Icons.sun }), + meta.text("Raw value", `${b.current} / ${b.max}`), + meta.text("Through", ddc ? "ddcutil" : via === "brightnessctl" ? "brightnessctl" : "systemd-logind", { + icon: Icons.settings, + }), + ], + }, + actions: [ + submenu( + "Set Brightness", + PRESETS.map((p) => action(`${p} %`, set(b, p, via), { icon: p >= 50 ? Icons.sun : Icons.moon })), + { icon: Icons.sun }, + ), + action("Brighter", set(b, stepped(pct, 10), via), { icon: Icons.arrowUp, shortcut: "Up" }), + action("Dimmer", set(b, stepped(pct, -10), via), { icon: Icons.arrowDown, shortcut: "Down" }), + ...PRESETS.map((p, i) => + action(`Set to ${p} %`, set(b, p, via), { icon: Icons.sun, shortcut: `${i + 1}`, section: "Presets" }) + ), + ], + }; +} + +function noBrightness(fam?: Family): Item { + return { + id: "brightness:none", + title: "No adjustable backlight", + subtitle: "desktop monitors need DDC/CI", + icon: { source: Icons.sun, tint: "gray" }, + section: "Brightness", + detail: { + markdown: [ + "Laptop panels are adjusted through the kernel's backlight; none was found here.", + "For **external monitors**, install ddcutil, allow your user to use I²C (`sudo usermod -aG i2c $USER`), then turn on **External monitors (DDC/CI)** in this extension's preferences.", + `\`${install(fam, "ddcutil")}\``, + "brightnessctl is used when installed:", + `\`${install(fam, "brightnessctl")}\``, + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; +} + +const BACKENDS: Record = { + gnome: "GNOME Settings", + kde: "KDE Plasma", + hyprsunset: "hyprsunset", + wlsunset: "wlsunset", + gammastep: "gammastep", + redshift: "Redshift", +}; + +function nightItem(n: NightLight | undefined, prefs: Prefs, fam?: Family): Item { + if (!n) { + const s = session(); + const pkg = s.desktop === "hyprland" ? "hyprsunset" : s.wayland ? "wlsunset" : "gammastep"; + return { + id: "night", + title: "Night Light unavailable", + subtitle: `install ${pkg}`, + icon: { source: Icons.moon, tint: "gray" }, + section: "Night Light", + detail: { + markdown: [ + "GNOME and KDE Plasma have their own night light. On Hyprland, Sway and other compositors, or X11, Backslash runs **hyprsunset**, **wlsunset**, **gammastep** or **Redshift** at a fixed temperature.", + `\`${install(fam, pkg)}\``, + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; + } + const kelvin = n.temperature ?? prefs.temperature ?? 4000; + const scheduled = n.enabled && n.active === false && (n.backend === "gnome" || n.backend === "kde"); + return { + id: "night", + title: n.enabled ? "Turn Night Light Off" : "Turn Night Light On", + subtitle: n.enabled ? scheduled ? "On · waiting for its schedule" : "On" : "Off", + icon: { source: Icons.moon, tint: n.enabled ? "orange" : "gray" }, + section: "Night Light", + keywords: ["night light", "lumière nocturne", "veilleuse", "blue light", "redshift", "warm", "chaud"], + accessories: [{ tag: n.enabled ? "On" : "Off", color: n.enabled ? "orange" : undefined }], + detail: { + stats: [ + { label: "night light", value: n.enabled ? "On" : "Off", color: n.enabled ? "orange" : undefined }, + ...(n.enabled || n.temperature ? [{ label: "temperature", value: `${kelvin} K` }] : []), + ], + metadata: [ + meta.text("Handled by", BACKENDS[n.backend] ?? n.backend, { icon: Icons.settings }), + ...(n.schedule ? [meta.text("Schedule", n.schedule, { icon: Icons.clock })] : []), + ...(n.backend !== "gnome" && n.backend !== "kde" + ? [meta.text("Runs", "In the background until turned off (systemd user unit)", { icon: Icons.info })] + : []), + ], + }, + actions: [ + action(n.enabled ? "Turn Off" : "Turn On", async () => { + await setNightLight(!n.enabled, prefs.temperature ?? 4000); + await toast(`Night light ${n.enabled ? "off" : "on"}`, { style: "success" }); + return refresh(); + }, { icon: n.enabled ? Icons.sun : Icons.moon, shortcut: "n" }), + ], + }; +} + +function monitorItem(m: Monitor, source?: string): Item { + const res = resolution(m); + const tags = [ + ...(m.builtin ? [{ text: "Built-in", color: "blue" as const }] : []), + ...(m.primary + ? [{ text: source === "hyprctl" || source === "swaymsg" ? "Focused" : "Primary", color: "green" as const }] + : []), + ...(!m.enabled ? [{ text: "Disabled", color: "gray" as const }] : []), + ...(m.vrr ? [{ text: "VRR", color: "purple" as const }] : []), + ]; + return { + id: `monitor:${m.name}`, + title: m.description, + subtitle: m.name, + icon: { source: m.builtin ? Icons.window : Icons.monitor, tint: m.enabled ? "blue" : "gray" }, + section: "Monitors", + keywords: [m.name, m.make ?? "", m.model ?? "", "monitor", "écran", "display"], + accessories: m.enabled + ? [ + { text: `${m.width}×${m.height}` }, + { text: refreshText(m) }, + ...(m.scale !== 1 ? [{ tag: scaleText(m.scale) }] : []), + ] + : [{ tag: "Off" }], + detail: { + stats: m.enabled + ? [ + { label: "resolution", value: res }, + { label: "refresh", value: refreshText(m) }, + { label: "scale", value: scaleText(m.scale) }, + ] + : [], + metadata: [ + ...(tags.length ? [meta.tags("Status", tags)] : []), + meta.text("Connector", m.name, { icon: Icons.plug }), + ...(m.make ? [meta.text("Make", m.make)] : []), + ...(m.model ? [meta.text("Model", m.model)] : []), + ...(m.serial ? [meta.text("Serial", m.serial)] : []), + meta.separator(), + ...(m.enabled + ? [ + meta.text("Aspect ratio", aspect(m.width, m.height)), + ...(m.scale !== 1 ? [meta.text("Looks like", logicalSize(m))] : []), + meta.text("Position", `${m.x}, ${m.y}`), + ...(m.transform && m.transform !== "normal" ? [meta.text("Rotation", m.transform)] : []), + ] + : []), + ...(m.modes.length > 1 ? [meta.tags("Modes", m.modes.slice(0, 8))] : []), + ...(source ? [meta.text("Source", source, { icon: Icons.terminal })] : []), + ], + }, + actions: [ + copy(m.name, "Copy Connector Name"), + copy(`${m.width}x${m.height}@${m.refresh.toFixed(2)}`, "Copy Mode", { section: "Copy" }), + copy( + [`${m.description} (${m.name})`, `${res} @ ${refreshText(m)}`, `Scale ${scaleText(m.scale)}`].join("\n"), + "Copy Details", + { section: "Copy", shortcut: "c" }, + ), + ], + }; +} + +function noMonitors(fam?: Family): Item { + const s = session(); + const tool = s.wayland + ? { hyprland: "hyprctl", sway: "swaymsg", kde: "kscreen-doctor", gnome: "gdbus", other: "wlr-randr" }[s.desktop] + : "xrandr"; + const xrandr = + { arch: "xorg-xrandr", debian: "x11-xserver-utils", fedora: "xrandr", suse: "xrandr" }[fam ?? "debian"]; + const pkg = tool === "wlr-randr" ? "wlr-randr" : tool === "xrandr" ? xrandr : undefined; + return { + id: "monitors:none", + title: "No monitor information", + subtitle: `${tool} did not answer`, + icon: { source: Icons.monitor, tint: "gray" }, + section: "Monitors", + detail: { + markdown: [ + "Monitors are listed by the compositor: **hyprctl** (Hyprland), **swaymsg** (Sway), **kscreen-doctor** (KDE), GNOME's display service, **wlr-randr** (other wlroots compositors) or **xrandr** (X11).", + ...(pkg ? [`\`${install(fam, pkg)}\``] : []), + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; +} + +export default list({ + placeholder: "Brightness, night light, monitors…", + empty: { title: "Nothing to show", icon: Icons.monitor }, + status: (items) => { + const b = items.find((i) => i.section === "Brightness" && i.accessories?.[0]?.tag); + const n = items.find((i) => i.id === "night"); + const m = items.filter((i) => i.section === "Monitors" && i.id !== "monitors:none").length; + return [ + b && `Brightness ${b.accessories![0].tag}`, + n?.accessories && `Night light ${n.accessories[0].tag?.toLowerCase()}`, + m && `${m} monitor${m > 1 ? "s" : ""}`, + ].filter(Boolean).join(" · ") || "Display"; + }, + async *items({ preferences }) { + const prefs = preferences as Prefs; + const [bl, night, mons, fam] = await Promise.all([backlights(), nightLight(), monitors(), family()]); + const subtitle = [ + bl.list[0] && `${percent(bl.list[0])} %`, + night?.enabled && "Night light on", + ].filter(Boolean).join(" · ") || null; + updateCommandMetadata({ subtitle }).catch(() => {}); + const rest = [ + nightItem(night, prefs, fam), + ...(mons.list.length ? mons.list.map((m) => monitorItem(m, mons.source)) : [noMonitors(fam)]), + ]; + const panels = bl.list.map((b) => brightnessItem(b, bl.via)); + if (!prefs.ddc) { + yield [...(panels.length ? panels : [noBrightness(fam)]), ...rest]; + return; + } + // External monitors answer slowly: show the rest first. + yield [...panels, ...rest]; + const ddc = (await ddcDisplays()).map((b) => brightnessItem(b, bl.via)); + yield [...(panels.length || ddc.length ? [...panels, ...ddc] : [noBrightness(fam)]), ...rest]; + }, +}); diff --git a/extensions/display/commands/night-light.ts b/extensions/display/commands/night-light.ts new file mode 100644 index 0000000..e83487e --- /dev/null +++ b/extensions/display/commands/night-light.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { toggleNight } from "../quick.ts"; + +export default run(({ preferences }) => toggleNight(Number(preferences.temperature) || 4000)); diff --git a/extensions/display/commands/set-brightness.ts b/extensions/display/commands/set-brightness.ts new file mode 100644 index 0000000..169d646 --- /dev/null +++ b/extensions/display/commands/set-brightness.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { brightness } from "../quick.ts"; + +export default run<{ level: number }>(({ args, preferences }) => brightness(Number(args.level), !!preferences.ddc)); diff --git a/extensions/display/lib.ts b/extensions/display/lib.ts new file mode 100644 index 0000000..dca6262 --- /dev/null +++ b/extensions/display/lib.ts @@ -0,0 +1,497 @@ +// Parsing brightness, night light and monitor information from the tools of +// each desktop. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Session + +export type Desktop = "gnome" | "kde" | "hyprland" | "sway" | "other"; +export interface Session { + wayland: boolean; + desktop: Desktop; +} +export type Env = Partial>; + +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] as const; + +export function detectSession(env: Env): Session { + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const current = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const desktop: Desktop = env.HYPRLAND_INSTANCE_SIGNATURE || current.includes("hyprland") + ? "hyprland" + : env.SWAYSOCK || current.includes("sway") + ? "sway" + : current.some((d) => ["gnome", "ubuntu", "unity", "pop"].includes(d)) + ? "gnome" + : current.includes("kde") + ? "kde" + : "other"; + return { wayland, desktop }; +} + +// --------------------------------------------------------------------------- +// Brightness + +export interface Backlight { + /** Kernel device name, e.g. intel_backlight; or "ddc:1" for an external monitor. */ + id: string; + name: string; + current: number; + max: number; + kind: "backlight" | "ddc"; +} + +export const percent = (b: Backlight) => (b.max > 0 ? Math.round((b.current / b.max) * 100) : 0); + +/** `brightnessctl -l -m -c backlight`: "intel_backlight,backlight,19200,40%,48000". */ +export function parseBrightnessctl(out: string): Backlight[] { + return out.split("\n").flatMap((line) => { + const [name, cls, current, , max] = line.trim().split(","); + if (!name || cls !== "backlight" || !Number(max)) return []; + return [{ id: name, name, current: Number(current), max: Number(max), kind: "backlight" as const }]; + }); +} + +/** `udevadm info --export-db` → the devices of a subsystem, with their properties. */ +export function parseUdevDb(out: string, subsystem: string): { path: string; props: Record }[] { + return out.split(/\n\s*\n/).flatMap((block) => { + const props: Record = {}; + let path = ""; + for (const line of block.split("\n")) { + if (line.startsWith("P: ")) path = line.slice(3).trim(); + else if (line.startsWith("E: ")) { + const eq = line.indexOf("="); + if (eq > 3) props[line.slice(3, eq)] = line.slice(eq + 1); + } + } + return props.SUBSYSTEM === subsystem && path ? [{ path, props }] : []; + }); +} + +/** `udevadm info -a -p …`: the first device's `ATTR{…}` values. */ +export function parseUdevAttrs(out: string): Record { + const attrs: Record = {}; + const first = out.split(/looking at parent device/)[0]; + for (const m of first.matchAll(/ATTR\{([^}]+)\}=="([^"]*)"/g)) attrs[m[1]] = m[2]; + return attrs; +} + +/** The raw value for a percentage, never fully off for a backlight. */ +export function rawFor(b: Backlight, pct: number): number { + const v = Math.round((Math.max(0, Math.min(100, pct)) / 100) * b.max); + return b.kind === "backlight" ? Math.max(1, v) : v; +} + +/** One step brighter or dimmer, landing on multiples of the step. */ +export function stepped(pct: number, step: number): number { + const next = step > 0 ? Math.floor(pct / step) * step + step : Math.ceil(pct / -step) * -step + step; + return Math.max(step > 0 ? 0 : 1, Math.min(100, next)); +} + +export const PRESETS = [100, 75, 50, 25, 10]; + +export interface DdcDisplay { + display: number; + model: string; + bus?: string; + connector?: string; +} + +/** `ddcutil detect --brief`. */ +export function parseDdcDetect(out: string): DdcDisplay[] { + return out.split(/\n\s*\n/).flatMap((block) => { + const n = block.match(/^Display\s+(\d+)/m); + if (!n) return []; + const monitor = block.match(/Monitor:\s+(.*)$/m)?.[1]?.trim() ?? ""; + const [, model] = monitor.split(":"); + return [{ + display: Number(n[1]), + model: (model || monitor || `Display ${n[1]}`).trim(), + bus: block.match(/I2C bus:\s+(\S+)/)?.[1], + connector: block.match(/DRM connector:\s+(\S+)/)?.[1]?.replace(/^card\d+-/, ""), + }]; + }); +} + +/** `ddcutil --brief getvcp 10`: "VCP 10 C 50 100" → current and max. */ +export function parseDdcVcp(out: string): { current: number; max: number } | undefined { + const m = out.match(/VCP\s+10\s+C\s+(\d+)\s+(\d+)/i); + return m ? { current: Number(m[1]), max: Number(m[2]) } : undefined; +} + +// --------------------------------------------------------------------------- +// Night light + +/** `gsettings get …`: "true", "uint32 2700", "'manual'". */ +export function parseGsettings(out: string): string | number | boolean { + const v = out.trim(); + if (v === "true" || v === "false") return v === "true"; + const n = v.match(/^(?:u?int\d*\s+)?(-?[\d.]+)$/); + if (n) return Number(n[1]); + return v.replace(/^'(.*)'$/, "$1"); +} + +/** `busctl get-property …`: "b true", "u 4500". */ +export function parseBusctl(out: string): string | number | boolean | undefined { + const m = out.trim().match(/^([a-z])\s+(.*)$/); + if (!m) return undefined; + if (m[1] === "b") return m[2] === "true"; + if ("uiynqxtd".includes(m[1])) return Number(m[2]); + return m[2].replace(/^"(.*)"$/, "$1"); +} + +/** Daemons that keep the screen warm on wlroots compositors and X11, best first. */ +export type Warmer = "hyprsunset" | "wlsunset" | "gammastep" | "redshift"; + +export function warmerCandidates(s: Session): Warmer[] { + if (s.wayland) return s.desktop === "hyprland" ? ["hyprsunset", "wlsunset", "gammastep"] : ["wlsunset", "gammastep"]; + return ["gammastep", "redshift"]; +} + +/** The daemon's command line, at a fixed temperature all day. */ +export function warmerCommand(tool: Warmer, kelvin: number): string[] { + const k = Math.round(Math.max(1000, Math.min(6500, kelvin))); + switch (tool) { + case "hyprsunset": + return ["hyprsunset", "-t", String(k)]; + case "wlsunset": + // wlsunset needs a day temperature above the night one. + return ["wlsunset", "-t", String(k), "-T", String(k + 1)]; + case "gammastep": + case "redshift": + return [tool, "-l", "0:0", "-t", `${k}:${k}`, "-r"]; + } +} + +export function systemdRunArgs(unit: string, command: string[], env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: night light", + "--collect", + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + ...command, + ]; +} + +// --------------------------------------------------------------------------- +// Monitors + +export interface Monitor { + name: string; + description: string; + make?: string; + model?: string; + serial?: string; + width: number; + height: number; + /** Hz. */ + refresh: number; + scale: number; + x: number; + y: number; + transform?: string; + enabled: boolean; + primary?: boolean; + builtin?: boolean; + vrr?: boolean; + modes: string[]; +} + +const nonEmpty = (s: unknown) => (typeof s === "string" && s.trim() && s !== "Unknown" ? s.trim() : undefined); +const isBuiltin = (name: string) => /^(eDP|LVDS|DSI)/i.test(name); +const TRANSFORMS = ["normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"]; +const modeName = (w: number, h: number, r: number) => `${w}×${h} @ ${round(r)} Hz`; +const round = (r: number) => (Math.abs(r - Math.round(r)) < 0.01 ? String(Math.round(r)) : r.toFixed(2)); + +/** `hyprctl -j monitors`. */ +export function parseHyprctl(json: string): Monitor[] { + return (JSON.parse(json) as any[]).map((m) => ({ + name: m.name, + // hyprctl appends the serial to the description. + description: (nonEmpty(m.description) ?? m.name).replace(m.serial ? ` ${m.serial}` : /$^/, ""), + make: nonEmpty(m.make), + model: nonEmpty(m.model), + serial: nonEmpty(m.serial), + width: m.width, + height: m.height, + refresh: m.refreshRate, + scale: m.scale, + x: m.x, + y: m.y, + transform: TRANSFORMS[m.transform] ?? String(m.transform), + enabled: !m.disabled && m.dpmsStatus !== false, + primary: !!m.focused, + builtin: isBuiltin(m.name), + vrr: !!m.vrr, + modes: [ + ...new Set(((m.availableModes ?? []) as string[]).map((s) => { + const x = s.match(/(\d+)x(\d+)@([\d.]+)/); + return x ? modeName(Number(x[1]), Number(x[2]), Number(x[3])) : s; + })), + ], + })); +} + +/** `swaymsg -t get_outputs -r`. */ +export function parseSway(json: string): Monitor[] { + return (JSON.parse(json) as any[]).map((o) => { + const mode = o.current_mode ?? {}; + return { + name: o.name, + description: [nonEmpty(o.make), nonEmpty(o.model)].filter(Boolean).join(" ") || o.name, + make: nonEmpty(o.make), + model: nonEmpty(o.model), + serial: nonEmpty(o.serial), + width: mode.width ?? 0, + height: mode.height ?? 0, + refresh: (mode.refresh ?? 0) / 1000, + scale: o.scale ?? 1, + x: o.rect?.x ?? 0, + y: o.rect?.y ?? 0, + transform: o.transform, + enabled: o.active !== false, + primary: !!o.focused, + builtin: isBuiltin(o.name), + vrr: o.adaptive_sync_status === "enabled", + modes: [...new Set(((o.modes ?? []) as any[]).map((m) => modeName(m.width, m.height, m.refresh / 1000)))], + }; + }); +} + +/** `wlr-randr --json`. */ +export function parseWlrRandrJson(json: string): Monitor[] { + return (JSON.parse(json) as any[]).map((o) => { + const modes = (o.modes ?? []) as any[]; + const cur = modes.find((m) => m.current) ?? modes.find((m) => m.preferred) ?? {}; + return { + name: o.name, + description: [nonEmpty(o.make), nonEmpty(o.model)].filter(Boolean).join(" ") || nonEmpty(o.description) || + o.name, + make: nonEmpty(o.make), + model: nonEmpty(o.model), + serial: nonEmpty(o.serial), + width: cur.width ?? 0, + height: cur.height ?? 0, + refresh: cur.refresh ?? 0, + scale: o.scale ?? 1, + x: o.position?.x ?? 0, + y: o.position?.y ?? 0, + transform: o.transform, + enabled: o.enabled !== false, + builtin: isBuiltin(o.name), + vrr: !!o.adaptive_sync, + modes: [...new Set(modes.map((m) => modeName(m.width, m.height, m.refresh)))], + }; + }); +} + +/** `wlr-randr` (text, for versions without --json). */ +export function parseWlrRandrText(out: string): Monitor[] { + const monitors: Monitor[] = []; + let cur: Monitor | undefined; + let inModes = false; + for (const line of out.split("\n")) { + const head = line.match(/^(\S+)\s*(?:"(.*)")?\s*$/); + if (head) { + cur = { + name: head[1], + description: head[2]?.replace(/\s*\([^)]*\)$/, "") || head[1], + width: 0, + height: 0, + refresh: 0, + scale: 1, + x: 0, + y: 0, + enabled: true, + builtin: isBuiltin(head[1]), + modes: [], + }; + monitors.push(cur); + inModes = false; + continue; + } + if (!cur) continue; + const kv = line.match(/^\s+([A-Za-z ]+):\s*(.*)$/); + if (kv) { + inModes = kv[1] === "Modes"; + const v = kv[2].trim(); + if (kv[1] === "Make") cur.make = nonEmpty(v); + else if (kv[1] === "Model") cur.model = nonEmpty(v); + else if (kv[1] === "Serial") cur.serial = v === "(null)" ? undefined : nonEmpty(v); + else if (kv[1] === "Enabled") cur.enabled = v === "yes"; + else if (kv[1] === "Scale") cur.scale = Number(v); + else if (kv[1] === "Transform") cur.transform = v; + else if (kv[1] === "Adaptive Sync") cur.vrr = v === "enabled"; + else if (kv[1] === "Position") { + const [x, y] = v.split(",").map(Number); + cur.x = x; + cur.y = y; + } + continue; + } + const mode = line.match(/^\s+(\d+)x(\d+) px, ([\d.]+) Hz(.*)$/); + if (mode && inModes) { + const [w, h, r] = [Number(mode[1]), Number(mode[2]), Number(mode[3])]; + cur.modes.push(modeName(w, h, r)); + if (mode[4].includes("current")) Object.assign(cur, { width: w, height: h, refresh: r }); + } + } + for (const m of monitors) { + if (m.make || m.model) m.description = [m.make, m.model].filter(Boolean).join(" "); + m.modes = [...new Set(m.modes)]; + } + return monitors; +} + +/** `xrandr --query`: connected outputs. */ +export function parseXrandr(out: string): Monitor[] { + const monitors: Monitor[] = []; + let cur: Monitor | undefined; + for (const line of out.split("\n")) { + const head = line.match( + /^(\S+) (connected|disconnected)( primary)?(?: (\d+)x(\d+)\+(-?\d+)\+(-?\d+))?(?: (left|right|inverted))?/, + ); + if (head) { + cur = undefined; + if (head[2] !== "connected") continue; + cur = { + name: head[1], + description: head[1], + width: Number(head[4] ?? 0), + height: Number(head[5] ?? 0), + refresh: 0, + scale: 1, + x: Number(head[6] ?? 0), + y: Number(head[7] ?? 0), + transform: head[8] ?? "normal", + enabled: head[4] !== undefined, + primary: !!head[3], + builtin: isBuiltin(head[1]), + modes: [], + }; + monitors.push(cur); + continue; + } + const mode = cur && line.match(/^\s+(\d+)x(\d+)i?\s+(.*)$/); + if (cur && mode) { + const [w, h] = [Number(mode[1]), Number(mode[2])]; + for (const r of mode[3].matchAll(/([\d.]+)(\*?)(\+?)/g)) { + cur.modes.push(modeName(w, h, Number(r[1]))); + if (r[2] === "*") cur.refresh = Number(r[1]); + } + } + } + for (const m of monitors) m.modes = [...new Set(m.modes)]; + return monitors; +} + +/** `kscreen-doctor -j` (KDE Plasma). */ +export function parseKscreen(json: string): Monitor[] { + const rotations: Record = { 1: "normal", 2: "90", 4: "180", 8: "270" }; + return ((JSON.parse(json).outputs ?? []) as any[]).filter((o) => o.connected).map((o) => { + const modes = (o.modes ?? []) as any[]; + const cur = modes.find((m) => String(m.id) === String(o.currentModeId)) ?? {}; + return { + name: o.name, + description: o.name, + width: cur.size?.width ?? o.size?.width ?? 0, + height: cur.size?.height ?? o.size?.height ?? 0, + refresh: cur.refreshRate ?? 0, + scale: o.scale ?? 1, + x: o.pos?.x ?? 0, + y: o.pos?.y ?? 0, + transform: rotations[o.rotation] ?? "normal", + enabled: !!o.enabled, + primary: o.priority === 1, + builtin: isBuiltin(o.name), + vrr: o.vrrPolicy === 1 || o.vrrPolicy === 2, + modes: [...new Set(modes.map((m) => modeName(m.size.width, m.size.height, m.refreshRate)))], + }; + }); +} + +/** `gdbus call … org.gnome.Mutter.DisplayConfig.GetCurrentState` (GNOME). */ +export function parseMutter(out: string): Monitor[] { + const heads = [...out.matchAll(/\(\('([^']*)', '([^']*)', '([^']*)', '([^']*)'\), \[\('/g)]; + const logical = [ + ...out.matchAll(/\((-?\d+), (-?\d+), ([\d.]+), (?:uint32 )?(\d+), (true|false), \[\('([^']*)'/g), + ]; + return heads.map((h, i) => { + const segment = out.slice(h.index!, heads[i + 1]?.index ?? out.length); + const modeRe = /\('([^']+)', (\d+), (\d+), ([\d.]+), ([\d.]+), \[[^\]]*\], (\{[^}]*\}|@a\{sv\} \{\})\)/g; + const modes = [...segment.matchAll(modeRe)]; + const cur = modes.find((m) => m[6].includes("'is-current': ")); + const log = logical.find((l) => l[6] === h[1]); + const display = segment.match(/'display-name': <'([^']*)'>/)?.[1]; + return { + name: h[1], + description: display ?? ([nonEmpty(h[2]), nonEmpty(h[3])].filter(Boolean).join(" ") || h[1]), + make: nonEmpty(h[2]), + model: nonEmpty(h[3]), + serial: h[4] && !/^0x0+$/.test(h[4]) ? h[4] : undefined, + width: cur ? Number(cur[2]) : 0, + height: cur ? Number(cur[3]) : 0, + refresh: cur ? Number(cur[4]) : 0, + scale: log ? Number(log[3]) : 1, + x: log ? Number(log[1]) : 0, + y: log ? Number(log[2]) : 0, + transform: log ? TRANSFORMS[Number(log[4])] : undefined, + enabled: !!cur && !!log, + primary: log?.[5] === "true", + builtin: /'is-builtin': /.test(segment) || isBuiltin(h[1]), + modes: [...new Set(modes.map((m) => modeName(Number(m[2]), Number(m[3]), Number(m[4]))))], + }; + }); +} + +export const resolution = (m: Monitor) => `${m.width} × ${m.height}`; +export const refreshText = (m: Monitor) => `${round(m.refresh)} Hz`; +export const scaleText = (s: number) => `${Number(s.toFixed(2))}×`; +/** The size the desktop lays out, e.g. 2256×1504 at 1.5 → 1504 × 1003. */ +export const logicalSize = (m: Monitor) => + `${Math.round(m.width / (m.scale || 1))} × ${Math.round(m.height / (m.scale || 1))}`; + +/** "16:9", "3:2", "21:9" (approximately). */ +export function aspect(w: number, h: number): string { + if (!w || !h) return ""; + const known: [number, number][] = [[16, 9], [16, 10], [3, 2], [4, 3], [21, 9], [32, 9], [5, 4], [1, 1]]; + const r = Math.max(w, h) / Math.min(w, h); + const best = known.reduce((a, b) => (Math.abs(b[0] / b[1] - r) < Math.abs(a[0] / a[1] - r) ? b : a)); + if (Math.abs(best[0] / best[1] - r) > 0.07) return r.toFixed(2) + ":1"; + return w >= h ? `${best[0]}:${best[1]}` : `${best[1]}:${best[0]}`; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +export const install = (family: Family | undefined, ...packages: string[]) => + `${MANAGERS[family ?? "debian"]} ${packages.join(" ")}`; diff --git a/extensions/display/lib_test.ts b/extensions/display/lib_test.ts new file mode 100644 index 0000000..7c477aa --- /dev/null +++ b/extensions/display/lib_test.ts @@ -0,0 +1,321 @@ +import { assertEquals } from "@std/assert"; +import { + aspect, + logicalSize, + parseBrightnessctl, + parseBusctl, + parseDdcDetect, + parseDdcVcp, + parseGsettings, + parseHyprctl, + parseKscreen, + parseMutter, + parseSway, + parseUdevAttrs, + parseUdevDb, + parseWlrRandrJson, + parseWlrRandrText, + parseXrandr, + percent, + rawFor, + refreshText, + scaleText, + stepped, + systemdRunArgs, + warmerCandidates, + warmerCommand, +} from "./lib.ts"; + +Deno.test("brightnessctl", () => { + const out = "intel_backlight,backlight,19200,40%,48000\nacpi_video0,backlight,5,50%,10\ninput3::capslock,leds,0,0%,1"; + const list = parseBrightnessctl(out); + assertEquals(list.map((b) => [b.id, percent(b)]), [["intel_backlight", 40], ["acpi_video0", 50]]); + assertEquals(rawFor(list[0], 50), 24000); + assertEquals(rawFor(list[0], 0), 1); // never fully black + assertEquals(rawFor({ ...list[0], kind: "ddc", max: 100 }, 0), 0); +}); + +Deno.test("sysfs through udevadm", () => { + const db = `P: /devices/pci0000:00/0000:00:02.0/drm/card1/card1-eDP-1/intel_backlight +M: intel_backlight +R: _backlight +U: backlight +E: DEVPATH=/devices/pci0000:00/0000:00:02.0/drm/card1/card1-eDP-1/intel_backlight +E: SUBSYSTEM=backlight + +P: /devices/LNXSYSTM:00 +E: SUBSYSTEM=acpi +`; + assertEquals(parseUdevDb(db, "backlight").map((d) => d.path.split("/").pop()), ["intel_backlight"]); + const attrs = ` +Udevadm info starts with the device specified by the devpath and then +walks up the chain of parent devices. + + looking at device '/devices/pci0000:00/0000:00:02.0/drm/card1/card1-eDP-1/intel_backlight': + KERNEL=="intel_backlight" + SUBSYSTEM=="backlight" + ATTR{actual_brightness}=="19200" + ATTR{brightness}=="19200" + ATTR{max_brightness}=="96000" + ATTR{type}=="raw" + + looking at parent device '/devices/pci0000:00/0000:00:02.0/drm/card1/card1-eDP-1': + ATTR{brightness}=="0" +`; + const a = parseUdevAttrs(attrs); + assertEquals([a.brightness, a.max_brightness, a.type], ["19200", "96000", "raw"]); +}); + +Deno.test("steps and presets", () => { + assertEquals([stepped(43, 10), stepped(50, 10), stepped(95, 10), stepped(100, 10)], [50, 60, 100, 100]); + assertEquals([stepped(43, -10), stepped(40, -10), stepped(10, -10), stepped(3, -10)], [40, 30, 1, 1]); +}); + +Deno.test("ddcutil", () => { + const detect = `Display 1 + I2C bus: /dev/i2c-6 + DRM connector: card1-DP-1 + Monitor: DEL:DELL U2720Q:7XYZ123 + +Display 2 + I2C bus: /dev/i2c-7 + DRM connector: card1-HDMI-A-1 + Monitor: GSM:LG ULTRAGEAR: + +Invalid display + I2C bus: /dev/i2c-4 + DRM connector: card1-eDP-1 +`; + assertEquals(parseDdcDetect(detect), [ + { display: 1, model: "DELL U2720Q", bus: "/dev/i2c-6", connector: "DP-1" }, + { display: 2, model: "LG ULTRAGEAR", bus: "/dev/i2c-7", connector: "HDMI-A-1" }, + ]); + assertEquals(parseDdcVcp("VCP 10 C 50 100"), { current: 50, max: 100 }); + assertEquals(parseDdcVcp("VCP 10 ERR"), undefined); +}); + +Deno.test("night light settings", () => { + assertEquals(parseGsettings("true\n"), true); + assertEquals(parseGsettings("uint32 2700"), 2700); + assertEquals(parseGsettings("20.0"), 20); + assertEquals(parseGsettings("'manual'"), "manual"); + assertEquals(parseBusctl("b true"), true); + assertEquals(parseBusctl("u 4500"), 4500); + assertEquals(parseBusctl('s "Automatic"'), "Automatic"); + assertEquals(warmerCandidates({ wayland: true, desktop: "hyprland" }), ["hyprsunset", "wlsunset", "gammastep"]); + assertEquals(warmerCandidates({ wayland: false, desktop: "other" }), ["gammastep", "redshift"]); + assertEquals(warmerCommand("wlsunset", 4000), ["wlsunset", "-t", "4000", "-T", "4001"]); + assertEquals(warmerCommand("gammastep", 99999), ["gammastep", "-l", "0:0", "-t", "6500:6500", "-r"]); + assertEquals(warmerCommand("hyprsunset", 3500), ["hyprsunset", "-t", "3500"]); + assertEquals(systemdRunArgs("backslash-night-light", ["wlsunset"], { WAYLAND_DISPLAY: "wayland-1" }).slice(-2), [ + "--setenv=WAYLAND_DISPLAY=wayland-1", + "wlsunset", + ]); +}); + +Deno.test("hyprctl monitors", () => { + const json = JSON.stringify([{ + id: 0, + name: "eDP-1", + description: "BOE 0x095F", + make: "BOE", + model: "0x095F", + serial: "", + width: 2256, + height: 1504, + refreshRate: 59.999, + x: 0, + y: 0, + activeWorkspace: { id: 1, name: "1" }, + scale: 1.57, + transform: 0, + focused: true, + dpmsStatus: true, + vrr: false, + disabled: false, + availableModes: ["2256x1504@60.00Hz", "2256x1504@48.00Hz"], + }]); + const [m] = parseHyprctl(json); + assertEquals([m.name, m.description, m.width, m.height, m.primary, m.builtin, m.transform], [ + "eDP-1", + "BOE 0x095F", + 2256, + 1504, + true, + true, + "normal", + ]); + assertEquals(m.modes, ["2256×1504 @ 60 Hz", "2256×1504 @ 48 Hz"]); + assertEquals([refreshText(m), scaleText(m.scale), logicalSize(m)], ["60 Hz", "1.57×", "1437 × 958"]); +}); + +Deno.test("sway outputs", () => { + const json = JSON.stringify([{ + name: "DP-1", + make: "Dell Inc.", + model: "DELL U2720Q", + serial: "ABC123", + active: true, + scale: 1.5, + transform: "normal", + current_mode: { width: 3840, height: 2160, refresh: 59997 }, + modes: [{ width: 3840, height: 2160, refresh: 59997 }, { width: 1920, height: 1080, refresh: 60000 }], + rect: { x: 1504, y: 0, width: 2560, height: 1440 }, + focused: false, + adaptive_sync_status: "disabled", + }]); + const [m] = parseSway(json); + assertEquals([m.description, m.refresh, m.x, m.builtin, m.vrr], [ + "Dell Inc. DELL U2720Q", + 59.997, + 1504, + false, + false, + ]); + assertEquals(m.modes, ["3840×2160 @ 60 Hz", "1920×1080 @ 60 Hz"]); +}); + +Deno.test("wlr-randr", () => { + const json = JSON.stringify([{ + name: "eDP-1", + description: "Sharp Corporation 0x14F9 (eDP-1)", + make: "Sharp Corporation", + model: "0x14F9", + serial: null, + enabled: true, + modes: [{ width: 2256, height: 1504, refresh: 59.999001, preferred: true, current: true }], + position: { x: 0, y: 0 }, + transform: "normal", + scale: 1.5, + adaptive_sync: false, + }]); + const [j] = parseWlrRandrJson(json); + assertEquals([j.description, j.width, j.scale, j.serial], ["Sharp Corporation 0x14F9", 2256, 1.5, undefined]); + + const text = `eDP-1 "Sharp Corporation 0x14F9 (eDP-1)" + Make: Sharp Corporation + Model: 0x14F9 + Serial: (null) + Physical size: 290x190 mm + Enabled: yes + Modes: + 2256x1504 px, 59.999001 Hz (preferred, current) + 1920x1200 px, 59.950001 Hz + Position: 0,0 + Transform: normal + Scale: 1.500000 + Adaptive Sync: disabled +HDMI-A-1 "LG Electronics LG ULTRAGEAR (HDMI-A-1)" + Make: LG Electronics + Model: LG ULTRAGEAR + Serial: 0x0000BEEF + Enabled: no + Modes: + 2560x1440 px, 143.912003 Hz (preferred) + Position: 2256,0 + Transform: 90 + Scale: 1.000000 +`; + const [a, b] = parseWlrRandrText(text); + assertEquals([a.description, a.width, a.height, a.refresh, a.scale, a.serial, a.vrr], [ + "Sharp Corporation 0x14F9", + 2256, + 1504, + 59.999001, + 1.5, + undefined, + false, + ]); + assertEquals(a.modes, ["2256×1504 @ 60 Hz", "1920×1200 @ 59.95 Hz"]); + assertEquals([b.enabled, b.x, b.transform, b.serial], [false, 2256, "90", "0x0000BEEF"]); +}); + +Deno.test("xrandr", () => { + const out = `Screen 0: minimum 320 x 200, current 4480 x 1440, maximum 16384 x 16384 +eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 193mm + 1920x1080 60.02*+ 59.97 48.00 + 1680x1050 59.95 +HDMI-1 connected 1440x2560+1920+0 left (normal left inverted right x axis y axis) 597mm x 336mm + 2560x1440 59.95 + 143.97* +DP-1 disconnected (normal left inverted right x axis y axis) +DP-2 connected (normal left inverted right x axis y axis) + 1920x1080 60.00 + +`; + const list = parseXrandr(out); + assertEquals(list.map((m) => [m.name, m.width, m.height, m.refresh, m.primary, m.enabled, m.transform]), [ + ["eDP-1", 1920, 1080, 60.02, true, true, "normal"], + ["HDMI-1", 1440, 2560, 143.97, false, true, "left"], + ["DP-2", 0, 0, 0, false, false, "normal"], + ]); + assertEquals(list[0].modes, [ + "1920×1080 @ 60.02 Hz", + "1920×1080 @ 59.97 Hz", + "1920×1080 @ 48 Hz", + "1680×1050 @ 59.95 Hz", + ]); +}); + +Deno.test("kscreen-doctor", () => { + const json = JSON.stringify({ + outputs: [ + { + connected: true, + enabled: true, + currentModeId: "2", + id: 1, + modes: [ + { id: "1", name: "2560x1440@60", refreshRate: 59.951, size: { width: 2560, height: 1440 } }, + { id: "2", name: "2560x1440@144", refreshRate: 143.912, size: { width: 2560, height: 1440 } }, + ], + name: "DP-2", + pos: { x: 0, y: 0 }, + priority: 1, + rotation: 1, + scale: 1.25, + vrrPolicy: 2, + }, + { connected: false, name: "HDMI-A-1", modes: [] }, + ], + }); + const list = parseKscreen(json); + assertEquals(list.length, 1); + assertEquals([list[0].refresh, list[0].scale, list[0].primary, list[0].vrr], [143.912, 1.25, true, true]); + assertEquals(refreshText(list[0]), "143.91 Hz"); +}); + +Deno.test("GNOME Mutter state", () => { + const out = + `(uint32 2, [(('eDP-1', 'BOE', '0x095f', '0x00000000'), [('2256x1504@59.999', 2256, 1504, 59.999275207519531, 1.5666666030883789, [1.0, 1.25, 1.5666666030883789, 2.0], {'is-current': , 'is-preferred': }), ('1920x1200@59.950', 1920, 1200, 59.950172424316406, 1.25, [1.0, 1.25, 1.5], @a{sv} {})], {'is-builtin': , 'display-name': <'Built-in display'>, 'is-underscanning': }), (('DP-3', 'DEL', 'DELL U2720Q', 'ABCD123'), [('3840x2160@59.997', 3840, 2160, 59.996623992919922, 2.0, [1.0, 2.0], {'is-current': , 'is-preferred': })], {'is-builtin': , 'display-name': <'Dell Inc. 27"'>})], [(0, 0, 1.5, uint32 0, true, [('eDP-1', 'BOE', '0x095f', '0x00000000')], @a{sv} {}), (1504, 0, 2.0, 1, false, [('DP-3', 'DEL', 'DELL U2720Q', 'ABCD123')], @a{sv} {})], {'layout-mode': , 'supports-changing-layout-mode': })`; + const [a, b] = parseMutter(out); + assertEquals([a.name, a.description, a.width, a.height, a.scale, a.primary, a.builtin, a.serial], [ + "eDP-1", + "Built-in display", + 2256, + 1504, + 1.5, + true, + true, + undefined, + ]); + assertEquals(a.modes, ["2256×1504 @ 60 Hz", "1920×1200 @ 59.95 Hz"]); + assertEquals([b.name, b.description, b.x, b.scale, b.transform, b.serial, b.builtin], [ + "DP-3", + 'Dell Inc. 27"', + 1504, + 2, + "90", + "ABCD123", + false, + ]); +}); + +Deno.test("aspect ratios", () => { + assertEquals([aspect(1920, 1080), aspect(2256, 1504), aspect(2560, 1600), aspect(3440, 1440), aspect(1080, 1920)], [ + "16:9", + "3:2", + "16:10", + "21:9", + "9:16", + ]); + assertEquals(aspect(0, 0), ""); +}); diff --git a/extensions/display/quick.ts b/extensions/display/quick.ts new file mode 100644 index 0000000..11a13aa --- /dev/null +++ b/extensions/display/quick.ts @@ -0,0 +1,35 @@ +// Shared by the no-view commands. +import { type Backlight, install, percent, stepped } from "./lib.ts"; +import { backlights, ddcDisplays, family, nightLight, setBacklight, setNightLight } from "./system.ts"; + +async function all(ddc: boolean): Promise<{ list: Backlight[]; via: Awaited>["via"] }> { + const bl = await backlights(); + const list = [...bl.list, ...(ddc ? await ddcDisplays() : [])]; + if (!list.length) { + throw new Error( + `No adjustable backlight: install brightnessctl (${ + install(await family(), "brightnessctl") + }), or ddcutil for external monitors`, + ); + } + return { list, via: bl.via }; +} + +/** Sets every screen to `pct`, or steps it; returns the new level of the first one. */ +export async function brightness(target: number | { step: number }, ddc: boolean): Promise { + const { list, via } = await all(ddc); + let first: number | undefined; + for (const b of list) { + const pct = typeof target === "number" ? target : stepped(percent(b), target.step); + await setBacklight(b, pct, via); + first ??= pct; + } + return `Brightness ${first} %`; +} + +export async function toggleNight(kelvin: number): Promise { + const n = await nightLight(); + if (!n) throw new Error("No night light: install wlsunset, hyprsunset or gammastep"); + await setNightLight(!n.enabled, kelvin); + return `Night light ${n.enabled ? "off" : "on"}`; +} diff --git a/extensions/display/system.ts b/extensions/display/system.ts new file mode 100644 index 0000000..e3c0040 --- /dev/null +++ b/extensions/display/system.ts @@ -0,0 +1,292 @@ +// Talking to brightnessctl / logind / ddcutil, the desktop's night light, and +// the compositor's outputs. + +import { exec } from "@backslash/api"; +import { + type Backlight, + type DdcDisplay, + detectSession, + type Env, + type Family, + type Monitor, + osFamily, + parseBrightnessctl, + parseBusctl, + parseDdcDetect, + parseDdcVcp, + parseGsettings, + parseHyprctl, + parseKscreen, + parseMutter, + parseSway, + parseUdevAttrs, + parseUdevDb, + parseWlrRandrJson, + parseWlrRandrText, + parseXrandr, + rawFor, + type Session, + SESSION_ENV, + systemdRunArgs, + type Warmer, + warmerCandidates, + warmerCommand, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key); + } catch { + return undefined; + } +}; +const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +export const session = (): Session => detectSession(sessionEnv()); + +export const missing = (e: unknown) => + e instanceof Deno.errors.NotFound || e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied; + +export async function family(): Promise { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +// --------------------------------------------------------------------------- +// Brightness + +export interface Backlights { + list: Backlight[]; + /** How we read and write them. */ + via?: "brightnessctl" | "logind"; +} + +/** Built-in panels: brightnessctl, else the kernel's values through udevadm (written via logind). */ +export async function backlights(): Promise { + try { + const list = parseBrightnessctl(await exec("brightnessctl", ["-l", "-m", "-c", "backlight"])); + if (list.length) return { list, via: "brightnessctl" }; + } catch { + // Not installed: read sysfs through udev. + } + try { + const devices = parseUdevDb(await exec("udevadm", ["info", "--export-db"]), "backlight"); + const list = await Promise.all(devices.map(async ({ path }) => { + const id = path.split("/").pop()!; + const attrs = parseUdevAttrs(await exec("udevadm", ["info", "-a", "-p", path])); + return { + id, + name: id, + current: Number(attrs.brightness), + max: Number(attrs.max_brightness), + kind: "backlight" as const, + }; + })); + const ok = list.filter((b) => b.max > 0 && Number.isFinite(b.current)); + return { list: ok, via: ok.length ? "logind" : undefined }; + } catch { + return { list: [] }; + } +} + +export async function setBacklight(b: Backlight, pct: number, via: Backlights["via"]): Promise { + if (b.kind === "ddc") { + await exec("ddcutil", ["--display", b.id.slice(4), "setvcp", "10", String(rawFor(b, pct))]); + return; + } + if (via === "brightnessctl") { + await exec("brightnessctl", ["-q", "-d", b.id, "set", String(rawFor(b, pct))]); + return; + } + // logind lets the session's user set the backlight without root. + await exec("busctl", [ + "call", + "org.freedesktop.login1", + "/org/freedesktop/login1/session/auto", + "org.freedesktop.login1.Session", + "SetBrightness", + "ssu", + "backlight", + b.id, + String(rawFor(b, pct)), + ]); +} + +/** External monitors over DDC/CI (slow: about a second each). */ +export async function ddcDisplays(): Promise { + let found: DdcDisplay[]; + try { + found = parseDdcDetect(await exec("ddcutil", ["detect", "--brief"])); + } catch { + return []; + } + const out = await Promise.all(found.map(async (d) => { + const v = parseDdcVcp( + await exec("ddcutil", ["--display", String(d.display), "--brief", "getvcp", "10"]).catch(() => ""), + ); + return v ? [{ id: `ddc:${d.display}`, name: d.model, current: v.current, max: v.max, kind: "ddc" as const }] : []; + })); + return out.flat(); +} + +// --------------------------------------------------------------------------- +// Night light + +export interface NightLight { + backend: "gnome" | "kde" | Warmer; + enabled: boolean; + /** Warming the screen right now (GNOME and KDE follow a schedule). */ + active?: boolean; + temperature?: number; + schedule?: string; +} + +const GNOME = "org.gnome.settings-daemon.plugins.color"; +const gsettings = async (key: string) => parseGsettings(await exec("gsettings", ["get", GNOME, key])); +const kwin = async (iface: string, path: string, prop: string) => + parseBusctl(await exec("busctl", ["--user", "get-property", "org.kde.KWin", path, iface, prop])); + +export const NIGHT_UNIT = "backslash-night-light"; + +async function warmer(s: Session): Promise { + for (const tool of warmerCandidates(s)) { + try { + await new Deno.Command(tool, { + args: ["-h"], + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }) + .output(); + return tool; + } catch (e) { + if (!missing(e)) return tool; + } + } + return undefined; +} + +/** The desktop's night light, or the daemon we run on wlroots and X11. */ +export async function nightLight(): Promise { + const s = session(); + if (s.desktop === "gnome") { + try { + const enabled = (await gsettings("night-light-enabled")) === true; + const [temperature, automatic, from, to] = await Promise.all([ + gsettings("night-light-temperature"), + gsettings("night-light-schedule-automatic"), + gsettings("night-light-schedule-from"), + gsettings("night-light-schedule-to"), + ]); + const clock = (h: unknown) => { + const n = Number(h); + return `${String(Math.floor(n)).padStart(2, "0")}:${String(Math.round((n % 1) * 60)).padStart(2, "0")}`; + }; + return { + backend: "gnome", + enabled, + temperature: Number(temperature) || undefined, + schedule: automatic ? "Sunset to sunrise" : `${clock(from)} to ${clock(to)}`, + }; + } catch { + // gsettings or the schema is missing. + } + } + if (s.desktop === "kde") { + for ( + const [iface, path] of [["org.kde.KWin.NightLight", "/org/kde/KWin/NightLight"], [ + "org.kde.kwin.ColorCorrect", + "/ColorCorrect", + ]] + ) { + try { + const enabled = (await kwin(iface, path, "enabled")) === true; + const active = await kwin(iface, path, "running").catch(() => undefined); + const temperature = await kwin(iface, path, "currentTemperature").catch(() => undefined); + return { backend: "kde", enabled, active: active === true, temperature: Number(temperature) || undefined }; + } catch { + // Older or newer Plasma: try the other interface. + } + } + } + const tool = await warmer(s); + if (!tool) return undefined; + const state = await exec("systemctl", ["--user", "is-active", NIGHT_UNIT]).catch(() => "inactive"); + return { backend: tool, enabled: state === "active" || state === "activating", active: state === "active" }; +} + +export async function setNightLight(on: boolean, kelvin: number): Promise { + const n = await nightLight(); + if (!n) throw new Error("No night light found"); + if (n.backend === "gnome") { + await exec("gsettings", ["set", GNOME, "night-light-enabled", String(on)]); + return; + } + if (n.backend === "kde") { + let written = false; + for (const tool of ["kwriteconfig6", "kwriteconfig5"]) { + try { + await exec(tool, ["--file", "kwinrc", "--group", "NightColor", "--key", "Active", String(on)]); + written = true; + break; + } catch (e) { + if (!missing(e)) throw e; + } + } + if (!written) throw new Error("kwriteconfig6 is needed to change KDE's night light"); + await exec("busctl", ["--user", "call", "org.kde.KWin", "/KWin", "org.kde.KWin", "reconfigure"]); + return; + } + if (!on) { + await exec("systemctl", ["--user", "stop", NIGHT_UNIT]).catch(() => {}); + return; + } + await exec("systemctl", ["--user", "reset-failed", NIGHT_UNIT]).catch(() => {}); + await exec("systemd-run", systemdRunArgs(NIGHT_UNIT, warmerCommand(n.backend, kelvin), sessionEnv())); +} + +// --------------------------------------------------------------------------- +// Monitors + +type Source = [string, string[], (out: string) => Monitor[]]; + +const MUTTER: Source = [ + "gdbus", + [ + "call", + "--session", + "--dest", + "org.gnome.Mutter.DisplayConfig", + "--object-path", + "/org/gnome/Mutter/DisplayConfig", + "--method", + "org.gnome.Mutter.DisplayConfig.GetCurrentState", + ], + parseMutter, +]; +const SOURCES: Record = { + hyprland: ["hyprctl", ["-j", "monitors", "all"], parseHyprctl], + sway: ["swaymsg", ["-t", "get_outputs", "-r"], parseSway], + kde: ["kscreen-doctor", ["-j"], parseKscreen], + gnome: MUTTER, + wlr: ["wlr-randr", ["--json"], parseWlrRandrJson], + wlrText: ["wlr-randr", [], parseWlrRandrText], + xrandr: ["xrandr", ["--query"], parseXrandr], +}; + +/** The connected monitors, from the first source that answers. */ +export async function monitors(): Promise<{ list: Monitor[]; source?: string }> { + const s = session(); + const order = s.wayland + ? [s.desktop, "wlr", "wlrText", "hyprland", "sway", "kde", "gnome"] + : [s.desktop === "gnome" ? "gnome" : "", "xrandr"]; + for (const key of [...new Set(order)].filter((k) => SOURCES[k])) { + const [program, args, parse] = SOURCES[key]; + try { + const list = parse(await exec(program, args)); + if (list.length) return { list, source: program }; + } catch { + // Next source. + } + } + return { list: [] }; +} diff --git a/extensions/ocr/backslash.toml b/extensions/ocr/backslash.toml new file mode 100644 index 0000000..65a0031 --- /dev/null +++ b/extensions/ocr/backslash.toml @@ -0,0 +1,49 @@ +[extension] +id = "ocr" +title = "Text from Screen" +description = "Select any part of the screen and copy the text in it, recognized offline with Tesseract" +version = "1.0.0" +author = "Backslash" +icon = "bs-scan" +license = "MIT" + +# The selection runs in the background (systemd-run) once the launcher is +# hidden: grim + slurp (Sway, Hyprland, wlroots), gnome-screenshot (GNOME), +# spectacle (KDE), maim, scrot or ImageMagick's import (X11), then tesseract. +# The grabbers are only started here to check that they are installed. +# Recognized texts are kept in ~/.cache/backslash/ocr (the last 30). +[permissions] +run = ["systemd-run", "tesseract", "grim", "slurp", "gnome-screenshot", "spectacle", "maim", "scrot", "import"] +read = ["~/.cache/backslash/ocr", "/etc/os-release"] +write = ["~/.cache/backslash/ocr"] +env = [ + "HOME", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] + +[[commands]] +name = "ocr" +title = "Copy Text from Screen" +description = "Select an area: its text goes to the clipboard" +icon = "bs-scan" +mode = "no-view" +keywords = ["ocr", "text", "texte", "écran", "screen", "copy", "copier", "tesseract", "recognize", "reconnaissance", "extract"] + +[[commands]] +name = "screen-text" +title = "Text from Screen" +description = "Read text, find what you read, and the installed languages" +icon = "bs-text" +keywords = ["ocr", "text", "texte", "langues", "languages", "tesseract", "history", "historique"] + +[[preferences]] +name = "languages" +title = "Languages" +description = "Tesseract codes joined by +, e.g. eng+fra (see the installed ones in Text from Screen)" +default = "eng" diff --git a/extensions/ocr/commands/ocr.ts b/extensions/ocr/commands/ocr.ts new file mode 100644 index 0000000..c977674 --- /dev/null +++ b/extensions/ocr/commands/ocr.ts @@ -0,0 +1,23 @@ +import { run } from "@backslash/api"; +import { describeLanguages, grabberInstall, installTesseract } from "../lib.ts"; +import { family, Missing, preferredLanguages, recognize, session } from "../system.ts"; + +// Hides the launcher right away; the result arrives as a notification and +// in the clipboard. +export default run(async ({ preferences }) => { + try { + const languages = preferredLanguages(preferences); + // The message flashes for half a second before the launcher hides. + await recognize(languages, { wait: 0.9 }); + return `Select the text to read (${describeLanguages(languages)})`; + } catch (e) { + if (!(e instanceof Missing)) throw e; + const fam = (await family()) ?? "debian"; + const how = e.message.startsWith("Tesseract has no") + ? "open Text from Screen to see the installed languages" + : e.message.startsWith("Tesseract") + ? installTesseract(fam) + : grabberInstall(session(), fam); + throw new Error(`${e.message}: ${how}`); + } +}); diff --git a/extensions/ocr/commands/screen-text.ts b/extensions/ocr/commands/screen-text.ts new file mode 100644 index 0000000..1d0ec76 --- /dev/null +++ b/extensions/ocr/commands/screen-text.ts @@ -0,0 +1,207 @@ +import { action, close, copy, Icons, type Item, list, meta, paste, refresh, submenu, toast } from "@backslash/api"; +import { + ago, + describeLanguages, + type Family, + FAMILY_NAMES, + firstLine, + grabberInstall, + installLanguage, + installTesseract, + languageName, + verbatim, + words, +} from "../lib.ts"; +import { + family, + history, + Missing, + preferredLanguages, + recognize, + type Recognized, + type Setup, + setup, +} from "../system.ts"; + +const families = (fam?: Family): Family[] => fam ? [fam] : ["debian", "fedora", "arch", "suse"]; + +const read = (languages: string, s: Setup) => async () => { + try { + await recognize(languages, { known: s }); + } catch (e) { + if (e instanceof Missing) { + await toast(e.message, { style: "failure" }); + return; + } + throw e; + } + return close(); +}; + +function readItems(s: Setup, languages: string, fam?: Family): Item[] { + if (!s.tesseract) { + return [{ + id: "missing-tesseract", + title: "Tesseract is not installed", + subtitle: "the text recognition engine", + icon: { source: Icons.alert, tint: "orange" }, + section: "Read", + detail: { + markdown: [ + "Backslash reads text with **Tesseract**, offline. Install it with the languages you read, then open this command again.", + ...families(fam).map((f) => + `**${FAMILY_NAMES[f]}**\n\n\`${installTesseract(f, [...new Set(["eng", ...languages.split("+")])])}\`` + ), + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + if (!s.grabber || (s.grabber === "grim" && !s.slurp)) { + return [{ + id: "missing-grabber", + title: "Nothing to select an area with", + subtitle: s.grabber === "grim" ? "slurp is missing" : "no screenshot tool", + icon: { source: Icons.alert, tint: "orange" }, + section: "Read", + detail: { + markdown: [ + "To read text, Backslash first grabs the area you select with **grim + slurp** (Sway, Hyprland, wlroots), **gnome-screenshot** (GNOME), **spectacle** (KDE) or **maim** (X11).", + ...families(fam).map((f) => `**${FAMILY_NAMES[f]}**\n\n\`${grabberInstall(s.session, f)}\``), + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + const others = s.languages.filter((l) => !languages.split("+").includes(l)); + return [{ + id: "read", + title: "Read Text from Screen", + subtitle: describeLanguages(languages), + icon: { source: Icons.scan, tint: "blue" }, + section: "Read", + keywords: ["ocr", "select", "sélection", "texte"], + detail: { + markdown: + "Select an area: its text is copied to the clipboard and kept below. A notification shows what was read.", + metadata: [ + meta.tags("Languages", languages.split("+").map((l) => ({ text: languageName(l), color: "blue" as const }))), + meta.text("Engine", `Tesseract ${s.tesseract}`, { icon: Icons.sparkle }), + meta.text("Selection", s.grabber === "grim" ? "grim + slurp" : s.grabber, { icon: Icons.scan }), + meta.text("Offline", "Nothing leaves your computer", { icon: Icons.lock }), + ], + }, + actions: [ + action("Read Text", read(languages, s), { icon: Icons.scan }), + ...(others.length + ? [submenu( + "Read in Another Language", + others.map((l) => action(languageName(l), read(l, s), { icon: Icons.globe })), + { icon: Icons.globe, shortcut: "l" }, + )] + : []), + ], + }]; +} + +function recentItem(r: Recognized): Item { + const n = words(r.text); + return { + id: r.path, + title: firstLine(r.text) || "(blank)", + subtitle: ago(r.date), + icon: { source: Icons.text, tint: "gray" }, + section: "Recent", + keywords: [r.text.slice(0, 500)], + accessories: [{ text: `${n} word${n === 1 ? "" : "s"}` }], + detail: { + markdown: verbatim(r.text), + metadata: [ + meta.text("Read", new Date(r.date).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + meta.text("Words", n), + meta.text("Characters", r.text.length), + ], + }, + actions: [ + copy(r.text, "Copy Text"), + paste(r.text, "Paste Text"), + copy(r.text.replace(/\s*\n\s*/g, " ").trim(), "Copy as One Line", { section: "Copy" }), + action("Delete", async () => { + await Deno.remove(r.path); + return refresh(); + }, { icon: Icons.trash, destructive: true, shortcut: "Delete", section: "Danger" }), + action("Clear History", async () => { + for (const h of await history()) await Deno.remove(h.path).catch(() => {}); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: "Delete every recognized text?", section: "Danger" }), + ], + }; +} + +function languageItem(code: string, chosen: string[], s: Setup, fam?: Family): Item { + const active = chosen.includes(code); + return { + id: `lang:${code}`, + title: languageName(code), + subtitle: code, + icon: { source: Icons.globe, tint: active ? "green" : "gray" }, + section: "Installed Languages", + keywords: [code], + accessories: active ? [{ tag: "Default", color: "green" }] : [], + detail: { + markdown: active + ? "Used by **Read Text from Screen**. Change the languages in the extension's preferences." + : `Add \`${code}\` to the **Languages** preference (e.g. \`eng+${code}\`) to read it by default.`, + metadata: [ + meta.text("Code", code, { icon: Icons.tag }), + meta.text("Default", active ? "Yes" : "No", { icon: active ? Icons.check : Icons.minus }), + ...(fam && code !== "osd" ? [meta.text("Package", installLanguage(fam, code).split(" ").pop()!)] : []), + ], + }, + actions: [ + action(`Read Text in ${languageName(code)}`, read(code, s), { icon: Icons.scan }), + copy(code, "Copy Code", { section: "Copy" }), + copy([...new Set([...chosen, code])].join("+"), "Copy Preference Value", { section: "Copy" }), + ], + }; +} + +const moreLanguages = (fam?: Family): Item => ({ + id: "more-languages", + title: "Add a Language…", + subtitle: "install its model", + icon: { source: Icons.plus, tint: "gray" }, + section: "Installed Languages", + detail: { + markdown: [ + "Tesseract reads one model per language. Install the ones you need, e.g. French:", + ...families(fam).map((f) => `**${FAMILY_NAMES[f]}**\n\n\`${installLanguage(f, "fra")}\``), + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], +}); + +export default list({ + placeholder: "Read text, or search what you read…", + empty: { title: "Nothing here", icon: Icons.scan }, + status: (items) => { + const n = items.filter((i) => i.section === "Recent").length; + const langs = items.filter((i) => i.section === "Installed Languages" && i.id !== "more-languages").length; + return [n && `${n} recognized text${n > 1 ? "s" : ""}`, langs && `${langs} languages`].filter(Boolean).join( + " · ", + ) || + "Text from Screen"; + }, + items: async ({ preferences }) => { + const [s, fam, recent] = await Promise.all([setup(), family(), history()]); + const languages = preferredLanguages(preferences); + const chosen = languages.split("+"); + return [ + ...readItems(s, languages, fam), + ...recent.map(recentItem), + ...(s.tesseract ? [...s.languages.map((l) => languageItem(l, chosen, s, fam)), moreLanguages(fam)] : []), + ]; + }, +}); diff --git a/extensions/ocr/lib.ts b/extensions/ocr/lib.ts new file mode 100644 index 0000000..8dfc7b2 --- /dev/null +++ b/extensions/ocr/lib.ts @@ -0,0 +1,285 @@ +// Choosing the selection tool, building the background script, and reading +// tesseract's output. Pure, tested in lib_test.ts. + +export type Desktop = "gnome" | "kde" | "hyprland" | "sway" | "other"; +export interface Session { + wayland: boolean; + desktop: Desktop; +} +export type Env = Partial>; + +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] as const; + +export function detectSession(env: Env): Session { + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const current = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const desktop: Desktop = env.HYPRLAND_INSTANCE_SIGNATURE || current.includes("hyprland") + ? "hyprland" + : env.SWAYSOCK || current.includes("sway") + ? "sway" + : current.some((d) => ["gnome", "ubuntu", "unity", "pop"].includes(d)) + ? "gnome" + : current.includes("kde") + ? "kde" + : "other"; + return { wayland, desktop }; +} + +export type Grabber = "grim" | "gnome-screenshot" | "spectacle" | "maim" | "scrot" | "import"; + +/** Tools that can grab a selected area in this session, best first. */ +export function grabberCandidates(s: Session): Grabber[] { + if (s.wayland) return s.desktop === "gnome" ? ["gnome-screenshot"] : s.desktop === "kde" ? ["spectacle"] : ["grim"]; + const x11: Grabber[] = ["maim", "scrot", "import"]; + return s.desktop === "gnome" ? ["gnome-screenshot", ...x11] : s.desktop === "kde" ? ["spectacle", ...x11] : x11; +} + +/** Single-quotes a string for sh. */ +export const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +/** Grabs a selected area into `$img`. */ +export function grabArea(tool: Grabber): string { + switch (tool) { + case "grim": + return `geometry=$(slurp) || exit 0; grim -g "$geometry" "$img"`; + case "gnome-screenshot": + return `gnome-screenshot -a -f "$img"`; + case "spectacle": + return `spectacle -b -n -r -o "$img"`; + case "maim": + return `maim -s "$img"`; + case "scrot": + return `scrot -s "$img"`; + case "import": + return `import "$img"`; + } +} + +/** "eng, fra" / "eng+fra" / "English" → "eng+fra"; undefined when nothing usable. */ +export function normalizeLanguages(text: string | undefined): string | undefined { + const codes = (text ?? "").toLowerCase().split(/[\s,;+]+/).map((c) => NAME_TO_CODE[c] ?? c).filter((c) => + /^[a-z][a-z0-9_]*$/.test(c) + ); + return codes.length ? [...new Set(codes)].join("+") : undefined; +} + +export interface OcrOptions { + tool: Grabber; + languages: string; + /** Where recognized texts are kept, one file each. */ + historyDir: string; + wayland: boolean; + keep?: number; + /** Seconds before selecting: the launcher must be gone. */ + wait?: number; +} + +/** The background script: select, recognize, copy, remember, notify. */ +export function ocrScript(o: OcrOptions): string { + const h = q(o.historyDir); + const copy = o.wayland + ? `printf '%s' "$text" | wl-copy 2>/dev/null || printf '%s' "$text" | xclip -selection clipboard 2>/dev/null` + : `printf '%s' "$text" | xclip -selection clipboard 2>/dev/null || printf '%s' "$text" | wl-copy 2>/dev/null`; + return [ + `sleep ${o.wait ?? 0.4}`, + `img="\${XDG_RUNTIME_DIR:-/tmp}/backslash-ocr-$$.png"`, + `trap 'rm -f "$img"' EXIT`, + `${grabArea(o.tool)} || exit 0`, + `[ -s "$img" ] || exit 0`, + `text=$(tesseract "$img" - -l ${q(o.languages)} 2>/dev/null | tr -d '\\f')`, + `if [ -z "$(printf '%s' "$text" | tr -d '[:space:]')" ]; then`, + ` notify-send -a Backslash 'Text from Screen' 'No text found in the selection' 2>/dev/null; exit 0`, + "fi", + `mkdir -p ${h} && printf '%s\\n' "$text" > ${h}/"$(date +%s)".txt`, + `ls -1t ${h}/*.txt 2>/dev/null | tail -n +${(o.keep ?? 30) + 1} | xargs -r rm -f`, + `if ${copy}; then title='Text copied'; else title='Text recognized'; fi`, + `notify-send -a Backslash "$title" "$(printf '%s' "$text" | head -c 240)" 2>/dev/null`, + "true", + ].join("\n"); +} + +/** `systemd-run` arguments to run the script outside the launcher. */ +export function systemdRunArgs(unit: string, script: string, env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: text from screen", + "--collect", + // wl-copy and xclip stay in the background to serve the clipboard. + "--property=KillMode=process", + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + "/bin/sh", + "-c", + script, + ]; +} + +/** `tesseract --list-langs` → codes, without the `osd` helper model. */ +export function parseLanguages(out: string): string[] { + return out.split("\n").map((l) => l.trim()).filter((l) => + l && !/^list of/i.test(l) && l !== "osd" && !l.includes(" ") + ) + .sort((a, b) => languageName(a).localeCompare(languageName(b))); +} + +/** `tesseract --version` → "5.3.4". */ +export const parseVersion = (out: string) => out.match(/tesseract\s+v?(\d+(?:\.\d+)*)/i)?.[1]; + +const NAMES: Record = { + afr: "Afrikaans", + ara: "Arabic", + bel: "Belarusian", + ben: "Bengali", + bul: "Bulgarian", + cat: "Catalan", + ces: "Czech", + chi_sim: "Chinese (Simplified)", + chi_tra: "Chinese (Traditional)", + dan: "Danish", + deu: "German", + ell: "Greek", + eng: "English", + epo: "Esperanto", + est: "Estonian", + eus: "Basque", + fas: "Persian", + fin: "Finnish", + fra: "French", + gle: "Irish", + glg: "Galician", + heb: "Hebrew", + hin: "Hindi", + hrv: "Croatian", + hun: "Hungarian", + ind: "Indonesian", + isl: "Icelandic", + ita: "Italian", + jpn: "Japanese", + kor: "Korean", + lat: "Latin", + lav: "Latvian", + lit: "Lithuanian", + nld: "Dutch", + nor: "Norwegian", + pol: "Polish", + por: "Portuguese", + ron: "Romanian", + rus: "Russian", + slk: "Slovak", + slv: "Slovenian", + spa: "Spanish", + srp: "Serbian", + swe: "Swedish", + tha: "Thai", + tur: "Turkish", + ukr: "Ukrainian", + vie: "Vietnamese", + equ: "Math / equations", +}; + +const NAME_TO_CODE: Record = Object.fromEntries([ + ...Object.entries(NAMES).map(([code, name]) => [name.toLowerCase(), code]), + ["french", "fra"], + ["français", "fra"], + ["anglais", "eng"], + ["german", "deu"], + ["allemand", "deu"], + ["espagnol", "spa"], +]); + +export const languageName = (code: string) => + NAMES[code] ?? (code.startsWith("script/") ? `${code.slice(7).replace(/_/g, " ")} (script)` : code); + +export const describeLanguages = (codes: string) => codes.split("+").map(languageName).join(" + "); + +/** First line of a recognized text, for a title. */ +export function firstLine(text: string, max = 70): string { + const line = text.split("\n").map((l) => l.trim()).find(Boolean) ?? ""; + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +export const words = (text: string) => text.split(/\s+/).filter(Boolean).length; + +/** Text shown verbatim in a detail panel (the markdown subset has no escapes). */ +export const verbatim = (text: string) => "```\n" + text.replace(/```/g, "ˋˋˋ").trimEnd() + "\n```"; + +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, (now - ms) / 1000); + if (s < 45) return "just now"; + if (s < 3600) return `${Math.round(s / 60)} min ago`; + if (s < 86400) return `${Math.round(s / 3600)} h ago`; + const days = Math.round(s / 86400); + return days === 1 ? "yesterday" : `${days} days ago`; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +export const FAMILY_NAMES: Record = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +/** The package with tesseract's model for a language, per distribution. */ +export function languagePackage(code: string, family: Family): string { + switch (family) { + case "debian": + return `tesseract-ocr-${code.replace(/_/g, "-")}`; + case "arch": + return `tesseract-data-${code}`; + case "fedora": + return `tesseract-langpack-${code}`; + case "suse": + return `tesseract-ocr-traineddata-${(NAMES[code] ?? code).toLowerCase().replace(/\W.*$/, "")}`; + } +} + +const MANAGERS: Record = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +/** The command installing tesseract (with English) or more languages. */ +export function installTesseract(family: Family, languages: string[] = ["eng"]): string { + const base = family === "debian" || family === "suse" ? "tesseract-ocr" : "tesseract"; + return `${MANAGERS[family]} ${[base, ...languages.map((l) => languagePackage(l, family))].join(" ")}`; +} + +export function installLanguage(family: Family, code: string): string { + return `${MANAGERS[family]} ${languagePackage(code, family)}`; +} + +export function grabberInstall(s: Session, family: Family): string { + const debianSpectacle = family === "debian" ? "kde-spectacle" : "spectacle"; + const clip = s.wayland ? "wl-clipboard" : "xclip"; + const tools = s.wayland + ? s.desktop === "gnome" ? "gnome-screenshot" : s.desktop === "kde" ? debianSpectacle : "grim slurp" + : "maim"; + return `${MANAGERS[family]} ${tools} ${clip}`; +} diff --git a/extensions/ocr/lib_test.ts b/extensions/ocr/lib_test.ts new file mode 100644 index 0000000..2751a66 --- /dev/null +++ b/extensions/ocr/lib_test.ts @@ -0,0 +1,122 @@ +import { assert, assertEquals } from "@std/assert"; +import { + describeLanguages, + detectSession, + firstLine, + grabArea, + grabberCandidates, + grabberInstall, + installLanguage, + installTesseract, + languageName, + normalizeLanguages, + ocrScript, + osFamily, + parseLanguages, + parseVersion, + systemdRunArgs, + verbatim, + words, +} from "./lib.ts"; + +Deno.test("selection tool per session", () => { + assertEquals(grabberCandidates(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "GNOME" })), [ + "gnome-screenshot", + ]); + assertEquals(grabberCandidates(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "Hyprland" })), [ + "grim", + ]); + assertEquals(grabberCandidates(detectSession({ XDG_SESSION_TYPE: "x11", XDG_CURRENT_DESKTOP: "KDE" })), [ + "spectacle", + "maim", + "scrot", + "import", + ]); + assertEquals(grabArea("grim"), `geometry=$(slurp) || exit 0; grim -g "$geometry" "$img"`); + assertEquals(grabArea("spectacle"), `spectacle -b -n -r -o "$img"`); +}); + +Deno.test("languages preference", () => { + assertEquals(normalizeLanguages("eng"), "eng"); + assertEquals(normalizeLanguages("eng, fra"), "eng+fra"); + assertEquals(normalizeLanguages(" English + French "), "eng+fra"); + assertEquals(normalizeLanguages("chi_sim+eng+eng"), "chi_sim+eng"); + assertEquals(normalizeLanguages("français"), "fra"); + assertEquals(normalizeLanguages(""), undefined); + assertEquals(normalizeLanguages("$(rm -rf)"), undefined); + assertEquals(describeLanguages("eng+fra"), "English + French"); + assertEquals(languageName("script/Latin"), "Latin (script)"); +}); + +Deno.test("tesseract output", () => { + const out = `List of available languages in "/usr/share/tesseract-ocr/5/tessdata/" (4): +eng +fra +osd +deu`; + assertEquals(parseLanguages(out), ["eng", "fra", "deu"]); + const version = `tesseract 5.3.4 + leptonica-1.82.0 + libgif 5.2.1 : libjpeg 8d (libjpeg-turbo 2.1.2) : libpng 1.6.43`; + assertEquals(parseVersion(version), "5.3.4"); + assertEquals(parseVersion("tesseract v5.0.0-alpha-20201231"), "5.0.0"); +}); + +Deno.test("background script", () => { + const script = ocrScript({ + tool: "grim", + languages: "eng+fra", + historyDir: "/home/ada/.cache/backslash/ocr", + wayland: true, + }); + const lines = script.split("\n"); + assertEquals(lines[0], "sleep 0.4"); + assert(lines[3].startsWith("geometry=$(slurp)")); + assert(script.includes(`tesseract "$img" - -l 'eng+fra'`)); + assert(script.includes(`printf '%s' "$text" | wl-copy`)); + assert(script.includes("tail -n +31")); + assert(script.includes(`'/home/ada/.cache/backslash/ocr'/"$(date +%s)".txt`)); + assert( + ocrScript({ tool: "maim", languages: "eng", historyDir: "/h", wayland: false, wait: 0.9 }).startsWith("sleep 0.9"), + ); + const args = systemdRunArgs("backslash-ocr-1", "x", { DISPLAY: ":0", WAYLAND_DISPLAY: undefined }); + assertEquals(args.slice(-5), ["--property=KillMode=process", "--setenv=DISPLAY=:0", "/bin/sh", "-c", "x"]); +}); + +Deno.test("text helpers", () => { + assertEquals(firstLine("\n\n Invoice #42 \nTotal: 12 €"), "Invoice #42"); + assertEquals(firstLine("a".repeat(100), 10), "aaaaaaaaa…"); + assertEquals(words(" Hello world\nagain "), 3); + assertEquals(verbatim("a ```b``` c\n"), "```\na ˋˋˋbˋˋˋ c\n```"); +}); + +Deno.test("what to install", () => { + assertEquals(osFamily(`ID=arch`), "arch"); + assertEquals( + installTesseract("debian", ["eng", "fra"]), + "sudo apt install tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra", + ); + assertEquals(installTesseract("arch"), "sudo pacman -S tesseract tesseract-data-eng"); + assertEquals(installLanguage("fedora", "chi_sim"), "sudo dnf install tesseract-langpack-chi_sim"); + assertEquals(installLanguage("debian", "chi_sim"), "sudo apt install tesseract-ocr-chi-sim"); + assertEquals(installLanguage("suse", "fra"), "sudo zypper install tesseract-ocr-traineddata-french"); + assertEquals( + grabberInstall({ wayland: true, desktop: "sway" }, "fedora"), + "sudo dnf install grim slurp wl-clipboard", + ); + assertEquals( + grabberInstall({ wayland: true, desktop: "kde" }, "debian"), + "sudo apt install kde-spectacle wl-clipboard", + ); +}); + +Deno.test("every generated script is valid sh", async () => { + for (const tool of ["grim", "gnome-screenshot", "spectacle", "maim", "scrot", "import"] as const) { + for (const wayland of [true, false]) { + const script = ocrScript({ tool, languages: "eng+fra", historyDir: "/h/it's/ocr", wayland }); + const { success, stderr } = await new Deno.Command("sh", { args: ["-n", "-c", script], stderr: "piped" }) + .output(); + assert(success, new TextDecoder().decode(stderr)); + } + } +}); diff --git a/extensions/ocr/system.ts b/extensions/ocr/system.ts new file mode 100644 index 0000000..86a8533 --- /dev/null +++ b/extensions/ocr/system.ts @@ -0,0 +1,135 @@ +// Environment, installed tools, the background job and the history folder. + +import { exec } from "@backslash/api"; +import { + detectSession, + type Env, + type Family, + type Grabber, + grabberCandidates, + normalizeLanguages, + ocrScript, + osFamily, + parseLanguages, + parseVersion, + type Session, + SESSION_ENV, + systemdRunArgs, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key); + } catch { + return undefined; + } +}; + +export const HOME = env("HOME") ?? ""; +export const HISTORY = `${HOME}/.cache/backslash/ocr`; +const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +export const session = (): Session => detectSession(sessionEnv()); + +/** Whether a program can be started: installed and allowed. */ +async function installed(program: string, args: string[]): Promise { + try { + await new Deno.Command(program, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }).output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +export interface Setup { + session: Session; + grabber?: Grabber; + slurp: boolean; + tesseract?: string; + languages: string[]; +} + +/** What is installed: the area grabber, tesseract (its version) and its languages. */ +export async function setup(): Promise { + const s = session(); + const candidates = grabberCandidates(s); + const [found, slurp, version, langs] = await Promise.all([ + Promise.all( + candidates.map((c) => installed(c, c === "import" ? ["-version"] : c === "grim" ? ["-h"] : ["--version"])), + ), + installed("slurp", ["-h"]), + exec("tesseract", ["--version"]).catch((e) => e instanceof Deno.errors.NotFound ? undefined : String(e)), + exec("tesseract", ["--list-langs"]).catch(() => ""), + ]); + return { + session: s, + grabber: candidates.find((_, i) => found[i]), + slurp, + tesseract: version === undefined ? undefined : parseVersion(version) ?? "installed", + languages: parseLanguages(langs), + }; +} + +export async function family(): Promise { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +/** The languages to read: the preference, else English. */ +export const preferredLanguages = (prefs: Record) => + normalizeLanguages(String(prefs.languages ?? "")) ?? "eng"; + +export class Missing extends Error {} + +/** Starts the selection in the background, once the launcher is hidden. */ +export async function recognize(languages: string, o: { known?: Setup; wait?: number } = {}): Promise { + const s = o.known ?? await setup(); + if (!s.tesseract) throw new Missing("Tesseract is not installed"); + if (!s.grabber) throw new Missing("No screenshot tool to select an area"); + if (s.grabber === "grim" && !s.slurp) throw new Missing("slurp is needed to select an area"); + const absent = languages.split("+").filter((l) => s.languages.length && !s.languages.includes(l)); + if (absent.length) throw new Missing(`Tesseract has no ${absent.join(", ")} model installed`); + const script = ocrScript({ + tool: s.grabber, + languages, + historyDir: HISTORY, + wayland: s.session.wayland, + wait: o.wait, + }); + try { + await exec("systemd-run", systemdRunArgs(`backslash-ocr-${Date.now()}`, script, sessionEnv())); + } catch (e) { + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: the selection runs in the background with systemd-run --user"); + } + throw e; + } +} + +export interface Recognized { + path: string; + text: string; + date: number; +} + +/** Recognized texts, newest first. */ +export async function history(): Promise { + const out: Recognized[] = []; + try { + for await (const e of Deno.readDir(HISTORY)) { + const m = e.name.match(/^(\d+)\.txt$/); + if (!e.isFile || !m) continue; + const path = `${HISTORY}/${e.name}`; + const text = (await Deno.readTextFile(path).catch(() => "")).trim(); + if (text) out.push({ path, text, date: Number(m[1]) * 1000 }); + } + } catch { + // Nothing recognized yet. + } + return out.sort((a, b) => b.date - a.date); +} diff --git a/extensions/power/backslash.toml b/extensions/power/backslash.toml new file mode 100644 index 0000000..b17ba7a --- /dev/null +++ b/extensions/power/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "power" +title = "Power" +description = "Switch power profiles and check your battery's charge, health, cycles and time left" +version = "1.0.0" +author = "Backslash" +icon = "bs-battery" +license = "MIT" + +# Profiles: powerprofilesctl (power-profiles-daemon, tuned-ppd) or tuned-adm. +# Batteries: the kernel's /sys/class/power_supply values, read through +# udevadm (Deno doesn't allow reading /sys), else upower. +[permissions] +run = ["powerprofilesctl", "tuned-adm", "udevadm", "upower"] + +[[commands]] +name = "power" +title = "Power" +description = "Power profile and battery" +icon = "bs-battery" +keywords = ["power", "battery", "batterie", "énergie", "profile", "profil", "performance", "économie d'énergie", "charge", "autonomie"] + +[[commands]] +name = "set-profile" +title = "Set Power Profile" +description = "power-saver, balanced or performance" +icon = "bs-bolt" +mode = "no-view" +keywords = ["power", "profile", "profil", "performance", "balanced", "power saver", "économie"] +[[commands.arguments]] +name = "profile" +placeholder = "power-saver, balanced, performance" +required = true + +[[commands]] +name = "next-profile" +title = "Next Power Profile" +description = "Power Saver → Balanced → Performance" +icon = "bs-bolt" +mode = "no-view" +keywords = ["power", "profile", "profil", "cycle", "switch", "changer"] diff --git a/extensions/power/commands/next-profile.ts b/extensions/power/commands/next-profile.ts new file mode 100644 index 0000000..1ee78e2 --- /dev/null +++ b/extensions/power/commands/next-profile.ts @@ -0,0 +1,11 @@ +import { run } from "@backslash/api"; +import { nextProfile, profileName } from "../lib.ts"; +import { profiles, setProfile } from "../system.ts"; + +export default run(async () => { + const all = await profiles(); + const next = nextProfile(all.list); + if (!all.tool || !next) throw new Error("No power profiles: install power-profiles-daemon (or tuned-ppd)"); + await setProfile(all.tool, next.id); + return `${profileName(next.id)} profile`; +}); diff --git a/extensions/power/commands/power.ts b/extensions/power/commands/power.ts new file mode 100644 index 0000000..7b8966b --- /dev/null +++ b/extensions/power/commands/power.ts @@ -0,0 +1,226 @@ +import { + action, + type Color, + copy, + Icons, + type Item, + list, + meta, + type Metadata, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Battery, + duration, + health, + levelColor, + type Profile, + profileName, + statusLine, + summary, + watts, + wh, +} from "../lib.ts"; +import { power, type Profiles, profiles, setProfile } from "../system.ts"; + +const PROFILE_LOOK: Record = { + "power-saver": { + icon: Icons.leaf, + tint: "green", + about: "Lower performance, longer battery life, a cooler and quieter computer.", + }, + balanced: { + icon: Icons.activity, + tint: "blue", + about: "Performance when you need it, power saving the rest of the time.", + }, + performance: { + icon: Icons.rocket, + tint: "orange", + about: "Full speed, more heat and fan noise, shorter battery life.", + }, +}; +const look = (id: string) => + PROFILE_LOOK[id] ?? + (/save|power|eco|quiet/.test(id) + ? PROFILE_LOOK["power-saver"] + : /perf|throughput|latency/.test(id) + ? PROFILE_LOOK.performance + : PROFILE_LOOK.balanced); + +function profileItem(p: Profile, all: Profiles): Item { + const l = look(p.id); + return { + id: `profile:${p.id}`, + title: profileName(p.id), + subtitle: p.active ? "Active" : p.description, + icon: { source: p.active ? Icons.done : l.icon, tint: p.active ? l.tint : "gray" }, + section: "Power Profile", + keywords: ["profile", "profil", "énergie", "performance", "économie", p.id], + accessories: [ + ...(p.degraded + ? [{ icon: { source: Icons.alert, tint: "orange" as const }, tooltip: `Limited: ${p.degraded}` }] + : []), + ...(p.active ? [{ tag: "Active", color: l.tint }] : []), + ], + detail: { + markdown: PROFILE_LOOK[p.id]?.about ?? p.description, + metadata: [ + meta.text("Profile", p.id, { icon: l.icon }), + meta.text("Status", p.active ? "Active" : "Available", { + icon: p.active ? Icons.check : Icons.dottedCircle, + color: p.active ? "green" : undefined, + }), + ...(p.driver ? [meta.text("Driver", p.driver, { icon: Icons.cpu })] : []), + ...(p.degraded + ? [meta.text("Limited", p.degraded.replace(/-/g, " "), { icon: Icons.alert, color: "orange" })] + : []), + meta.text("Through", all.tool ?? "", { icon: Icons.terminal }), + ], + }, + actions: p.active ? [copy(p.id, "Copy Profile Name")] : [ + action(`Switch to ${profileName(p.id)}`, async () => { + await setProfile(all.tool, p.id); + await toast(`${profileName(p.id)} profile`, { style: "success" }); + return refresh(); + }, { icon: l.icon }), + copy(p.id, "Copy Profile Name", { section: "Copy" }), + ], + }; +} + +const noProfiles = (error?: string): Item => ({ + id: "profiles:none", + title: "Power profiles unavailable", + subtitle: error ? "the daemon did not answer" : "install power-profiles-daemon", + icon: { source: Icons.bolt, tint: "gray" }, + section: "Power Profile", + detail: { + markdown: [ + "Backslash switches profiles with **power-profiles-daemon** (GNOME, KDE and most desktops) or **tuned** (Fedora's tuned-ppd provides both).", + "**Debian, Ubuntu** `sudo apt install power-profiles-daemon`", + "**Fedora** `sudo dnf install tuned-ppd`", + "**Arch** `sudo pacman -S power-profiles-daemon`", + "Then enable it: `sudo systemctl enable --now power-profiles-daemon`", + ...(error ? [`\`\`\`\n${error}\n\`\`\``] : []), + ].join("\n\n"), + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], +}); + +function batteryMetadata(b: Battery, plugged?: boolean): Metadata[] { + const h = health(b); + return [ + ...(b.capacity !== undefined ? [meta.progress("Charge", b.capacity / 100, `${Math.round(b.capacity)} %`)] : []), + ...(h !== undefined ? [meta.progress("Health", h, `${Math.round(h * 100)} % of design`)] : []), + meta.text("Status", statusLine(b), { + icon: b.status === "Charging" ? Icons.batteryCharging : Icons.battery, + color: b.status === "Charging" ? "green" : undefined, + }), + ...(plugged !== undefined && !b.peripheral + ? [meta.text("Charger", plugged ? "Plugged in" : "Unplugged", { icon: Icons.plug })] + : []), + ...(b.power + ? [meta.text(b.status === "Charging" ? "Charging at" : "Power draw", watts(b.power), { icon: Icons.bolt })] + : []), + meta.separator(), + ...(b.energyNow !== undefined && b.energyFull + ? [meta.text("Energy", `${wh(b.energyNow)} of ${wh(b.energyFull)}`)] + : []), + ...(b.energyDesign ? [meta.text("Design capacity", wh(b.energyDesign))] : []), + ...(b.cycles !== undefined ? [meta.text("Charge cycles", b.cycles, { icon: Icons.cycle })] : []), + ...(b.voltage ? [meta.text("Voltage", `${b.voltage.toFixed(2)} V`)] : []), + ...(b.technology ? [meta.text("Technology", b.technology)] : []), + ...(b.manufacturer || b.model ? [meta.text("Model", [b.manufacturer, b.model].filter(Boolean).join(" "))] : []), + meta.text("Device", b.id, { icon: Icons.tag }), + ]; +} + +function batteryItem(b: Battery, plugged?: boolean): Item { + const pct = b.capacity; + const color = b.status === "Charging" || b.status === "Full" ? "green" : levelColor(pct); + const h = health(b); + const summaryText = [ + `${b.model ?? b.id}: ${pct !== undefined ? `${Math.round(pct)} %` : "?"}`, + statusLine(b), + ...(h !== undefined ? [`health ${Math.round(h * 100)} %`] : []), + ...(b.cycles !== undefined ? [`${b.cycles} cycles`] : []), + ].join(", "); + return { + id: `battery:${b.id}`, + title: b.peripheral ? b.model ?? b.id : "Battery", + subtitle: statusLine(b), + icon: { source: b.status === "Charging" ? Icons.batteryCharging : Icons.battery, tint: color }, + section: b.peripheral ? "Devices" : "Battery", + keywords: ["battery", "batterie", "charge", "autonomie", b.id, b.model ?? ""], + accessories: [ + ...(b.power && !b.peripheral ? [{ text: watts(b.power), tooltip: "Power draw" }] : []), + ...(pct !== undefined ? [{ tag: `${Math.round(pct)} %`, color }] : []), + ], + detail: { + stats: [ + ...(pct !== undefined ? [{ label: "charge", value: `${Math.round(pct)} %`, color }] : []), + ...(h !== undefined + ? [{ label: "health", value: `${Math.round(h * 100)} %`, color: h < 0.7 ? "orange" as const : undefined }] + : []), + ...(b.timeLeft && b.timeLeft < 172800 + ? [{ label: b.status === "Charging" ? "until full" : "left", value: duration(b.timeLeft) }] + : []), + ], + metadata: batteryMetadata(b, plugged), + }, + actions: [ + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + copy(summaryText, "Copy Battery Summary", { section: "Copy" }), + ], + }; +} + +const noBattery = (): Item => ({ + id: "battery:none", + title: "No battery", + subtitle: "Running on mains power", + icon: { source: Icons.plug, tint: "gray" }, + section: "Battery", + detail: { markdown: "This computer has no battery, or the system doesn't report one." }, + actions: [action("Refresh", () => refresh(), { icon: Icons.refresh })], +}); + +export default list({ + placeholder: "Power profiles and batteries…", + empty: { title: "Nothing to show", icon: Icons.power }, + status: (items) => { + const p = items.find((i) => i.section === "Power Profile" && i.subtitle === "Active"); + const b = items.find((i) => i.section === "Battery" && i.id !== "battery:none"); + return [p?.title, b && `${b.accessories?.at(-1)?.tag ?? ""} · ${b.subtitle}`].filter(Boolean).join(" · ") || + "Power"; + }, + // Refreshed every ten seconds while open: charge, draw and time left move. + async *items({ signal }) { + let last = ""; + let lastSubtitle: string | null | undefined; + while (!signal.aborted) { + const [all, state] = await Promise.all([profiles(), power()]); + const main = state.batteries.find((b) => !b.peripheral); + const sub = summary(all.list.find((p) => p.active), main); + if (sub !== lastSubtitle) { + lastSubtitle = sub; + updateCommandMetadata({ subtitle: sub }).catch(() => {}); + } + const own = state.batteries.filter((b) => !b.peripheral); + const items = [ + ...(own.length ? own.map((b) => batteryItem(b, state.plugged)) : [noBattery()]), + ...(all.list.length ? all.list.map((p) => profileItem(p, all)) : [noProfiles(all.error)]), + ...state.batteries.filter((b) => b.peripheral).map((b) => batteryItem(b)), + ]; + const key = JSON.stringify(items.map((i) => [i.id, i.subtitle, i.accessories, i.detail])); + if (key !== last) { + last = key; + yield items; + } + await new Promise((r) => setTimeout(r, 10_000)); + } + }, +}); diff --git a/extensions/power/commands/set-profile.ts b/extensions/power/commands/set-profile.ts new file mode 100644 index 0000000..a0f01d0 --- /dev/null +++ b/extensions/power/commands/set-profile.ts @@ -0,0 +1,14 @@ +import { run } from "@backslash/api"; +import { profileName } from "../lib.ts"; +import { profiles, setProfile } from "../system.ts"; + +export default run<{ profile: string }>(async ({ args }) => { + const all = await profiles(); + if (!all.tool) throw new Error("No power profiles: install power-profiles-daemon (or tuned-ppd)"); + const wanted = args.profile.toLowerCase().replace(/\s+/g, "-"); + const p = all.list.find((x) => x.id === wanted) ?? + all.list.find((x) => x.id.includes(wanted.replace(/^(saver|eco|save)$/, "power-saver"))); + if (!p) throw new Error(`No “${args.profile}” profile: ${all.list.map((x) => x.id).join(", ")}`); + await setProfile(all.tool, p.id); + return `${profileName(p.id)} profile`; +}); diff --git a/extensions/power/lib.ts b/extensions/power/lib.ts new file mode 100644 index 0000000..1d32d66 --- /dev/null +++ b/extensions/power/lib.ts @@ -0,0 +1,251 @@ +// Parsing power profiles (powerprofilesctl, tuned-adm) and batteries (the +// kernel's power_supply class through udevadm, or upower). Pure, tested in +// lib_test.ts. + +// --------------------------------------------------------------------------- +// Profiles + +export interface Profile { + /** As the tool names it: "power-saver", "balanced", "throughput-performance". */ + id: string; + active: boolean; + description?: string; + driver?: string; + /** Why performance is limited, e.g. "lap-detected" or "high-operating-temperature". */ + degraded?: string; +} + +/** `powerprofilesctl list`. */ +export function parsePowerProfiles(out: string): Profile[] { + const profiles: Profile[] = []; + let cur: Profile | undefined; + for (const line of out.split("\n")) { + const head = line.match(/^(\*)?\s*([a-z][a-z0-9-]*):\s*$/); + if (head) { + cur = { id: head[2], active: !!head[1] }; + profiles.push(cur); + continue; + } + const kv = cur && line.match(/^\s+(\w+):\s*(.*)$/); + if (!cur || !kv) continue; + const v = kv[2].trim(); + if (kv[1] === "CpuDriver" || kv[1] === "Driver") cur.driver = cur.driver ? `${v}, ${cur.driver}` : v; + else if (kv[1] === "PlatformDriver") cur.driver = cur.driver ? `${cur.driver}, ${v}` : v; + else if (kv[1] === "Degraded" && v !== "no") cur.degraded = v.match(/\((.*)\)/)?.[1] ?? "yes"; + } + return profiles; +} + +/** `tuned-adm list`: "- balanced - General non-specialized tuned profile". */ +export function parseTuned(out: string): Profile[] { + const active = out.match(/^Current active profile:\s*(\S+)/m)?.[1]; + return out.split("\n").flatMap((line) => { + const m = line.match(/^-\s+(\S+)(?:\s+-\s+(.*))?$/); + return m ? [{ id: m[1], active: m[1] === active, description: m[2]?.trim() }] : []; + }); +} + +/** "power-saver" → "Power Saver". */ +export const profileName = (id: string) => + id.split(/[-_]/).map((w) => (w === "ppd" ? "PPD" : w[0].toUpperCase() + w.slice(1))).join(" "); + +/** Cycles power-saver → balanced → performance → power-saver (the order the tool lists, otherwise). */ +export function nextProfile(profiles: Profile[]): Profile | undefined { + const order = ["power-saver", "balanced", "performance"]; + const sorted = profiles.every((p) => order.includes(p.id)) + ? [...profiles].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) + : profiles; + const i = sorted.findIndex((p) => p.active); + return sorted.length ? sorted[(i + 1) % sorted.length] : undefined; +} + +// --------------------------------------------------------------------------- +// Batteries + +export interface Battery { + id: string; + model?: string; + manufacturer?: string; + technology?: string; + /** Charging, Discharging, Full, Not charging, Unknown. */ + status: string; + /** 0–100. */ + capacity?: number; + /** Wh. */ + energyNow?: number; + energyFull?: number; + energyDesign?: number; + /** W, positive. */ + power?: number; + /** V. */ + voltage?: number; + cycles?: number; + /** Seconds to empty (discharging) or to full (charging). */ + timeLeft?: number; + /** A peripheral (mouse, headset) rather than the computer's own battery. */ + peripheral: boolean; + kind?: string; +} + +export interface PowerState { + batteries: Battery[]; + /** Whether a charger is plugged in, when the system says. */ + plugged?: boolean; +} + +const micro = (v: string | undefined) => (v === undefined || v === "" ? undefined : Number(v) / 1e6); + +/** One power_supply's `POWER_SUPPLY_*` properties (uevent) → a battery. */ +export function batteryFromUevent(p: Record): Battery | undefined { + const get = (k: string) => p[`POWER_SUPPLY_${k}`]; + if (get("TYPE") !== "Battery" || get("PRESENT") === "0") return undefined; + const voltage = micro(get("VOLTAGE_NOW")); + // Some batteries report charge (µAh) instead of energy (µWh): convert with the voltage. + const design = micro(get("VOLTAGE_MIN_DESIGN")) ?? voltage; + const fromCharge = (k: string) => { + const c = micro(get(k)); + return c !== undefined && design ? c * design : undefined; + }; + const energyNow = micro(get("ENERGY_NOW")) ?? fromCharge("CHARGE_NOW"); + const energyFull = micro(get("ENERGY_FULL")) ?? fromCharge("CHARGE_FULL"); + const energyDesign = micro(get("ENERGY_FULL_DESIGN")) ?? fromCharge("CHARGE_FULL_DESIGN"); + const powerRaw = micro(get("POWER_NOW")) ?? (() => { + const i = micro(get("CURRENT_NOW")); + return i !== undefined && voltage ? i * voltage : undefined; + })(); + const power = powerRaw === undefined ? undefined : Math.abs(powerRaw); + const status = get("STATUS") ?? "Unknown"; + const capacity = get("CAPACITY") !== undefined + ? Number(get("CAPACITY")) + : energyNow !== undefined && energyFull + ? Math.round((energyNow / energyFull) * 100) + : undefined; + const cycles = get("CYCLE_COUNT") !== undefined ? Number(get("CYCLE_COUNT")) : undefined; + return { + id: get("NAME") ?? p.DEVPATH?.split("/").pop() ?? "battery", + model: get("MODEL_NAME")?.trim() || undefined, + manufacturer: get("MANUFACTURER")?.trim() || undefined, + technology: get("TECHNOLOGY") && get("TECHNOLOGY") !== "Unknown" ? get("TECHNOLOGY") : undefined, + status, + capacity, + energyNow, + energyFull, + energyDesign, + power: power && power > 0.05 ? power : undefined, + voltage, + cycles: cycles && cycles > 0 ? cycles : undefined, + timeLeft: timeLeft(status, energyNow, energyFull, power), + peripheral: get("SCOPE") === "Device", + }; +} + +/** Hours of energy left (or to fill), as seconds. */ +export function timeLeft(status: string, now?: number, full?: number, power?: number): number | undefined { + if (!power || power < 0.05 || now === undefined) return undefined; + if (status === "Discharging") return (now / power) * 3600; + if (status === "Charging" && full !== undefined && full > now) return ((full - now) / power) * 3600; + return undefined; +} + +/** `udevadm info --export-db` → batteries and whether a charger is online. */ +export function parseUdevPower(out: string): PowerState { + const batteries: Battery[] = []; + let plugged: boolean | undefined; + for (const block of out.split(/\n\s*\n/)) { + const props: Record = {}; + for (const line of block.split("\n")) { + if (!line.startsWith("E: ")) continue; + const eq = line.indexOf("="); + if (eq > 3) props[line.slice(3, eq)] = line.slice(eq + 1); + } + if (props.SUBSYSTEM !== "power_supply") continue; + const type = props.POWER_SUPPLY_TYPE; + if ((type === "Mains" || type?.startsWith("USB")) && props.POWER_SUPPLY_SCOPE !== "Device") { + if (props.POWER_SUPPLY_ONLINE !== undefined) plugged = !!plugged || props.POWER_SUPPLY_ONLINE === "1"; + continue; + } + const b = batteryFromUevent(props); + if (b) batteries.push(b); + } + return { batteries, plugged }; +} + +/** `upower -i ` → a battery (fallback where udev isn't readable). */ +export function parseUpower(out: string): Battery | undefined { + const kv: Record = {}; + let kind = ""; + for (const line of out.split("\n")) { + const m = line.match(/^\s+([a-z][a-z -]*?):\s+(.*)$/); + if (m) kv[m[1]] = m[2].trim(); + else if (/^\s{2}[a-z-]+\s*$/.test(line)) kind = line.trim(); + } + if (!kind || kind === "line-power" || kv.present === "no") return undefined; + const n = (k: string) => (kv[k] ? parseFloat(kv[k].replace(",", ".")) : undefined); + const duration = (k: string) => { + const m = kv[k]?.replace(",", ".").match(/([\d.]+)\s*(hours?|minutes?|seconds?|days?)/); + if (!m) return undefined; + const f = m[2].startsWith("d") ? 86400 : m[2].startsWith("h") ? 3600 : m[2].startsWith("m") ? 60 : 1; + return Number(m[1]) * f; + }; + const state = kv.state ?? "unknown"; + const status = state === "fully-charged" + ? "Full" + : state === "pending-charge" + ? "Not charging" + : state[0].toUpperCase() + state.slice(1); + return { + id: kv["native-path"] ?? kind, + model: kv.model || undefined, + manufacturer: kv.vendor || undefined, + technology: kv.technology && kv.technology !== "unknown" ? kv.technology : undefined, + status, + capacity: n("percentage"), + energyNow: n("energy"), + energyFull: n("energy-full"), + energyDesign: n("energy-full-design"), + power: n("energy-rate") || undefined, + voltage: n("voltage"), + cycles: n("charge-cycles") && n("charge-cycles")! > 0 ? n("charge-cycles") : undefined, + timeLeft: duration("time to empty") ?? duration("time to full"), + peripheral: kv["power supply"] === "no", + kind, + }; +} + +/** energy_full / energy_full_design, 0–1 (can slightly exceed 1 on new batteries). */ +export const health = (b: Battery) => + b.energyFull && b.energyDesign ? Math.min(1.1, b.energyFull / b.energyDesign) : undefined; + +/** 11520 → "3 h 12 min"; 600 → "10 min". */ +export function duration(seconds: number): string { + const total = Math.round(seconds / 60); + const h = Math.floor(total / 60); + const m = total % 60; + if (!h) return `${m} min`; + return m ? `${h} h ${String(m).padStart(2, "0")} min` : `${h} h`; +} + +/** "Discharging · 3 h 12 min left", "Charging · full in 45 min", "Fully charged". */ +export function statusLine(b: Battery): string { + const t = b.timeLeft && b.timeLeft < 86400 * 2 ? duration(b.timeLeft) : undefined; + if (b.status === "Discharging") return t ? `${t} left` : "On battery"; + if (b.status === "Charging") return t ? `Charging · full in ${t}` : "Charging"; + if (b.status === "Full") return "Fully charged"; + if (b.status === "Not charging") return "Plugged in, not charging"; + return b.status; +} + +export const levelColor = (pct: number | undefined): "green" | "yellow" | "orange" | "red" | "gray" => + pct === undefined ? "gray" : pct > 50 ? "green" : pct > 25 ? "yellow" : pct > 10 ? "orange" : "red"; + +/** The root search line: "Balanced · 82 %". */ +export function summary(profile: Profile | undefined, b: Battery | undefined): string | null { + const parts = [ + profile && profileName(profile.id), + b?.capacity !== undefined && `${Math.round(b.capacity)} %${b.status === "Charging" ? " ⚡" : ""}`, + ].filter(Boolean); + return parts.length ? parts.join(" · ") : null; +} + +export const watts = (w: number) => `${w.toFixed(w < 10 ? 1 : 0)} W`; +export const wh = (w: number) => `${w.toFixed(1)} Wh`; diff --git a/extensions/power/lib_test.ts b/extensions/power/lib_test.ts new file mode 100644 index 0000000..2207bcc --- /dev/null +++ b/extensions/power/lib_test.ts @@ -0,0 +1,204 @@ +import { assertAlmostEquals, assertEquals } from "@std/assert"; +import { + batteryFromUevent, + duration, + health, + levelColor, + nextProfile, + parsePowerProfiles, + parseTuned, + parseUdevPower, + parseUpower, + profileName, + statusLine, + summary, + timeLeft, +} from "./lib.ts"; + +const PPD = ` performance: + CpuDriver: amd_pstate + PlatformDriver: platform_profile + Degraded: yes (high-operating-temperature) + +* balanced: + CpuDriver: amd_pstate + PlatformDriver: platform_profile + + power-saver: + CpuDriver: amd_pstate + PlatformDriver: platform_profile +`; + +Deno.test("powerprofilesctl", () => { + const list = parsePowerProfiles(PPD); + assertEquals(list.map((p) => [p.id, p.active, p.degraded]), [ + ["performance", false, "high-operating-temperature"], + ["balanced", true, undefined], + ["power-saver", false, undefined], + ]); + assertEquals(list[1].driver, "amd_pstate, platform_profile"); + assertEquals(nextProfile(list)?.id, "performance"); + assertEquals( + nextProfile([{ id: "performance", active: true }, { id: "power-saver", active: false }])?.id, + "power-saver", + ); + assertEquals([profileName("power-saver"), profileName("throughput-performance")], [ + "Power Saver", + "Throughput Performance", + ]); +}); + +Deno.test("tuned-adm", () => { + const out = `Available profiles: +- accelerator-performance - Throughput performance based tuning with disabled higher latency STOP states +- balanced - General non-specialized tuned profile +- powersave - Optimize for low power consumption +- throughput-performance - Broadly applicable tuning that provides excellent performance +Current active profile: balanced`; + const list = parseTuned(out); + assertEquals(list.length, 4); + assertEquals(list.find((p) => p.active)?.id, "balanced"); + assertEquals(list[2].description, "Optimize for low power consumption"); +}); + +const DB = `P: /devices/LNXSYSTM:00/LNXSYBUS:00/PNP0A08:00/device:1f/PNP0C09:00/ACPI0003:00/power_supply/AC +E: DEVPATH=/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0A08:00/device:1f/PNP0C09:00/ACPI0003:00/power_supply/AC +E: SUBSYSTEM=power_supply +E: POWER_SUPPLY_NAME=AC +E: POWER_SUPPLY_TYPE=Mains +E: POWER_SUPPLY_ONLINE=0 + +P: /devices/LNXSYSTM:00/LNXSYBUS:00/PNP0C0A:00/power_supply/BAT0 +E: DEVPATH=/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0C0A:00/power_supply/BAT0 +E: SUBSYSTEM=power_supply +E: POWER_SUPPLY_NAME=BAT0 +E: POWER_SUPPLY_TYPE=Battery +E: POWER_SUPPLY_STATUS=Discharging +E: POWER_SUPPLY_PRESENT=1 +E: POWER_SUPPLY_TECHNOLOGY=Li-poly +E: POWER_SUPPLY_CYCLE_COUNT=183 +E: POWER_SUPPLY_VOLTAGE_MIN_DESIGN=15440000 +E: POWER_SUPPLY_VOLTAGE_NOW=16432000 +E: POWER_SUPPLY_POWER_NOW=7712000 +E: POWER_SUPPLY_ENERGY_FULL_DESIGN=57000000 +E: POWER_SUPPLY_ENERGY_FULL=50530000 +E: POWER_SUPPLY_ENERGY_NOW=41420000 +E: POWER_SUPPLY_CAPACITY=82 +E: POWER_SUPPLY_CAPACITY_LEVEL=Normal +E: POWER_SUPPLY_MODEL_NAME=5B10W13930 +E: POWER_SUPPLY_MANUFACTURER=SMP + +P: /devices/pci0000:00/0000:00:14.0/usb1/1-2/1-2:1.2/0003:046D:C52B.0003/0003:046D:405E.0004/power_supply/hidpp_battery_0 +E: SUBSYSTEM=power_supply +E: POWER_SUPPLY_NAME=hidpp_battery_0 +E: POWER_SUPPLY_TYPE=Battery +E: POWER_SUPPLY_SCOPE=Device +E: POWER_SUPPLY_STATUS=Discharging +E: POWER_SUPPLY_CAPACITY=55 +E: POWER_SUPPLY_MODEL_NAME=MX Master 3 +E: POWER_SUPPLY_MANUFACTURER=Logitech + +P: /devices/virtual/net/lo +E: SUBSYSTEM=net +`; + +Deno.test("batteries from udev", () => { + const { batteries, plugged } = parseUdevPower(DB); + assertEquals(plugged, false); + assertEquals(batteries.map((b) => [b.id, b.capacity, b.peripheral]), [ + ["BAT0", 82, false], + ["hidpp_battery_0", 55, true], + ]); + const b = batteries[0]; + assertEquals([b.cycles, b.technology, b.model, b.manufacturer], [183, "Li-poly", "5B10W13930", "SMP"]); + assertAlmostEquals(health(b)!, 0.8865, 0.001); + assertAlmostEquals(b.power!, 7.712, 1e-9); + assertAlmostEquals(b.timeLeft!, (41.42 / 7.712) * 3600, 1); + assertEquals(statusLine(b), "5 h 22 min left"); + assertEquals(batteries[1].model, "MX Master 3"); +}); + +Deno.test("batteries reporting charge instead of energy", () => { + const b = batteryFromUevent({ + POWER_SUPPLY_NAME: "BAT1", + POWER_SUPPLY_TYPE: "Battery", + POWER_SUPPLY_STATUS: "Charging", + POWER_SUPPLY_VOLTAGE_MIN_DESIGN: "11400000", + POWER_SUPPLY_VOLTAGE_NOW: "12300000", + POWER_SUPPLY_CURRENT_NOW: "-2000000", + POWER_SUPPLY_CHARGE_FULL_DESIGN: "4000000", + POWER_SUPPLY_CHARGE_FULL: "3600000", + POWER_SUPPLY_CHARGE_NOW: "1800000", + POWER_SUPPLY_CYCLE_COUNT: "0", + })!; + assertEquals(b.capacity, 50); + assertAlmostEquals(health(b)!, 0.9, 1e-9); + assertAlmostEquals(b.power!, 24.6, 1e-9); + assertEquals(b.cycles, undefined); // 0 means "not reported" + assertEquals(statusLine(b), "Charging · full in 50 min"); + assertEquals(batteryFromUevent({ POWER_SUPPLY_TYPE: "Battery", POWER_SUPPLY_PRESENT: "0" }), undefined); + assertEquals(batteryFromUevent({ POWER_SUPPLY_TYPE: "Mains" }), undefined); +}); + +Deno.test("upower", () => { + const out = ` native-path: BAT0 + vendor: SMP + model: 5B10W13930 + power supply: yes + updated: Tue 28 Oct 2026 14:02:11 CET (12 seconds ago) + has history: yes + battery + present: yes + rechargeable: yes + state: charging + energy: 41,42 Wh + energy-full: 50,53 Wh + energy-full-design: 57 Wh + energy-rate: 20,5 W + voltage: 16,432 V + charge-cycles: 183 + time to full: 26,7 minutes + percentage: 82% + capacity: 88,6491% + technology: lithium-polymer +`; + const b = parseUpower(out)!; + assertEquals([b.id, b.status, b.capacity, b.cycles, b.peripheral, b.kind], [ + "BAT0", + "Charging", + 82, + 183, + false, + "battery", + ]); + assertAlmostEquals(b.timeLeft!, 26.7 * 60, 0.1); + assertAlmostEquals(health(b)!, 0.8865, 0.001); + assertEquals(parseUpower(` native-path: AC\n line-power\n online: yes\n`), undefined); + const mouse = parseUpower(` model: MX Master 3 + power supply: no + mouse + present: yes + state: discharging + percentage: 55% +`)!; + assertEquals([mouse.peripheral, mouse.capacity, mouse.kind], [true, 55, "mouse"]); +}); + +Deno.test("formatting", () => { + assertEquals([duration(11520), duration(600), duration(7200)], ["3 h 12 min", "10 min", "2 h"]); + assertEquals(timeLeft("Full", 50, 50, 5), undefined); + assertEquals(timeLeft("Discharging", 50, 60, 0), undefined); + assertEquals([levelColor(80), levelColor(40), levelColor(20), levelColor(5), levelColor(undefined)], [ + "green", + "yellow", + "orange", + "red", + "gray", + ]); + const balanced = { id: "balanced", active: true }; + const bat = { id: "BAT0", status: "Discharging", capacity: 82.4, peripheral: false }; + assertEquals(summary(balanced, bat), "Balanced · 82 %"); + assertEquals(summary(balanced, { ...bat, status: "Charging" }), "Balanced · 82 % ⚡"); + assertEquals(summary(undefined, undefined), null); + assertEquals(statusLine({ ...bat, status: "Full" }), "Fully charged"); +}); diff --git a/extensions/power/system.ts b/extensions/power/system.ts new file mode 100644 index 0000000..66f9778 --- /dev/null +++ b/extensions/power/system.ts @@ -0,0 +1,63 @@ +// Power profiles and batteries from the system's tools. + +import { exec } from "@backslash/api"; +import { + type Battery, + parsePowerProfiles, + parseTuned, + parseUdevPower, + parseUpower, + type PowerState, + type Profile, +} from "./lib.ts"; + +export interface Profiles { + list: Profile[]; + tool?: "powerprofilesctl" | "tuned-adm"; + error?: string; +} + +/** power-profiles-daemon (or tuned-ppd), else tuned. */ +export async function profiles(): Promise { + let error: string | undefined; + try { + const list = parsePowerProfiles(await exec("powerprofilesctl", ["list"])); + if (list.length) return { list, tool: "powerprofilesctl" }; + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) error = String((e as Error).message ?? e); + } + try { + const list = parseTuned(await exec("tuned-adm", ["list"])); + if (list.length) return { list, tool: "tuned-adm" }; + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) error ??= String((e as Error).message ?? e); + } + return { list: [], error }; +} + +export async function setProfile(tool: Profiles["tool"], id: string): Promise { + if (tool === "tuned-adm") await exec("tuned-adm", ["profile", id]); + else await exec("powerprofilesctl", ["set", id]); +} + +/** Batteries from the kernel (through udev, as Deno can't read /sys), else UPower. */ +export async function power(): Promise { + try { + const state = parseUdevPower(await exec("udevadm", ["info", "--export-db"])); + if (state.batteries.length || state.plugged !== undefined) return state; + } catch { + // No udev: ask UPower. + } + try { + const devices = (await exec("upower", ["-e"])).split("\n").filter((d) => d && !d.includes("DisplayDevice")); + const batteries = (await Promise.all(devices.map(async (d) => parseUpower(await exec("upower", ["-i", d]))))) + .filter( + (b): b is Battery => !!b, + ); + const ac = devices.find((d) => /line_power/.test(d)); + const plugged = ac ? /online:\s+yes/.test(await exec("upower", ["-i", ac])) : undefined; + return { batteries, plugged }; + } catch { + return { batteries: [] }; + } +} diff --git a/extensions/screenshot/backslash.toml b/extensions/screenshot/backslash.toml new file mode 100644 index 0000000..5757ed1 --- /dev/null +++ b/extensions/screenshot/backslash.toml @@ -0,0 +1,116 @@ +[extension] +id = "screenshot" +title = "Screenshot & Recording" +description = "Capture an area, a window or the whole screen, record the screen, and find your recent captures" +version = "1.0.0" +author = "Backslash" +icon = "bs-camera" +license = "MIT" + +# Captures run in the background (systemd-run) once the launcher is hidden, +# with the first tool installed: grim + slurp (Sway, Hyprland, wlroots), +# gnome-screenshot (GNOME), spectacle (KDE), maim, scrot or ImageMagick's +# import (X11); wf-recorder or wl-screenrec (Wayland) and ffmpeg (X11) record. +# The other programs are only started to check that they are installed. +# hyprctl and swaymsg list the windows to pick from; gdbus shows a file in +# the file manager; wl-copy and xclip copy pictures. +[permissions] +run = [ + "systemd-run", + "systemctl", + "grim", + "slurp", + "gnome-screenshot", + "spectacle", + "maim", + "scrot", + "import", + "wf-recorder", + "wl-screenrec", + "ffmpeg", + "slop", + "hyprctl", + "swaymsg", + "wl-copy", + "xclip", + "gdbus", +] +read = ["~/Pictures", "~/Images", "~/Videos", "~/Vidéos", "~/.config/user-dirs.dirs", "/etc/os-release"] +write = ["~/Pictures", "~/Images", "~/Videos", "~/Vidéos"] +env = [ + "HOME", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] + +[[commands]] +name = "screenshots" +title = "Screenshots & Recordings" +description = "Capture, record, and browse your recent captures" +icon = "bs-camera" +keywords = ["screenshot", "capture", "écran", "capture d'écran", "recording", "enregistrement", "screencast", "print"] + +[[commands]] +name = "capture-area" +title = "Capture Area" +description = "Select part of the screen: saved and copied" +icon = "bs-scan" +mode = "no-view" +keywords = ["screenshot", "capture", "region", "zone", "sélection", "écran", "snip"] + +[[commands]] +name = "capture-window" +title = "Capture Window" +description = "Click a window: saved and copied" +icon = "bs-window" +mode = "no-view" +keywords = ["screenshot", "capture", "window", "fenêtre", "écran"] + +[[commands]] +name = "capture-screen" +title = "Capture Full Screen" +description = "The whole screen: saved and copied" +icon = "bs-monitor" +mode = "no-view" +keywords = ["screenshot", "capture", "full screen", "plein écran", "écran", "print screen"] + +[[commands]] +name = "record" +title = "Toggle Screen Recording" +description = "Start recording the screen, or stop and save" +icon = "bs-video" +mode = "no-view" +keywords = ["record", "recording", "screencast", "enregistrer", "vidéo", "écran", "video"] + +[[commands]] +name = "record-area" +title = "Toggle Area Recording" +description = "Record part of the screen, or stop and save" +icon = "bs-video" +mode = "no-view" +keywords = ["record", "recording", "region", "enregistrer", "vidéo", "zone", "gif"] + +[[preferences]] +name = "delay" +title = "Delay before capturing (seconds)" +description = "Time to open a menu or hover something first" +type = "number" +default = 0 + +[[preferences]] +name = "copy" +title = "Copy screenshots to the clipboard" +type = "boolean" +default = true + +[[preferences]] +name = "audio" +title = "Record audio" +description = "Records the default audio input with the screen" +type = "boolean" +default = false diff --git a/extensions/screenshot/capture.ts b/extensions/screenshot/capture.ts new file mode 100644 index 0000000..aa27d62 --- /dev/null +++ b/extensions/screenshot/capture.ts @@ -0,0 +1,34 @@ +// Shared by the no-view commands: errors read as what to install. +import type { Context } from "@backslash/api"; +import { installCommands, type Mode, recorderPackages, screenshotPackages } from "./lib.ts"; +import { family, MissingTool, recording, screenshot, session, startRecording, stopRecording } from "./system.ts"; + +async function hint(e: unknown, packages: string[]): Promise { + if (!(e instanceof MissingTool)) throw e; + const fam = await family(); + const how = installCommands(packages, fam); + throw new Error(`${e.message}. Install it: ${how.length === 1 ? how[0].command : packages.join(", ")}`); +} + +/** Hides the launcher right away (no message), then captures in the background. */ +export const capture = (mode: Mode) => async ({ preferences }: Context) => { + const prefs = preferences as { delay?: number; copy?: boolean }; + await screenshot(mode, { delay: prefs.delay, copy: prefs.copy }).catch((e) => + hint( + e, + mode === "screen" ? screenshotPackages(session()).filter((p) => p !== "slurp") : screenshotPackages(session()), + ) + ); +}; + +/** Starts a recording, or stops the one in progress. */ +export const toggleRecording = (area: boolean) => async ({ preferences }: Context) => { + if (await recording()) { + const rec = await stopRecording(); + return `Recording saved${rec ? `: ${rec.file.split("/").pop()}` : ""}`; + } + await startRecording(area, !!(preferences as { audio?: boolean }).audio).catch((e) => + hint(e, recorderPackages(session())) + ); + return area ? "Select the area to record…" : "Recording… run again to stop"; +}; diff --git a/extensions/screenshot/commands/capture-area.ts b/extensions/screenshot/commands/capture-area.ts new file mode 100644 index 0000000..32bf8a5 --- /dev/null +++ b/extensions/screenshot/commands/capture-area.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { capture } from "../capture.ts"; + +export default run(capture("area")); diff --git a/extensions/screenshot/commands/capture-screen.ts b/extensions/screenshot/commands/capture-screen.ts new file mode 100644 index 0000000..a522755 --- /dev/null +++ b/extensions/screenshot/commands/capture-screen.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { capture } from "../capture.ts"; + +export default run(capture("screen")); diff --git a/extensions/screenshot/commands/capture-window.ts b/extensions/screenshot/commands/capture-window.ts new file mode 100644 index 0000000..d12973e --- /dev/null +++ b/extensions/screenshot/commands/capture-window.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { capture } from "../capture.ts"; + +export default run(capture("window")); diff --git a/extensions/screenshot/commands/record-area.ts b/extensions/screenshot/commands/record-area.ts new file mode 100644 index 0000000..e60fb47 --- /dev/null +++ b/extensions/screenshot/commands/record-area.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { toggleRecording } from "../capture.ts"; + +export default run(toggleRecording(true)); diff --git a/extensions/screenshot/commands/record.ts b/extensions/screenshot/commands/record.ts new file mode 100644 index 0000000..b337954 --- /dev/null +++ b/extensions/screenshot/commands/record.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { toggleRecording } from "../capture.ts"; + +export default run(toggleRecording(false)); diff --git a/extensions/screenshot/commands/screenshots.ts b/extensions/screenshot/commands/screenshots.ts new file mode 100644 index 0000000..69ff4b4 --- /dev/null +++ b/extensions/screenshot/commands/screenshots.ts @@ -0,0 +1,343 @@ +import { + action, + close, + type Color, + copy, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + ago, + clock, + companions, + type Family, + formatBytes, + installMarkdown, + type Mode, + recorderPackages, + screenshotPackages, + sessionName, +} from "../lib.ts"; +import { + type Capture, + captures, + copyImage, + family, + folders, + MissingTool, + type Recording, + recording, + screenshot, + showInFolder, + startRecording, + stopRecording, + tilde, + type Tools, + tools, +} from "../system.ts"; + +interface Prefs { + delay?: number; + copy?: boolean; + audio?: boolean; +} + +const MODES: { mode: Mode; title: string; icon: string; tint: Color; about: string; keywords: string[] }[] = [ + { + mode: "area", + title: "Capture Area", + icon: Icons.scan, + tint: "blue", + about: "Drag a rectangle over the part of the screen to keep. Escape cancels.", + keywords: ["region", "selection", "zone", "capture"], + }, + { + mode: "window", + title: "Capture Window", + icon: Icons.window, + tint: "purple", + about: "Click the window to capture.", + keywords: ["window", "fenêtre", "app"], + }, + { + mode: "screen", + title: "Capture Full Screen", + icon: Icons.monitor, + tint: "teal", + about: "Everything on your screens, right away.", + keywords: ["screen", "écran", "full", "entier", "monitor"], + }, +]; + +const shoot = (mode: Mode, prefs: Prefs, delay?: number) => async () => { + try { + await screenshot(mode, { delay: delay ?? prefs.delay, copy: prefs.copy }); + } catch (e) { + if (e instanceof MissingTool) { + await toast(e.message, { style: "failure" }); + return; + } + throw e; + } + return close(); +}; + +function captureItems(t: Tools, prefs: Prefs, pictures: string, fam?: Family): Item[] { + if (!t.shot) { + return [{ + id: "missing-shot", + title: "No screenshot tool found", + subtitle: `for ${sessionName(t.session)}`, + icon: { source: Icons.alert, tint: "orange" }, + section: "Capture", + detail: { + markdown: installMarkdown( + `Backslash captures with **${screenshotPackages(t.session).filter((p) => p !== "wl-copy").join(" + ")}** on ${ + sessionName(t.session) + }. Install it, then open this command again.`, + screenshotPackages(t.session), + fam, + ), + metadata: [ + meta.text("Session", sessionName(t.session), { icon: Icons.monitor }), + meta.tags("Supported", ["grim + slurp", "gnome-screenshot", "spectacle", "maim", "scrot", "import"]), + ], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + const missing = (mode: Mode) => companions(t.shot!, mode).filter((p) => p === "slurp" && !t.slurp); + return MODES.map((m) => ({ + id: `capture:${m.mode}`, + title: m.title, + subtitle: missing(m.mode).length ? `needs ${missing(m.mode).join(", ")}` : undefined, + icon: { source: m.icon, tint: missing(m.mode).length ? "gray" : m.tint }, + section: "Capture", + keywords: m.keywords, + accessories: prefs.delay ? [{ icon: Icons.timer, text: `${prefs.delay} s` }] : [], + detail: { + markdown: missing(m.mode).length + ? installMarkdown(`${m.about}\n\nThis needs **slurp** to select on screen.`, missing(m.mode), fam) + : m.about, + metadata: [ + meta.text("Tool", t.shot!, { icon: Icons.camera }), + meta.text("Session", sessionName(t.session), { icon: Icons.monitor }), + meta.text("Saved to", tilde(pictures), { icon: Icons.folder }), + meta.text("Clipboard", prefs.copy === false ? "Not copied" : "Copied as PNG", { icon: Icons.copy }), + ...(prefs.delay ? [meta.text("Delay", `${prefs.delay} s`, { icon: Icons.timer })] : []), + ], + }, + actions: [ + action(m.title, shoot(m.mode, prefs), { icon: m.icon }), + submenu( + "Capture After a Delay", + [3, 5, 10].map((s) => action(`In ${s} Seconds`, shoot(m.mode, prefs, s), { icon: Icons.timer })), + { icon: Icons.timer, shortcut: "d" }, + ), + open(pictures, "Open Screenshots Folder", { icon: Icons.folder, section: "Folder" }), + ], + })); +} + +function recordItems(t: Tools, rec: Recording | undefined, prefs: Prefs, videos: string, fam?: Family): Item[] { + if (rec) { + const elapsed = (Date.now() - rec.started) / 1000; + return [{ + id: "recording", + title: "Stop Recording", + subtitle: rec.area ? "Area" : "Screen", + icon: { source: Icons.stop, tint: "red" }, + section: "Record", + accessories: [{ tag: `● ${clock(elapsed)}`, color: "red" }], + detail: { + stats: [{ label: "recording", value: clock(elapsed), color: "red" }], + metadata: [ + meta.text("File", tilde(rec.file), { icon: Icons.video }), + meta.text("Recorder", rec.tool, { icon: Icons.terminal }), + meta.text("Started", new Date(rec.started).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), { + icon: Icons.clock, + }), + ], + }, + actions: [ + action("Stop Recording", async () => { + const done = await stopRecording(); + await updateCommandMetadata({ subtitle: null }).catch(() => {}); + await toast(done ? `Saved ${done.file.split("/").pop()}` : "Recording stopped", { style: "success" }); + return refresh(); + }, { icon: Icons.stop, shortcut: "s" }), + open(videos, "Open Recordings Folder", { icon: Icons.folder, section: "Folder" }), + ], + }]; + } + if (!t.recorder) { + const builtIn = t.session.desktop === "gnome" + ? "GNOME records the screen itself: press **Ctrl + Shift + Alt + R**, or use the screenshot tool (Print Screen)." + : t.session.desktop === "kde" && t.session.wayland + ? "On KDE Plasma, Spectacle records the screen: open it and pick **Record**." + : undefined; + return [{ + id: "missing-recorder", + title: "Screen recording unavailable", + subtitle: builtIn ? "use your desktop's recorder" : `for ${sessionName(t.session)}`, + icon: { source: Icons.video, tint: "gray" }, + section: "Record", + detail: { + markdown: builtIn ?? + installMarkdown( + `Backslash records with **${t.session.wayland ? "wf-recorder or wl-screenrec" : "ffmpeg"}**.`, + recorderPackages(t.session), + fam, + ), + metadata: [meta.text("Session", sessionName(t.session), { icon: Icons.monitor })], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + const start = (area: boolean) => async () => { + try { + await startRecording(area, !!prefs.audio); + } catch (e) { + if (e instanceof MissingTool) { + await toast(e.message, { style: "failure" }); + return; + } + throw e; + } + return close(); + }; + const canArea = t.recorder === "ffmpeg" ? t.slop : t.slurp; + return ([false, true] as const).map((area) => ({ + id: `record:${area ? "area" : "screen"}`, + title: area ? "Record Area" : "Record Screen", + subtitle: area && !canArea ? `needs ${t.recorder === "ffmpeg" ? "slop" : "slurp"}` : undefined, + icon: { source: area ? Icons.scan : Icons.video, tint: area && !canArea ? "gray" : "red" }, + section: "Record", + keywords: ["record", "video", "enregistrer", "vidéo", "screencast"], + accessories: prefs.audio ? [{ icon: Icons.mic, tooltip: "With audio" }] : [], + detail: { + markdown: area + ? "Drag a rectangle, recording starts right away. Open this command again to stop." + : "Records the screen you're on. Open this command again, or run **Toggle Screen Recording**, to stop.", + metadata: [ + meta.text("Recorder", t.recorder!, { icon: Icons.video }), + meta.text("Audio", prefs.audio ? "Default input" : "Off", { icon: prefs.audio ? Icons.mic : Icons.speakerOff }), + meta.text("Saved to", tilde(videos), { icon: Icons.folder }), + ], + }, + actions: [ + action(area ? "Record Area" : "Record Screen", start(area), { icon: Icons.video }), + open(videos, "Open Recordings Folder", { icon: Icons.folder, section: "Folder" }), + ], + })); +} + +function captureItem(c: Capture): Item { + const image = c.kind === "image"; + const dims = c.width ? `${c.width} × ${c.height}` : undefined; + const date = new Date(c.modified); + return { + id: c.path, + title: c.name, + subtitle: ago(c.modified), + icon: { source: image ? Icons.image : Icons.video, tint: image ? "blue" : "red" }, + section: "Recent", + keywords: [image ? "screenshot" : "recording", c.folder], + accessories: [...(dims ? [{ text: dims }] : []), { text: formatBytes(c.size) }], + detail: { + image: image ? c.path : undefined, + metadata: [ + ...(dims ? [meta.text("Dimensions", dims, { icon: Icons.image })] : []), + meta.text("Size", formatBytes(c.size), { icon: Icons.drive }), + meta.text("Taken", date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" }), { icon: Icons.clock }), + meta.text("Folder", tilde(c.folder), { icon: Icons.folder }), + meta.tags("Type", [image ? { text: "Screenshot", color: "blue" } : { text: "Recording", color: "red" }]), + ], + }, + actions: [ + open(c.path, image ? "Open" : "Play"), + ...(image + ? [action("Copy Image", async () => { + if (await copyImage(c)) return close("Image copied"); + await toast("Install wl-clipboard (Wayland) or xclip (X11) to copy images", { style: "failure" }); + }, { icon: Icons.copy, shortcut: "c", section: "Copy" })] + : []), + copy(c.path, "Copy Path", { shortcut: "c", section: "Copy" }), + action("Show in Folder", async () => { + await showInFolder(c.path); + return close(); + }, { icon: Icons.folder, shortcut: "o", section: "File" }), + action("Delete", async () => { + await Deno.remove(c.path); + await toast(`Deleted ${c.name}`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${c.name}?`, + shortcut: "Delete", + section: "Danger", + }), + ], + }; +} + +export default list({ + placeholder: "Capture, record, or search recent captures…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "screenshots", title: "Screenshots" }, + { value: "recordings", title: "Recordings" }, + ], + default: "all", + }, + empty: { title: "No captures yet", description: "Take a screenshot and it shows up here", icon: Icons.camera }, + status: (items) => { + const rec = items.find((i) => i.id === "recording"); + if (rec) return `Recording ${rec.accessories?.[0]?.tag?.replace("● ", "") ?? ""}`; + const shots = items.filter((i) => i.section === "Recent" && i.keywords?.[0] === "screenshot").length; + const videos = items.filter((i) => i.section === "Recent" && i.keywords?.[0] === "recording").length; + return [ + shots && `${shots} screenshot${shots > 1 ? "s" : ""}`, + videos && `${videos} recording${videos > 1 ? "s" : ""}`, + ] + .filter(Boolean).join(" · ") || "Nothing captured yet"; + }, + // Live while a recording runs: the elapsed time ticks every second. + async *items({ signal, filter, preferences }) { + const prefs = preferences as Prefs; + const view = filter ?? "all"; + const [t, dirs, fam] = await Promise.all([tools(), folders(), family()]); + let recent = await captures(); + let last: string | undefined; + let wasRecording = false; + while (!signal.aborted) { + const rec = await recording(); + if (wasRecording && !rec) recent = await captures(); // the new file + wasRecording = !!rec; + const subtitle = rec ? `Recording ${clock((Date.now() - rec.started) / 1000)}` : null; + if (subtitle !== last) { + last = subtitle ?? undefined; + updateCommandMetadata({ subtitle }).catch(() => {}); + } + const shown = recent.filter((c) => + view === "all" || (view === "screenshots" ? c.kind === "image" : c.kind === "video") + ); + yield [ + ...(view !== "recordings" ? captureItems(t, prefs, dirs.pictures, fam) : []), + ...(view !== "screenshots" ? recordItems(t, rec, prefs, dirs.videos, fam) : []), + ...shown.map(captureItem), + ]; + if (!rec) return; + await new Promise((r) => setTimeout(r, 1000)); + } + }, +}); diff --git a/extensions/screenshot/lib.ts b/extensions/screenshot/lib.ts new file mode 100644 index 0000000..9188b7b --- /dev/null +++ b/extensions/screenshot/lib.ts @@ -0,0 +1,392 @@ +// Picking a capture tool, building its command line, and parsing what the +// system tells us. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Session + +export type Desktop = "gnome" | "kde" | "hyprland" | "sway" | "other"; + +export interface Session { + wayland: boolean; + desktop: Desktop; +} + +/** The environment variables we look at; `system.ts` reads them. */ +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] as const; + +export type Env = Partial>; + +export function detectSession(env: Env): Session { + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const current = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const desktop: Desktop = env.HYPRLAND_INSTANCE_SIGNATURE || current.includes("hyprland") + ? "hyprland" + : env.SWAYSOCK || current.includes("sway") + ? "sway" + : current.some((d) => d === "gnome" || d === "ubuntu" || d === "unity" || d === "pop") + ? "gnome" + : current.includes("kde") + ? "kde" + : "other"; + return { wayland, desktop }; +} + +export const sessionName = (s: Session) => + `${ + { gnome: "GNOME", kde: "KDE Plasma", hyprland: "Hyprland", sway: "Sway", other: s.wayland ? "Wayland" : "X11" }[ + s.desktop + ] + }${s.desktop === "other" ? "" : s.wayland ? " (Wayland)" : " (X11)"}`; + +// --------------------------------------------------------------------------- +// Tools + +export type ShotTool = "grim" | "gnome-screenshot" | "spectacle" | "maim" | "scrot" | "import"; +export type RecTool = "wf-recorder" | "wl-screenrec" | "ffmpeg"; +export type Mode = "area" | "window" | "screen"; + +/** Which screenshot tools work in this session, best first. */ +export function screenshotCandidates(s: Session): ShotTool[] { + if (s.wayland) { + // GNOME and KDE don't implement wlr-screencopy: only their own tools work. + if (s.desktop === "gnome") return ["gnome-screenshot"]; + if (s.desktop === "kde") return ["spectacle"]; + return ["grim"]; + } + const x11: ShotTool[] = ["maim", "scrot", "import"]; + if (s.desktop === "gnome") return ["gnome-screenshot", ...x11]; + if (s.desktop === "kde") return ["spectacle", ...x11]; + return [...x11, "gnome-screenshot", "spectacle"]; +} + +/** Which recorders work in this session, best first. */ +export function recorderCandidates(s: Session): RecTool[] { + if (s.wayland) return s.desktop === "gnome" || s.desktop === "kde" ? [] : ["wf-recorder", "wl-screenrec"]; + return ["ffmpeg"]; +} + +export const pick = (candidates: T[], available: Set): T | undefined => + candidates.find((t) => available.has(t)); + +/** Programs a tool needs besides itself, for a mode. */ +export function companions(tool: ShotTool | RecTool, mode: Mode | "record-area"): string[] { + if ((tool === "grim" || tool === "wf-recorder" || tool === "wl-screenrec") && mode !== "screen") return ["slurp"]; + if (tool === "ffmpeg" && mode === "record-area") return ["slop"]; + return []; +} + +// --------------------------------------------------------------------------- +// Shell + +/** Single-quotes a string for sh. */ +export const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +/** The command that captures `mode` into `file`. `windows` are "x,y wxh" rectangles to choose from. */ +export function captureCommand(tool: ShotTool, mode: Mode, file: string, windows: string[] = []): string { + const f = q(file); + switch (tool) { + case "grim": + if (mode === "screen") return `grim ${f}`; + if (mode === "window" && windows.length) { + return `geometry=$(printf '%s\\n' ${windows.map(q).join(" ")} | slurp -r) || exit 0; grim -g "$geometry" ${f}`; + } + return `geometry=$(slurp -d) || exit 0; grim -g "$geometry" ${f}`; + case "gnome-screenshot": + return `gnome-screenshot ${mode === "area" ? "-a " : mode === "window" ? "-w " : ""}-f ${f}`; + case "spectacle": + return `spectacle -b -n ${mode === "area" ? "-r" : mode === "window" ? "-a" : "-f"} -o ${f}`; + case "maim": + // A huge tolerance turns maim's selection into "click a window". + return mode === "screen" ? `maim ${f}` : mode === "window" ? `maim -s -t 9999999 ${f}` : `maim -s ${f}`; + case "scrot": + return mode === "screen" ? `scrot ${f}` : mode === "window" ? `scrot -u ${f}` : `scrot -s ${f}`; + case "import": + return mode === "screen" ? `import -window root ${f}` : `import ${f}`; + } +} + +/** Copies an image file to the clipboard, Wayland or X11; succeeds if either works. */ +export const copyImageCommand = (file: string, wayland: boolean) => { + const wl = `wl-copy --type image/png < ${q(file)}`; + const x = `xclip -selection clipboard -t image/png -i ${q(file)}`; + return `{ ${wayland ? `${wl} || ${x}` : `${x} || ${wl}`}; } 2>/dev/null`; +}; + +export interface ShotOptions { + tool: ShotTool; + mode: Mode; + file: string; + wayland: boolean; + /** Seconds to wait first, on top of the time the launcher takes to hide. */ + delay?: number; + copy?: boolean; + windows?: string[]; +} + +/** The script run in the background: capture, copy, notify. */ +export function screenshotScript(o: ShotOptions): string { + const name = basename(o.file); + const wait = Math.max(0, o.delay ?? 0) + 0.4; + return [ + `sleep ${wait}`, + `mkdir -p ${q(dirname(o.file))}`, + `${captureCommand(o.tool, o.mode, o.file, o.windows)} || exit 0`, + `[ -s ${q(o.file)} ] || exit 0`, + o.copy === false + ? `note='Saved to ${dirname(o.file).replace(/'/g, "")}'` + : `if ${copyImageCommand(o.file, o.wayland)}; then note='Copied to the clipboard'; else note='Saved'; fi`, + `notify-send -a Backslash -i ${q(o.file)} 'Screenshot' "${name.replace(/["$`\\]/g, "")} · $note" 2>/dev/null`, + "true", + ].join("\n"); +} + +export interface RecordOptions { + tool: RecTool; + area: boolean; + file: string; + audio?: boolean; + /** Output (monitor) to record; asked otherwise when there are several. */ + output?: string; +} + +/** The recorder replaces the shell (`exec`) so stopping the unit stops it cleanly. */ +export function recordScript(o: RecordOptions): string { + const f = q(o.file); + const lines = ["sleep 0.8", `mkdir -p ${q(dirname(o.file))}`]; + if (o.tool === "ffmpeg") { + const audio = o.audio ? "-f pulse -i default -c:a aac " : ""; + const video = `-c:v libx264 -preset veryfast -pix_fmt yuv420p -vf 'scale=trunc(iw/2)*2:trunc(ih/2)*2'`; + if (o.area) { + lines.push(`geometry=$(slop -f '%wx%h %x,%y') || exit 0`, `set -- $geometry`); + lines.push( + `exec ffmpeg -hide_banner -loglevel error -f x11grab -framerate 30 -video_size "$1" -i "$DISPLAY+$2" ${audio}${video} ${f}`, + ); + } else { + lines.push( + `exec ffmpeg -hide_banner -loglevel error -f x11grab -framerate 30 -i "$DISPLAY" ${audio}${video} ${f}`, + ); + } + return lines.join("\n"); + } + const audio = o.audio ? (o.tool === "wf-recorder" ? " -a" : " --audio") : ""; + if (o.area) { + lines.push(`geometry=$(slurp -d) || exit 0`, `exec ${o.tool} -g "$geometry"${audio} -f ${f}`); + } else { + lines.push(`exec ${o.tool}${o.output ? ` -o ${q(o.output)}` : ""}${audio} -f ${f}`); + } + return lines.join("\n"); +} + +/** `systemd-run` arguments to run a script in the background, outside the launcher. */ +export function systemdRunArgs( + unit: string, + script: string, + o: { description: string; env?: Env; keepChildren?: boolean; killSignal?: string }, +): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + `--description=${o.description}`, + "--collect", + // wl-copy and xclip stay in the background to serve the clipboard. + ...(o.keepChildren ? ["--property=KillMode=process"] : []), + ...(o.killSignal ? [`--property=KillSignal=${o.killSignal}`] : []), + ...Object.entries(o.env ?? {}).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + "/bin/sh", + "-c", + script, + ]; +} + +// --------------------------------------------------------------------------- +// Windows and outputs from the compositor + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} +const rect = (r: Rect) => `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.width)}x${Math.round(r.height)}`; + +/** `hyprctl -j clients` + `hyprctl -j monitors`: windows on the visible workspaces, as slurp rectangles. */ +export function hyprlandWindows(clientsJson: string, monitorsJson: string): string[] { + const monitors = JSON.parse(monitorsJson) as { + activeWorkspace?: { id: number }; + specialWorkspace?: { id: number }; + }[]; + const visible = new Set(monitors.flatMap((m) => [m.activeWorkspace?.id, m.specialWorkspace?.id]).filter(Boolean)); + const clients = JSON.parse(clientsJson) as { + at: [number, number]; + size: [number, number]; + workspace: { id: number }; + mapped?: boolean; + hidden?: boolean; + }[]; + return clients + .filter((c) => c.mapped !== false && !c.hidden && visible.has(c.workspace.id)) + .map((c) => rect({ x: c.at[0], y: c.at[1], width: c.size[0], height: c.size[1] })); +} + +/** `swaymsg -t get_tree -r`: visible windows, as slurp rectangles. */ +export function swayWindows(treeJson: string): string[] { + const out: string[] = []; + const walk = (n: any) => { + if ((n.type === "con" || n.type === "floating_con") && n.visible && (n.pid || n.app_id || n.window)) { + out.push(rect(n.rect)); + } + for (const c of [...(n.nodes ?? []), ...(n.floating_nodes ?? [])]) walk(c); + }; + walk(JSON.parse(treeJson)); + return out; +} + +/** The focused output's name from `hyprctl -j monitors` or `swaymsg -t get_outputs -r`. */ +export function focusedOutput(json: string): string | undefined { + const outputs = JSON.parse(json) as { name: string; focused?: boolean }[]; + return outputs.find((o) => o.focused)?.name ?? (outputs.length === 1 ? outputs[0].name : undefined); +} + +// --------------------------------------------------------------------------- +// Folders and files + +const path = (s: string) => s.replace(/\/+$/, ""); +export const basename = (p: string) => path(p).split("/").pop() ?? p; +export const dirname = (p: string) => path(p).split("/").slice(0, -1).join("/") || "/"; + +/** `~/.config/user-dirs.dirs` → the pictures and videos folders. */ +export function parseUserDirs(text: string, home: string): { pictures?: string; videos?: string } { + const get = (key: string) => { + const m = text.match(new RegExp(`^\\s*${key}="([^"]*)"`, "m")); + if (!m) return undefined; + const p = path(m[1].replace(/^\$HOME/, home)); + return p === home ? undefined : p; // "$HOME/" means the folder is disabled + }; + return { pictures: get("XDG_PICTURES_DIR"), videos: get("XDG_VIDEOS_DIR") }; +} + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** "Screenshot_2026-10-28_14-02-11.png" */ +export function captureName(kind: "Screenshot" | "Recording", date: Date): string { + const d = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + const t = `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`; + return `${kind}_${d}_${t}.${kind === "Screenshot" ? "png" : "mp4"}`; +} + +export const IMAGE = /\.(png|jpe?g|webp|avif|bmp)$/i; +export const VIDEO = /\.(mp4|mkv|webm|mov)$/i; + +/** Width and height from the first bytes of a PNG file. */ +export function pngSize(head: Uint8Array): { width: number; height: number } | undefined { + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (head.length < 24 || sig.some((b, i) => head[i] !== b)) return undefined; + const v = new DataView(head.buffer, head.byteOffset, head.byteLength); + return { width: v.getUint32(16), height: v.getUint32(20) }; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** "just now", "5 min ago", "3 h ago", "yesterday", "12 days ago". */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, (now - ms) / 1000); + if (s < 45) return "just now"; + if (s < 3600) return `${Math.round(s / 60)} min ago`; + if (s < 86400) return `${Math.round(s / 3600)} h ago`; + const days = Math.round(s / 86400); + return days === 1 ? "yesterday" : `${days} days ago`; +} + +/** Elapsed time as a clock: "0:42", "12:05", "1:02:03". */ +export function clock(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + const h = Math.floor(s / 3600); + const mm = String(Math.floor((s % 3600) / 60)).padStart(h ? 2 : 1, "0"); + return `${h ? `${h}:` : ""}${mm}:${pad(s % 60)}`; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +/** `/etc/os-release` → the distribution family. */ +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const PACKAGES: Record>> = { + spectacle: { debian: "kde-spectacle" }, + import: { debian: "imagemagick", arch: "imagemagick", fedora: "ImageMagick", suse: "ImageMagick" }, + "wl-copy": { debian: "wl-clipboard", arch: "wl-clipboard", fedora: "wl-clipboard", suse: "wl-clipboard" }, +}; + +const MANAGERS: Record = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +/** "sudo apt install grim slurp" for the family, every family when unknown. */ +export function installCommands(programs: string[], family?: Family): { family: Family; command: string }[] { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + return families.map((f) => ({ + family: f, + command: `${MANAGERS[f]} ${[...new Set(programs.map((p) => PACKAGES[p]?.[f] ?? p))].join(" ")}`, + })); +} + +/** What to install to take screenshots in this session. */ +export function screenshotPackages(s: Session): string[] { + if (s.wayland) { + if (s.desktop === "gnome") return ["gnome-screenshot", "wl-copy"]; + if (s.desktop === "kde") return ["spectacle", "wl-copy"]; + return ["grim", "slurp", "wl-copy"]; + } + return ["maim", "xclip"]; +} + +export function recorderPackages(s: Session): string[] { + if (s.wayland) return ["wf-recorder", "slurp"]; + return ["ffmpeg", "slop"]; +} + +const FAMILY_NAMES: Record = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +/** Markdown explaining what to install, for an empty state's detail panel. */ +export function installMarkdown(what: string, programs: string[], family?: Family): string { + const lines = installCommands(programs, family).map((c) => `**${FAMILY_NAMES[c.family]}**\n\n\`${c.command}\``); + return `${what}\n\n${lines.join("\n\n")}`; +} diff --git a/extensions/screenshot/lib_test.ts b/extensions/screenshot/lib_test.ts new file mode 100644 index 0000000..53f3cdd --- /dev/null +++ b/extensions/screenshot/lib_test.ts @@ -0,0 +1,257 @@ +import { assert, assertEquals } from "@std/assert"; +import { + ago, + captureCommand, + captureName, + clock, + companions, + detectSession, + focusedOutput, + formatBytes, + hyprlandWindows, + installCommands, + osFamily, + parseUserDirs, + pick, + pngSize, + recorderCandidates, + recordScript, + screenshotCandidates, + screenshotScript, + sessionName, + swayWindows, + systemdRunArgs, +} from "./lib.ts"; + +Deno.test("sessions", () => { + assertEquals(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "ubuntu:GNOME" }), { + wayland: true, + desktop: "gnome", + }); + assertEquals(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "KDE" }).desktop, "kde"); + assertEquals(detectSession({ WAYLAND_DISPLAY: "wayland-1", HYPRLAND_INSTANCE_SIGNATURE: "abc" }), { + wayland: true, + desktop: "hyprland", + }); + assertEquals(detectSession({ XDG_SESSION_TYPE: "wayland", SWAYSOCK: "/run/user/1000/sway.sock" }).desktop, "sway"); + assertEquals(detectSession({ XDG_SESSION_TYPE: "x11", DISPLAY: ":0", WAYLAND_DISPLAY: "w" }), { + wayland: false, + desktop: "other", + }); + assertEquals(sessionName({ wayland: true, desktop: "gnome" }), "GNOME (Wayland)"); + assertEquals(sessionName({ wayland: false, desktop: "other" }), "X11"); +}); + +Deno.test("the first installed tool that works in the session", () => { + const gnome = { wayland: true, desktop: "gnome" as const }; + const hypr = { wayland: true, desktop: "hyprland" as const }; + const x11 = { wayland: false, desktop: "other" as const }; + assertEquals(screenshotCandidates(gnome), ["gnome-screenshot"]); + assertEquals(pick(screenshotCandidates(hypr), new Set(["grim", "maim"])), "grim"); + assertEquals(pick(screenshotCandidates(x11), new Set(["scrot", "import"])), "scrot"); + assertEquals(pick(screenshotCandidates(gnome), new Set(["grim"])), undefined); + assertEquals(recorderCandidates(gnome), []); + assertEquals(pick(recorderCandidates(hypr), new Set(["wl-screenrec"])), "wl-screenrec"); + assertEquals(recorderCandidates(x11), ["ffmpeg"]); + assertEquals(companions("grim", "area"), ["slurp"]); + assertEquals(companions("grim", "screen"), []); + assertEquals(companions("ffmpeg", "record-area"), ["slop"]); +}); + +Deno.test("capture command lines", () => { + const f = "/home/ada/Pictures/Screenshots/Shot it's.png"; + const quoted = `'/home/ada/Pictures/Screenshots/Shot it'\\''s.png'`; + assertEquals(captureCommand("grim", "screen", f), `grim ${quoted}`); + assertEquals(captureCommand("grim", "area", f), `geometry=$(slurp -d) || exit 0; grim -g "$geometry" ${quoted}`); + assertEquals( + captureCommand("grim", "window", f, ["0,0 800x600", "800,0 1120x1080"]), + `geometry=$(printf '%s\\n' '0,0 800x600' '800,0 1120x1080' | slurp -r) || exit 0; grim -g "$geometry" ${quoted}`, + ); + assertEquals(captureCommand("gnome-screenshot", "window", f), `gnome-screenshot -w -f ${quoted}`); + assertEquals(captureCommand("gnome-screenshot", "screen", f), `gnome-screenshot -f ${quoted}`); + assertEquals(captureCommand("spectacle", "area", f), `spectacle -b -n -r -o ${quoted}`); + assertEquals(captureCommand("maim", "window", f), `maim -s -t 9999999 ${quoted}`); + assertEquals(captureCommand("scrot", "window", f), `scrot -u ${quoted}`); + assertEquals(captureCommand("import", "screen", f), `import -window root ${quoted}`); +}); + +Deno.test("screenshot script: waits, captures, copies, notifies", () => { + const script = screenshotScript({ tool: "grim", mode: "screen", file: "/p/S/a.png", wayland: true, delay: 2 }); + const lines = script.split("\n"); + assertEquals(lines[0], "sleep 2.4"); + assertEquals(lines[1], "mkdir -p '/p/S'"); + assertEquals(lines[2], "grim '/p/S/a.png' || exit 0"); + assert(lines[4].startsWith("if { wl-copy --type image/png < '/p/S/a.png' || xclip")); + assert(lines[5].startsWith("notify-send -a Backslash -i '/p/S/a.png' 'Screenshot' \"a.png · $note\"")); + const x = screenshotScript({ tool: "maim", mode: "area", file: "/p/a.png", wayland: false, copy: false }); + assert(!x.includes("xclip")); + assertEquals(x.split("\n")[0], "sleep 0.4"); +}); + +Deno.test("recording scripts exec the recorder", () => { + assertEquals( + recordScript({ tool: "wf-recorder", area: false, file: "/v/r.mp4", output: "DP-1", audio: true }).split("\n").at( + -1, + ), + "exec wf-recorder -o 'DP-1' -a -f '/v/r.mp4'", + ); + assertEquals( + recordScript({ tool: "wl-screenrec", area: true, file: "/v/r.mp4", audio: true }).split("\n").slice(2), + ["geometry=$(slurp -d) || exit 0", `exec wl-screenrec -g "$geometry" --audio -f '/v/r.mp4'`], + ); + const ff = recordScript({ tool: "ffmpeg", area: true, file: "/v/r.mp4" }); + assert(ff.includes(`-video_size "$1" -i "$DISPLAY+$2"`)); + assert(ff.includes("-pix_fmt yuv420p")); +}); + +Deno.test("systemd-run arguments", () => { + const args = systemdRunArgs("backslash-screenshot-1", "grim x", { + description: "Backslash: screenshot", + keepChildren: true, + env: { WAYLAND_DISPLAY: "wayland-1", DISPLAY: undefined }, + }); + assertEquals(args, [ + "--user", + "--quiet", + "--unit=backslash-screenshot-1", + "--description=Backslash: screenshot", + "--collect", + "--property=KillMode=process", + "--setenv=WAYLAND_DISPLAY=wayland-1", + "/bin/sh", + "-c", + "grim x", + ]); + assert(systemdRunArgs("u", "s", { description: "d", killSignal: "SIGINT" }).includes("--property=KillSignal=SIGINT")); +}); + +Deno.test("windows from Hyprland and Sway", () => { + const monitors = JSON.stringify([ + { name: "eDP-1", focused: false, activeWorkspace: { id: 1 }, specialWorkspace: { id: 0 } }, + { name: "DP-2", focused: true, activeWorkspace: { id: 4 }, specialWorkspace: { id: 0 } }, + ]); + const clients = JSON.stringify([ + { at: [10, 40], size: [940, 1030], workspace: { id: 1 }, mapped: true, hidden: false, class: "kitty" }, + { at: [1930, 40], size: [1900, 1030], workspace: { id: 4 }, mapped: true, hidden: false, class: "firefox" }, + { at: [0, 0], size: [500, 500], workspace: { id: 2 }, mapped: true, hidden: false, class: "hidden-ws" }, + { at: [0, 0], size: [500, 500], workspace: { id: 1 }, mapped: true, hidden: true, class: "grouped" }, + ]); + assertEquals(hyprlandWindows(clients, monitors), ["10,40 940x1030", "1930,40 1900x1030"]); + assertEquals(focusedOutput(monitors), "DP-2"); + + const tree = { + type: "root", + nodes: [{ + type: "output", + name: "eDP-1", + nodes: [{ + type: "workspace", + nodes: [ + { type: "con", visible: true, pid: 100, app_id: "foot", rect: { x: 0, y: 0, width: 960, height: 1080 } }, + { type: "con", visible: false, pid: 101, app_id: "tabbed", rect: { x: 960, y: 0, width: 960, height: 1080 } }, + ], + floating_nodes: [ + { type: "floating_con", visible: true, pid: 102, rect: { x: 600, y: 300, width: 640, height: 480 } }, + ], + }], + }], + }; + assertEquals(swayWindows(JSON.stringify(tree)), ["0,0 960x1080", "600,300 640x480"]); + assertEquals(focusedOutput(JSON.stringify([{ name: "HDMI-A-1" }])), "HDMI-A-1"); +}); + +Deno.test("folders and names", () => { + const dirs = `# This file is written by xdg-user-dirs-update +XDG_DESKTOP_DIR="$HOME/Bureau" +XDG_PICTURES_DIR="$HOME/Images" +XDG_VIDEOS_DIR="$HOME/" +`; + assertEquals(parseUserDirs(dirs, "/home/ada"), { pictures: "/home/ada/Images", videos: undefined }); + assertEquals(captureName("Screenshot", new Date(2026, 9, 28, 14, 2, 11)), "Screenshot_2026-10-28_14-02-11.png"); + assertEquals(captureName("Recording", new Date(2026, 0, 5, 9, 0, 0)), "Recording_2026-01-05_09-00-00.mp4"); +}); + +Deno.test("png size", () => { + const head = new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0, + 0, + 0, + 13, + 0x49, + 0x48, + 0x44, + 0x52, + 0, + 0, + 0x07, + 0x80, + 0, + 0, + 0x04, + 0x38, + ]); + assertEquals(pngSize(head), { width: 1920, height: 1080 }); + assertEquals(pngSize(new Uint8Array([0xff, 0xd8, 0xff])), undefined); +}); + +Deno.test("formatting", () => { + assertEquals([formatBytes(512), formatBytes(2048), formatBytes(1536 * 1024), formatBytes(250 * 1024 * 1024)], [ + "512 B", + "2.0 KB", + "1.5 MB", + "250 MB", + ]); + const now = Date.UTC(2026, 9, 28, 12); + assertEquals( + [ago(now - 10_000, now), ago(now - 5 * 60_000, now), ago(now - 3 * 3600_000, now), ago(now - 86400_000, now)], + ["just now", "5 min ago", "3 h ago", "yesterday"], + ); + assertEquals([clock(42), clock(725), clock(3723)], ["0:42", "12:05", "1:02:03"]); +}); + +Deno.test("what to install", () => { + const ubuntu = `NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n`; + const fedora = `NAME="Fedora Linux"\nID=fedora\n`; + const tumbleweed = `ID="opensuse-tumbleweed"\nID_LIKE="opensuse suse"\n`; + assertEquals([osFamily(ubuntu), osFamily(fedora), osFamily(tumbleweed), osFamily("ID=nixos")], [ + "debian", + "fedora", + "suse", + undefined, + ]); + assertEquals(installCommands(["spectacle", "wl-copy"], "debian"), [ + { family: "debian", command: "sudo apt install kde-spectacle wl-clipboard" }, + ]); + assertEquals(installCommands(["grim", "slurp"]).length, 4); +}); + +/** `sh -n`: the generated scripts parse. */ +async function parses(script: string) { + const { success, stderr } = await new Deno.Command("sh", { args: ["-n", "-c", script], stderr: "piped" }).output(); + assert(success, `${new TextDecoder().decode(stderr)}\n${script}`); +} + +Deno.test("every generated script is valid sh", async () => { + for (const tool of ["grim", "gnome-screenshot", "spectacle", "maim", "scrot", "import"] as const) { + for (const mode of ["area", "window", "screen"] as const) { + for (const copy of [true, false]) { + await parses( + screenshotScript({ tool, mode, file: "/h/P/it's.png", wayland: true, copy, windows: ["0,0 1x1"] }), + ); + } + } + } + for (const tool of ["wf-recorder", "wl-screenrec", "ffmpeg"] as const) { + for (const area of [true, false]) { + await parses(recordScript({ tool, area, file: "/v/a b.mp4", audio: true, output: "DP-1" })); + } + } +}); diff --git a/extensions/screenshot/system.ts b/extensions/screenshot/system.ts new file mode 100644 index 0000000..9d8afcc --- /dev/null +++ b/extensions/screenshot/system.ts @@ -0,0 +1,339 @@ +// Everything that touches the system: environment, installed tools, folders, +// background jobs (systemd-run), the clipboard. + +import { exec, openExternal, storage } from "@backslash/api"; +import { + basename, + captureName, + detectSession, + dirname, + type Env, + focusedOutput, + hyprlandWindows, + IMAGE, + type Mode, + osFamily, + parseUserDirs, + pick, + pngSize, + recorderCandidates, + recordScript, + type RecTool, + screenshotCandidates, + screenshotScript, + type Session, + SESSION_ENV, + type ShotTool, + swayWindows, + systemdRunArgs, + VIDEO, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key); + } catch { + return undefined; + } +}; + +export const HOME = env("HOME") ?? ""; +export const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +export const session = (): Session => detectSession(sessionEnv()); + +/** Whether a program can be started: installed and allowed. */ +export async function installed(program: string, args = ["--version"]): Promise { + try { + await new Deno.Command(program, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }).output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +const PROBE: Record = { import: ["-version"], slurp: ["-h"], grim: ["-h"], slop: ["--version"] }; + +/** Which of these programs are installed. */ +export async function available(programs: string[]): Promise> { + const found = await Promise.all(programs.map((p) => installed(p, PROBE[p] ?? ["--version"]))); + return new Set(programs.filter((_, i) => found[i])); +} + +export interface Tools { + session: Session; + shot?: ShotTool; + recorder?: RecTool; + slurp: boolean; + slop: boolean; +} + +export async function tools(): Promise { + const s = session(); + const shots = screenshotCandidates(s); + const recs = recorderCandidates(s); + const found = await available([...shots, ...recs, "slurp", "slop"]); + return { + session: s, + shot: pick(shots, found), + recorder: pick(recs, found), + slurp: found.has("slurp"), + slop: found.has("slop"), + }; +} + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +// --------------------------------------------------------------------------- +// Folders + +export async function folders(): Promise<{ pictures: string; videos: string }> { + const dirs = parseUserDirs(await Deno.readTextFile(`${HOME}/.config/user-dirs.dirs`).catch(() => ""), HOME); + return { + pictures: `${dirs.pictures ?? `${HOME}/Pictures`}/Screenshots`, + videos: `${dirs.videos ?? `${HOME}/Videos`}/Screencasts`, + }; +} + +export interface Capture { + path: string; + name: string; + folder: string; + kind: "image" | "video"; + size: number; + modified: number; + width?: number; + height?: number; +} + +async function readHead(path: string): Promise { + const f = await Deno.open(path, { read: true }); + try { + const buf = new Uint8Array(24); + const n = await f.read(buf); + return buf.subarray(0, n ?? 0); + } finally { + f.close(); + } +} + +/** Screenshots and recordings, newest first. */ +export async function captures(limit = 60): Promise { + const { pictures, videos } = await folders(); + const found: Capture[] = []; + for (const folder of new Set([pictures, videos])) { + try { + for await (const e of Deno.readDir(folder)) { + if (!e.isFile) continue; + const kind = IMAGE.test(e.name) ? "image" : VIDEO.test(e.name) ? "video" : undefined; + if (!kind) continue; + const path = `${folder}/${e.name}`; + const st = await Deno.stat(path).catch(() => undefined); + if (st) found.push({ path, name: e.name, folder, kind, size: st.size, modified: st.mtime?.getTime() ?? 0 }); + } + } catch { + // The folder doesn't exist yet, or isn't readable. + } + } + const recent = found.sort((a, b) => b.modified - a.modified).slice(0, limit); + await Promise.all( + recent.filter((c) => /\.png$/i.test(c.name)).map(async (c) => { + const size = pngSize(await readHead(c.path).catch(() => new Uint8Array())); + if (size) Object.assign(c, size); + }), + ); + return recent; +} + +// --------------------------------------------------------------------------- +// Background jobs + +async function detach( + unit: string, + script: string, + o: { description: string; keepChildren?: boolean; killSignal?: string }, +) { + try { + await exec("systemd-run", systemdRunArgs(unit, script, { ...o, env: sessionEnv() })); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to capture in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: captures run in the background with systemd-run --user"); + } + throw e; + } +} + +/** Rectangles of the windows to pick from, when the compositor tells us. */ +async function windows(s: Session): Promise { + try { + if (s.desktop === "hyprland") { + const [clients, monitors] = await Promise.all([ + exec("hyprctl", ["-j", "clients"]), + exec("hyprctl", ["-j", "monitors"]), + ]); + return hyprlandWindows(clients, monitors); + } + if (s.desktop === "sway") return swayWindows(await exec("swaymsg", ["-t", "get_tree", "-r"])); + } catch { + // Not available: slurp lets the user draw the window's area instead. + } + return []; +} + +async function output(s: Session): Promise { + try { + if (s.desktop === "hyprland") return focusedOutput(await exec("hyprctl", ["-j", "monitors"])); + if (s.desktop === "sway") return focusedOutput(await exec("swaymsg", ["-t", "get_outputs", "-r"])); + } catch { + // The recorder asks. + } + return undefined; +} + +export class MissingTool extends Error {} + +/** Captures in the background, once the launcher is hidden. Returns the file it will write. */ +export async function screenshot(mode: Mode, o: { delay?: number; copy?: boolean } = {}): Promise { + const t = await tools(); + if (!t.shot) throw new MissingTool("No screenshot tool found"); + if (t.shot === "grim" && mode !== "screen" && !t.slurp) throw new MissingTool("slurp is needed to select an area"); + const file = `${(await folders()).pictures}/${captureName("Screenshot", new Date())}`; + const script = screenshotScript({ + tool: t.shot, + mode, + file, + wayland: t.session.wayland, + delay: o.delay, + copy: o.copy, + windows: mode === "window" && t.shot === "grim" ? await windows(t.session) : [], + }); + await detach(`backslash-screenshot-${Date.now()}`, script, { + description: "Backslash: screenshot", + keepChildren: true, + }); + return file; +} + +// --------------------------------------------------------------------------- +// Recording + +export const RECORDING_UNIT = "backslash-screen-recording"; + +export interface Recording { + file: string; + started: number; + tool: RecTool; + area: boolean; +} + +/** The recording in progress, if any. */ +export async function recording(): Promise { + const saved = (await storage.get("recording")) as unknown as Recording | undefined; + if (!saved) return undefined; + const state = await exec("systemctl", ["--user", "is-active", RECORDING_UNIT]).catch(() => "inactive"); + if (state === "active" || state === "activating") return saved; + await storage.delete("recording"); + return undefined; +} + +export async function startRecording(area: boolean, audio: boolean): Promise { + const t = await tools(); + if (!t.recorder) { + if (t.session.wayland && t.session.desktop === "gnome") { + throw new Error("GNOME records the screen itself: press Ctrl + Shift + Alt + R"); + } + if (t.session.wayland && t.session.desktop === "kde") throw new Error("On KDE Plasma, record with Spectacle"); + throw new MissingTool("No screen recorder found"); + } + if (area && t.recorder !== "ffmpeg" && !t.slurp) throw new MissingTool("slurp is needed to select an area"); + if (area && t.recorder === "ffmpeg" && !t.slop) throw new MissingTool("slop is needed to select an area on X11"); + const file = `${(await folders()).videos}/${captureName("Recording", new Date())}`; + const script = recordScript({ + tool: t.recorder, + area, + file, + audio, + output: area ? undefined : await output(t.session), + }); + await exec("systemctl", ["--user", "reset-failed", RECORDING_UNIT]).catch(() => {}); + await detach(RECORDING_UNIT, script, { description: "Backslash: screen recording", killSignal: "SIGINT" }); + const rec = { file, started: Date.now() + 800, tool: t.recorder, area }; + await storage.set("recording", rec as any); + return rec; +} + +/** Stops the recorder (SIGINT, so the file is finalized) and waits for it. */ +export async function stopRecording(): Promise { + const rec = await recording(); + await exec("systemctl", ["--user", "stop", RECORDING_UNIT]).catch(() => {}); + await storage.delete("recording"); + return rec; +} + +// --------------------------------------------------------------------------- +// Files + +async function pipe(program: string, args: string[], input?: Uint8Array): Promise { + try { + const child = new Deno.Command(program, { + args, + stdin: input ? "piped" : "null", + stdout: "null", + stderr: "null", + }).spawn(); + if (input) { + const w = child.stdin.getWriter(); + await w.write(input); + await w.close(); + } + return (await child.status).success; + } catch { + return false; + } +} + +/** Copies a picture to the clipboard (wl-copy, then xclip). */ +export async function copyImage(c: Capture): Promise { + const type = c.name.toLowerCase().endsWith(".png") + ? "image/png" + : c.name.match(/\.jpe?g$/i) + ? "image/jpeg" + : "image/png"; + const wayland = session().wayland; + const wl = async () => pipe("wl-copy", ["--type", type], await Deno.readFile(c.path)); + const x = () => pipe("xclip", ["-selection", "clipboard", "-t", type, "-i", c.path]); + return wayland ? (await wl()) || (await x()) : (await x()) || (await wl()); +} + +/** Opens the folder with the file selected (FileManager1), or just the folder. */ +export async function showInFolder(path: string): Promise { + const uri = `file://${path.split("/").map(encodeURIComponent).join("/")}`; + try { + await exec("gdbus", [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + `['${uri.replace(/'/g, "%27")}']`, + "", + ]); + } catch { + await openExternal(dirname(path)); + } +} + +export const tilde = (p: string) => (HOME && p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p); +export { basename }; From 2065ec647e12be16b833b9762bd3e636173e58b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:03:27 +0000 Subject: [PATCH 06/10] Calendar, Obsidian, Quick Notes, Passwords, Web Search and World Clock Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/calendar/backslash.toml | 62 ++ extensions/calendar/calendars.ts | 263 ++++++++ extensions/calendar/commands/upcoming.ts | 83 +++ extensions/calendar/ics.ts | 619 ++++++++++++++++++ extensions/calendar/lib.ts | 281 ++++++++ extensions/calendar/lib_test.ts | 398 +++++++++++ extensions/calendar/setup.ts | 93 +++ extensions/obsidian/backslash.toml | 73 +++ extensions/obsidian/commands/capture.ts | 17 + extensions/obsidian/commands/daily.ts | 17 + extensions/obsidian/commands/search.ts | 81 +++ extensions/obsidian/commands/vaults.ts | 60 ++ extensions/obsidian/lib.ts | 388 +++++++++++ extensions/obsidian/lib_test.ts | 181 +++++ extensions/obsidian/vault.ts | 252 +++++++ extensions/passwords/backends.ts | 297 +++++++++ extensions/passwords/backslash.toml | 55 ++ extensions/passwords/commands/search.ts | 81 +++ extensions/passwords/lib.ts | 260 ++++++++ extensions/passwords/lib_test.ts | 206 ++++++ extensions/passwords/testdata/bin/bw | 26 + .../passwords/testdata/bin/keepassxc-cli | 15 + extensions/passwords/testdata/bin/op | 10 + extensions/passwords/testdata/bin/pass | 9 + extensions/passwords/ui.ts | 323 +++++++++ extensions/quick-notes/backslash.toml | 46 ++ extensions/quick-notes/commands/capture.ts | 12 + extensions/quick-notes/commands/new.ts | 7 + extensions/quick-notes/commands/notes.ts | 48 ++ extensions/quick-notes/lib.ts | 189 ++++++ extensions/quick-notes/lib_test.ts | 114 ++++ extensions/quick-notes/notes.ts | 288 ++++++++ extensions/web-search/backslash.toml | 51 ++ extensions/web-search/commands/search.ts | 255 ++++++++ extensions/web-search/lib.ts | 351 ++++++++++ extensions/web-search/lib_test.ts | 128 ++++ extensions/world-clock/add.ts | 53 ++ extensions/world-clock/backslash.toml | 44 ++ extensions/world-clock/cities.ts | 253 +++++++ extensions/world-clock/clocks.ts | 200 ++++++ extensions/world-clock/commands/add-city.ts | 3 + extensions/world-clock/commands/clocks.ts | 140 ++++ extensions/world-clock/lib.ts | 361 ++++++++++ extensions/world-clock/lib_test.ts | 179 +++++ 44 files changed, 6872 insertions(+) create mode 100644 extensions/calendar/backslash.toml create mode 100644 extensions/calendar/calendars.ts create mode 100644 extensions/calendar/commands/upcoming.ts create mode 100644 extensions/calendar/ics.ts create mode 100644 extensions/calendar/lib.ts create mode 100644 extensions/calendar/lib_test.ts create mode 100644 extensions/calendar/setup.ts create mode 100644 extensions/obsidian/backslash.toml create mode 100644 extensions/obsidian/commands/capture.ts create mode 100644 extensions/obsidian/commands/daily.ts create mode 100644 extensions/obsidian/commands/search.ts create mode 100644 extensions/obsidian/commands/vaults.ts create mode 100644 extensions/obsidian/lib.ts create mode 100644 extensions/obsidian/lib_test.ts create mode 100644 extensions/obsidian/vault.ts create mode 100644 extensions/passwords/backends.ts create mode 100644 extensions/passwords/backslash.toml create mode 100644 extensions/passwords/commands/search.ts create mode 100644 extensions/passwords/lib.ts create mode 100644 extensions/passwords/lib_test.ts create mode 100755 extensions/passwords/testdata/bin/bw create mode 100755 extensions/passwords/testdata/bin/keepassxc-cli create mode 100755 extensions/passwords/testdata/bin/op create mode 100755 extensions/passwords/testdata/bin/pass create mode 100644 extensions/passwords/ui.ts create mode 100644 extensions/quick-notes/backslash.toml create mode 100644 extensions/quick-notes/commands/capture.ts create mode 100644 extensions/quick-notes/commands/new.ts create mode 100644 extensions/quick-notes/commands/notes.ts create mode 100644 extensions/quick-notes/lib.ts create mode 100644 extensions/quick-notes/lib_test.ts create mode 100644 extensions/quick-notes/notes.ts create mode 100644 extensions/web-search/backslash.toml create mode 100644 extensions/web-search/commands/search.ts create mode 100644 extensions/web-search/lib.ts create mode 100644 extensions/web-search/lib_test.ts create mode 100644 extensions/world-clock/add.ts create mode 100644 extensions/world-clock/backslash.toml create mode 100644 extensions/world-clock/cities.ts create mode 100644 extensions/world-clock/clocks.ts create mode 100644 extensions/world-clock/commands/add-city.ts create mode 100644 extensions/world-clock/commands/clocks.ts create mode 100644 extensions/world-clock/lib.ts create mode 100644 extensions/world-clock/lib_test.ts diff --git a/extensions/calendar/backslash.toml b/extensions/calendar/backslash.toml new file mode 100644 index 0000000..f34fe47 --- /dev/null +++ b/extensions/calendar/backslash.toml @@ -0,0 +1,62 @@ +[extension] +id = "calendar" +title = "Calendar" +description = "Your upcoming events from Google, Outlook, iCloud, Fastmail or Proton calendars, with one-key meeting joins" +version = "1.0.0" +author = "Backslash" +icon = "bs-calendar" +license = "MIT" + +# Calendars are read from their private iCal (.ics) addresses. Only these +# services can be reached; *.icloud.com covers iCloud's p01…p180-caldav hosts. +[permissions] +net = [ + "calendar.google.com", + "outlook.office365.com", + "outlook.office.com", + "outlook.live.com", + "caldav.fastmail.com", + "user.fm", + "*.icloud.com", + "calendar.proton.me", + "calendar.zoho.com", + "calendar.zoho.eu", + "export.calendar.yahoo.com", +] + +[[commands]] +name = "upcoming" +title = "Upcoming Events" +description = "Today, tomorrow and the week ahead; Enter joins the meeting" +icon = "bs-calendar" +keywords = [ + "calendar", + "events", + "meeting", + "meetings", + "schedule", + "agenda", + "today", + "zoom", + "meet", + "teams", + "calendrier", + "agenda", + "réunion", + "réunions", + "rendez-vous", + "événements", +] + +[[preferences]] +name = "urls" +title = "Calendar addresses" +description = "Private iCal (.ics) addresses, one per line. Google: Settings → your calendar → Integrate calendar → Secret address in iCal format." +type = "secret" + +[[preferences]] +name = "hour12" +title = "12-hour clock" +description = "Show 3:00 PM instead of 15:00" +type = "boolean" +default = false diff --git a/extensions/calendar/calendars.ts b/extensions/calendar/calendars.ts new file mode 100644 index 0000000..f36fe52 --- /dev/null +++ b/extensions/calendar/calendars.ts @@ -0,0 +1,263 @@ +// Fetching the calendars, with a short cache in storage so opening the +// command is instant, and the events as launcher items. + +import { + type Action, + action, + copy, + detail, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + storage, +} from "@backslash/api"; +import { + attending, + type Calendar, + calendarLabel, + cleanDescription, + describeRule, + eventText, + findMeeting, + formatDuration, + hostAllowed, + isNow, + type Occurrence, + PALETTE, + parseCalendar, + placeOf, + relative, + timeRange, + urlKey, +} from "./lib.ts"; + +export const CACHE_MINUTES = 5; +/** Larger calendars are kept in memory only, not in storage. */ +const MAX_CACHED = 3_000_000; + +export interface Loaded { + url: string; + index: number; + name: string; + calendar?: Calendar; + error?: string; + fetchedAt?: number; +} + +type Cached = { at: number; text: string }; +const memory = new Map(); + +async function fetchText(url: string, signal: AbortSignal): Promise { + if (!hostAllowed(url)) { + throw new Error( + `${new URL(url).hostname} is not one of the calendar services this extension may reach ` + + "(Google, Outlook, iCloud, Fastmail, Proton, Zoho, Yahoo)", + ); + } + let res: Response; + try { + res = await fetch(url, { signal, headers: { Accept: "text/calendar, */*" } }); + } catch (e) { + if (signal.aborted) throw e; + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new Error(`${new URL(url).hostname} is not allowed (redirected outside the allowed calendar hosts?)`); + } + throw new Error(`Could not reach ${new URL(url).hostname}: ${(e as Error).message}`); + } + if (res.status === 401 || res.status === 403 || res.status === 404) { + throw new Error(`${new URL(url).hostname} answered ${res.status}: the address may have been reset; copy it again`); + } + if (!res.ok) throw new Error(`${new URL(url).hostname} answered ${res.status}`); + const text = await res.text(); + if (!/BEGIN:VCALENDAR/i.test(text.slice(0, 2000))) { + throw new Error("This address does not return an iCalendar (.ics) file: use the “iCal” / “ICS” address"); + } + return text; +} + +/** One calendar, from memory, storage (if fresh) or the network. */ +async function loadOne(url: string, index: number, signal: AbortSignal, force: boolean): Promise { + const key = `ics:${await urlKey(url)}`; + const fresh = (c?: Cached) => c && Date.now() - c.at < CACHE_MINUTES * 60_000; + let cached = memory.get(url); + if (!force && !fresh(cached)) { + const stored = await storage.get(key).catch(() => undefined); + if (stored && typeof stored.text === "string") cached = stored; + } + try { + if (force || !fresh(cached)) { + const text = await fetchText(url, signal); + cached = { at: Date.now(), text }; + memory.set(url, cached); + if (text.length <= MAX_CACHED) await storage.set(key, cached).catch(() => {}); + } + } catch (e) { + if (signal.aborted) throw e; + // Offline or failing: an older copy is better than nothing. + if (!cached) return { url, index, name: calendarLabel(url), error: (e as Error).message }; + const calendar = parseCalendar(cached.text); + return { + url, + index, + name: calendar.name ?? calendarLabel(url), + calendar, + fetchedAt: cached.at, + error: (e as Error).message, + }; + } + const calendar = parseCalendar(cached!.text); + return { url, index, name: calendar.name ?? calendarLabel(url), calendar, fetchedAt: cached!.at }; +} + +export function loadAll(urls: string[], signal: AbortSignal, force = false): Promise { + return Promise.all(urls.map((u, i) => loadOne(u, i, signal, force))); +} + +// --- Items ------------------------------------------------------------------- + +export interface Shown extends Occurrence { + calendar: Loaded; +} + +const longDate = (at: number, zone: string) => + new Date(at).toLocaleDateString("en-GB", { timeZone: zone, weekday: "long", day: "numeric", month: "long" }); + +const mapsUrl = (place: string) => `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place)}`; + +function eventMetadata(o: Shown, zone: string, now: number, several: boolean, hour12: boolean): Metadata[] { + const e = o.event; + const meeting = findMeeting(e); + const place = placeOf(e.location); + const { going, total } = attending(e); + const lines: Metadata[] = [ + meta.text("When", `${longDate(o.start, zone)}, ${timeRange(o, zone, now, hour12)}`, { icon: Icons.calendar }), + ]; + if (isNow(o, now)) { + lines.push( + meta.text("Status", `In progress · ends ${relative(o.end, now)}`, { icon: Icons.inProgress, color: "green" }), + ); + } + if (e.status === "TENTATIVE") lines.push(meta.text("Status", "Tentative", { icon: Icons.question, color: "orange" })); + if (place) lines.push(meta.text("Location", place, { icon: Icons.pin })); + if (meeting) lines.push(meta.link("Video call", meeting.service, meeting.url)); + if (e.organizer?.name || e.organizer?.email) { + lines.push(meta.person("Organizer", e.organizer.name ?? e.organizer.email!)); + } + if (total) { + lines.push(meta.text("Attendees", total === going ? String(total) : `${going} of ${total} going`, { + icon: Icons.person, + })); + const names = e.attendees.filter((a) => a.status !== "DECLINED").map((a) => a.name ?? a.email ?? "").filter( + Boolean, + ); + if (names.length) { + lines.push(meta.tags("Guests", [...names.slice(0, 8), ...(names.length > 8 ? [`+${names.length - 8}`] : [])])); + } + } + const rule = describeRule(e.rule); + if (o.recurring) lines.push(meta.text("Repeats", rule ?? "Yes", { icon: Icons.repeat })); + lines.push(meta.separator()); + lines.push( + meta.text("Calendar", o.calendar.name, { icon: Icons.dot, color: PALETTE[o.calendar.index % PALETTE.length] }), + ); + if (several && e.transparent) lines.push(meta.text("Shown as", "Free")); + return lines; +} + +function eventActions(o: Shown, zone: string, now: number, hour12: boolean): Action[] { + const e = o.event; + const meeting = findMeeting(e); + const place = placeOf(e.location); + const page = action("Show Details", () => + detail({ + title: e.summary, + markdown: `# ${e.summary}\n\n${cleanDescription(e.description) || "_No description._"}`, + metadata: eventMetadata(o, zone, now, true, hour12), + status: `${o.calendar.name} · ${timeRange(o, zone, now, hour12)}`, + actions: eventActions(o, zone, now, hour12).filter((a) => a.title !== "Show Details"), + }), { icon: Icons.info, shortcut: "i" }); + return [ + ...(meeting ? [open(meeting.url, "Join Meeting", { icon: Icons.video })] : []), + page, + ...(e.url && /^https?:/i.test(e.url) && e.url !== meeting?.url + ? [open(e.url, "Open Event Page", { section: "Open" })] + : []), + ...(place ? [open(mapsUrl(place), "Open Location in Maps", { icon: Icons.pin, section: "Open" })] : []), + ...(meeting ? [copy(meeting.url, "Copy Meeting Link", { shortcut: "c", section: "Copy" })] : []), + copy(eventText(o, zone, now, meeting), "Copy Event Details", { section: "Copy" }), + ...(place ? [copy(place, "Copy Location", { section: "Copy" })] : []), + action("Refresh Calendars", () => { + requestRefresh(); + return refresh(); + }, { icon: Icons.refresh, shortcut: "r", section: "Calendar" }), + ]; +} + +/** Set by "Refresh Calendars": the next load skips the cache. */ +let force = false; +export function takeForce(): boolean { + const f = force; + force = false; + return f; +} +export function requestRefresh() { + force = true; +} + +export function eventItem( + o: Shown, + o2: { zone: string; now: number; section: string; several: boolean; hour12: boolean }, +): Item { + const { zone, now, section, several, hour12 } = o2; + const e = o.event; + const meeting = findMeeting(e); + const place = placeOf(e.location); + const live = !o.allDay && isNow(o, now); + const past = o.end <= now; + const color = PALETTE[o.calendar.index % PALETTE.length]; + const { total } = attending(e); + const desc = cleanDescription(e.description); + const soon = !o.allDay && !live && o.start > now && o.start - now < 3 * 3_600_000; + return { + id: `${o.calendar.index}:${e.uid}:${o.start}`, + title: e.summary, + subtitle: timeRange(o, zone, now, hour12), + icon: live + ? { source: meeting ? Icons.video : Icons.inProgress, tint: "green" } + : { source: meeting ? Icons.video : o.allDay ? Icons.calendar : Icons.dot, tint: past ? "gray" : color }, + section, + keywords: [place, meeting?.service ?? "", o.calendar.name, e.organizer?.name ?? ""].filter(Boolean), + accessories: [ + ...(live ? [{ tag: `Now · ends ${relative(o.end, now)}`, color: "green" as const }] : []), + ...(soon + ? [{ tag: relative(o.start, now), color: o.start - now <= 15 * 60_000 ? "orange" as const : "blue" as const }] + : []), + ...(e.status === "TENTATIVE" + ? [{ icon: { source: Icons.question, tint: "orange" as const }, tooltip: "Tentative" }] + : []), + ...(meeting ? [{ text: meeting.service, tooltip: meeting.url }] : []), + ...(place + ? [{ icon: Icons.pin, text: place.length > 28 ? `${place.slice(0, 27)}…` : place, tooltip: place }] + : []), + ...(total > 1 ? [{ icon: Icons.person, text: String(total), tooltip: `${total} attendees` }] : []), + ...(o.recurring ? [{ icon: Icons.repeat, tooltip: describeRule(e.rule) ?? "Recurring" }] : []), + ...(several ? [{ icon: { source: Icons.dot, tint: color }, tooltip: o.calendar.name }] : []), + ], + detail: { + stats: [ + { + label: o.allDay ? "Day" : "Starts", + value: o.allDay ? "All day" : timeRange(o, zone, now, hour12).split(" – ")[0], + }, + { label: "Duration", value: formatDuration(o.end - o.start) }, + ...(total ? [{ label: "Attendees", value: total }] : []), + ], + markdown: desc ? (desc.length > 1500 ? `${desc.slice(0, 1500)}…` : desc) : undefined, + metadata: eventMetadata(o, zone, now, several, hour12), + }, + actions: eventActions(o, zone, now, hour12), + }; +} diff --git a/extensions/calendar/commands/upcoming.ts b/extensions/calendar/commands/upcoming.ts new file mode 100644 index 0000000..ab7624d --- /dev/null +++ b/extensions/calendar/commands/upcoming.ts @@ -0,0 +1,83 @@ +import { copy, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { CACHE_MINUTES, eventItem, loadAll, type Loaded, type Shown, takeForce } from "../calendars.ts"; +import { expand, isNow, parseUrls, sectionOf, statusFor, subtitleFor } from "../lib.ts"; +import { setupItems } from "../setup.ts"; + +const DAY = 86_400_000; +let footer = ""; + +/** Sleeps until the next minute (or `signal`), so "in 12 min" stays right. */ +const nextMinute = (signal: AbortSignal) => + new Promise((resolve) => { + const t = setTimeout(resolve, 60_000 - (Date.now() % 60_000) + 50); + signal.addEventListener("abort", () => { + clearTimeout(t); + resolve(); + }, { once: true }); + }); + +function problemItem(c: Loaded): Item { + return { + id: `error:${c.index}`, + title: c.calendar ? `${c.name}: showing an older copy` : `${c.name} could not be loaded`, + subtitle: c.error, + icon: { source: Icons.alert, tint: c.calendar ? "orange" : "red" }, + section: "Problems", + detail: { markdown: `**${c.name}**\n\n${c.error}` }, + actions: [copy(c.error ?? "", "Copy Error")], + }; +} + +export default list({ + placeholder: "Filter events by title, place, people, calendar…", + filter: { + options: [ + { value: "7", title: "Next 7 Days" }, + { value: "1", title: "Today" }, + { value: "30", title: "Next 30 Days" }, + ], + }, + empty: { title: "Nothing planned", description: "Enjoy the free time.", icon: Icons.calendar }, + status: () => footer, + async *items({ preferences, signal, filter }) { + const urls = parseUrls(preferences.urls); + if (!urls.length) { + footer = "No calendar yet: add its private iCal address in the extension's settings"; + yield setupItems(); + return; + } + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const hour12 = preferences.hour12 === true; + const days = Number(filter ?? 7) || 7; + let calendars = await loadAll(urls, signal, takeForce()); + let loadedAt = Date.now(); + while (!signal.aborted) { + const now = Date.now(); + if (now - loadedAt > CACHE_MINUTES * 60_000) { + calendars = await loadAll(urls, signal); + loadedAt = now; + } + const midnight = new Date(now).setHours(0, 0, 0, 0); + const to = new Date(midnight + days * DAY + DAY / 2).setHours(0, 0, 0, 0); + const shown: Shown[] = calendars.flatMap((c) => + c.calendar ? expand(c.calendar.events, midnight, to, zone).map((o) => ({ ...o, calendar: c })) : [] + ).filter((o) => o.end > now || (o.start === o.end && o.start >= now)) + .sort((a, b) => a.start - b.start || Number(b.allDay) - Number(a.allDay)); + const several = calendars.length > 1; + + // In-progress events first in Today, then the rest by start. + const items: Item[] = []; + const live = shown.filter((o) => !o.allDay && isNow(o, now)); + const rest = shown.filter((o) => !live.includes(o)); + for (const o of [...live, ...rest]) { + items.push(eventItem(o, { zone, now, section: sectionOf(Math.max(o.start, now), now, zone), several, hour12 })); + } + items.push(...calendars.filter((c) => c.error).map(problemItem)); + + footer = statusFor(shown, now, zone, calendars.length); + updateCommandMetadata({ subtitle: subtitleFor(shown, now, zone) }).catch(() => {}); + yield items; + await nextMinute(signal); + } + }, +}); diff --git a/extensions/calendar/ics.ts b/extensions/calendar/ics.ts new file mode 100644 index 0000000..1c1542b --- /dev/null +++ b/extensions/calendar/ics.ts @@ -0,0 +1,619 @@ +// An iCalendar (RFC 5545) reader: what calendar apps publish as "secret +// address in iCal format". Unfolding, escaping, time zones (IANA, Windows and +// Outlook names), all-day dates, and recurrence rules with exceptions. + +// --------------------------------------------------------------------------- +// Lines and properties + +export interface Prop { + name: string; + params: Record; + value: string; +} + +/** Joins folded lines (a line starting with a space or tab continues the previous one). */ +export function unfold(text: string): string[] { + const out: string[] = []; + for (const raw of text.replace(/^/, "").split(/\r\n|\n|\r/)) { + if ((raw.startsWith(" ") || raw.startsWith("\t")) && out.length) out[out.length - 1] += raw.slice(1); + else if (raw.length) out.push(raw); + } + return out; +} + +/** `NAME;PARAM=a;PARAM2="b:c":value`: parameters may be quoted and hold colons. */ +export function parseProp(line: string): Prop | undefined { + let i = 0; + const nameEnd = line.search(/[;:]/); + if (nameEnd <= 0) return undefined; + const name = line.slice(0, nameEnd).toUpperCase(); + i = nameEnd; + const params: Record = {}; + while (line[i] === ";") { + i++; + const eq = line.indexOf("=", i); + if (eq === -1) return undefined; + const key = line.slice(i, eq).toUpperCase(); + i = eq + 1; + const values: string[] = []; + for (;;) { + if (line[i] === '"') { + const close = line.indexOf('"', i + 1); + if (close === -1) return undefined; + values.push(line.slice(i + 1, close)); + i = close + 1; + } else { + let j = i; + while (j < line.length && line[j] !== ";" && line[j] !== ":" && line[j] !== ",") j++; + values.push(line.slice(i, j)); + i = j; + } + if (line[i] === ",") i++; + else break; + } + params[key] = values.join(","); + } + if (line[i] !== ":") return undefined; + return { name, params, value: line.slice(i + 1) }; +} + +/** TEXT values: `\n`, `\,`, `\;` and `\\`. */ +export function unescapeText(value: string): string { + return value.replace(/\\([nN,;\\])/g, (_, c: string) => (c === "n" || c === "N" ? "\n" : c)); +} + +// --------------------------------------------------------------------------- +// Time zones + +const formatters = new Map(); + +function formatter(zone: string): Intl.DateTimeFormat { + let f = formatters.get(zone); + if (!f) { + f = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + formatters.set(zone, f); + } + return f; +} + +export function isZone(zone: string): boolean { + try { + formatter(zone); + return true; + } catch { + return false; + } +} + +/** Minutes east of UTC in `zone` at `at`. */ +export function zoneOffset(zone: string, at: number): number { + if (zone === "UTC") return 0; + const p: Record = {}; + for (const part of formatter(zone).formatToParts(new Date(at))) { + if (part.type !== "literal") p[part.type] = Number(part.value); + } + const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour === 24 ? 0 : p.hour, p.minute, p.second); + return Math.round((asUtc - Math.floor(at / 1000) * 1000) / 60000); +} + +/** The instant showing civil time `civil` (a Date.UTC of the wall clock) in `zone`; DST gaps move forward. */ +export function civilToInstant(civil: number, zone: string): number { + if (zone === "UTC") return civil; + const before = zoneOffset(zone, civil - 86_400_000), after = zoneOffset(zone, civil + 86_400_000); + const valid = [before, after].map((o) => civil - o * 60000).filter((t) => civil - zoneOffset(zone, t) * 60000 === t); + return valid.length ? Math.min(...valid) : civil - before * 60000; +} + +/** The civil time (as Date.UTC ms) of instant `at` in `zone`. */ +export function instantToCivil(at: number, zone: string): number { + return at + zoneOffset(zone, at) * 60000; +} + +/** Windows names that Outlook and Exchange put in TZID. */ +const WINDOWS_ZONES: Record = { + "Dateline Standard Time": "Etc/GMT+12", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Alaskan Standard Time": "America/Anchorage", + "Pacific Standard Time": "America/Los_Angeles", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "US Mountain Standard Time": "America/Phoenix", + "Mountain Standard Time": "America/Denver", + "Central Standard Time": "America/Chicago", + "Central America Standard Time": "America/Guatemala", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Canada Central Standard Time": "America/Regina", + "SA Pacific Standard Time": "America/Bogota", + "Eastern Standard Time": "America/New_York", + "US Eastern Standard Time": "America/Indianapolis", + "Atlantic Standard Time": "America/Halifax", + "Newfoundland Standard Time": "America/St_Johns", + "E. South America Standard Time": "America/Sao_Paulo", + "Argentina Standard Time": "America/Argentina/Buenos_Aires", + "Pacific SA Standard Time": "America/Santiago", + "Venezuela Standard Time": "America/Caracas", + "UTC": "UTC", + "Coordinated Universal Time": "UTC", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Central European Standard Time": "Europe/Warsaw", + "W. Central Africa Standard Time": "Africa/Lagos", + "Morocco Standard Time": "Africa/Casablanca", + "GTB Standard Time": "Europe/Bucharest", + "E. Europe Standard Time": "Europe/Chisinau", + "FLE Standard Time": "Europe/Kiev", + "Israel Standard Time": "Asia/Jerusalem", + "Egypt Standard Time": "Africa/Cairo", + "South Africa Standard Time": "Africa/Johannesburg", + "Turkey Standard Time": "Europe/Istanbul", + "Russian Standard Time": "Europe/Moscow", + "Arab Standard Time": "Asia/Riyadh", + "E. Africa Standard Time": "Africa/Nairobi", + "Iran Standard Time": "Asia/Tehran", + "Arabian Standard Time": "Asia/Dubai", + "Pakistan Standard Time": "Asia/Karachi", + "India Standard Time": "Asia/Kolkata", + "Nepal Standard Time": "Asia/Kathmandu", + "Bangladesh Standard Time": "Asia/Dhaka", + "SE Asia Standard Time": "Asia/Bangkok", + "China Standard Time": "Asia/Shanghai", + "Singapore Standard Time": "Asia/Singapore", + "Taipei Standard Time": "Asia/Taipei", + "W. Australia Standard Time": "Australia/Perth", + "Tokyo Standard Time": "Asia/Tokyo", + "Korea Standard Time": "Asia/Seoul", + "Cen. Australia Standard Time": "Australia/Adelaide", + "AUS Central Standard Time": "Australia/Darwin", + "E. Australia Standard Time": "Australia/Brisbane", + "AUS Eastern Standard Time": "Australia/Sydney", + "Tasmania Standard Time": "Australia/Hobart", + "New Zealand Standard Time": "Pacific/Auckland", +}; + +/** + * An IANA zone for a TZID: IANA names as is (also behind prefixes like + * `/mozilla.org/20050126_1/Europe/Paris`), Windows names, and Outlook's + * "(UTC+01:00) Brussels, Copenhagen…" labels by their offset. + */ +export function resolveZone(tzid: string | undefined): string | undefined { + if (!tzid) return undefined; + const t = tzid.trim().replace(/^"|"$/g, ""); + if (/^(utc|gmt|z|etc\/utc|etc\/gmt)$/i.test(t)) return "UTC"; + if (WINDOWS_ZONES[t]) return WINDOWS_ZONES[t]; + if (t.includes("/") && isZone(t)) return t; + const iana = /([A-Z][a-zA-Z_]+\/[A-Z][a-zA-Z_+\-]+(?:\/[A-Z][a-zA-Z_]+)?)$/.exec(t); + if (iana && isZone(iana[1])) return iana[1]; + const label = /^\(?(?:UTC|GMT)\s*([+-])(\d{1,2}):?(\d{2})?\)?/i.exec(t); + if (label) { + const minutes = (Number(label[2]) * 60 + Number(label[3] ?? 0)) * (label[1] === "-" ? -1 : 1); + if (minutes === 0) return "UTC"; + if (minutes % 60 === 0) return `Etc/GMT${minutes > 0 ? "-" : "+"}${Math.abs(minutes / 60)}`; + } + return isZone(t) ? t : undefined; +} + +// --------------------------------------------------------------------------- +// Dates + +export interface DateValue { + /** The wall clock as a Date.UTC timestamp. */ + civil: number; + /** IANA zone or "UTC"; undefined for floating times and dates. */ + zone?: string; + allDay: boolean; +} + +/** `20261028`, `20261028T093000`, `20261028T093000Z`, with `TZID` / `VALUE=DATE` parameters. */ +export function parseDate( + value: string, + params: Record = {}, + fallbackZone?: string, +): DateValue | undefined { + const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/.exec(value.trim()); + if (!m) return undefined; + const [, y, mo, d, h, mi, s, z] = m; + const civil = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0), Number(s ?? 0)); + if (h === undefined || params.VALUE === "DATE") return { civil, allDay: true }; + if (z) return { civil, zone: "UTC", allDay: false }; + return { civil, zone: resolveZone(params.TZID) ?? fallbackZone, allDay: false }; +} + +/** The instant of a date: floating times and all-day dates are in `localZone`. */ +export function instantOf(d: DateValue, localZone: string): number { + return civilToInstant(d.civil, d.zone ?? localZone); +} + +/** `PT1H30M`, `P1D`, `-PT15M`, `P1W` in ms. */ +export function parseDuration(value: string): number | undefined { + const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value.trim()); + if (!m || value.trim() === "P") return undefined; + const [, sign, w, d, h, mi, s] = m; + const ms = ((Number(w ?? 0) * 7 + Number(d ?? 0)) * 86400 + Number(h ?? 0) * 3600 + Number(mi ?? 0) * 60 + + Number(s ?? 0)) * 1000; + return sign === "-" ? -ms : ms; +} + +// --------------------------------------------------------------------------- +// Recurrence rules + +export type Freq = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + +export interface Rule { + freq: Freq; + interval: number; + count?: number; + until?: DateValue; + /** Weekdays (0 = Sunday), with an optional position ("2nd Tuesday" = { day: 2, n: 2 }, last Friday n = -1). */ + byDay?: { day: number; n?: number }[]; + byMonthDay?: number[]; + byMonth?: number[]; + bySetPos?: number[]; + /** Week start, 0 = Sunday (default Monday). */ + wkst: number; +} + +const WEEKDAYS = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + +export function parseRule(value: string, zone?: string): Rule | undefined { + const parts: Record = {}; + for (const kv of value.split(";")) { + const [k, v] = kv.split("="); + if (k && v !== undefined) parts[k.toUpperCase()] = v; + } + const freq = parts.FREQ?.toUpperCase(); + if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") return undefined; + const nums = (s?: string) => s?.split(",").map(Number).filter((n) => Number.isInteger(n) && n !== 0); + const byDay = parts.BYDAY?.split(",").map((d) => { + const m = /^([+-]?\d{1,2})?(SU|MO|TU|WE|TH|FR|SA)$/i.exec(d.trim()); + return m ? { day: WEEKDAYS.indexOf(m[2].toUpperCase()), ...(m[1] ? { n: Number(m[1]) } : {}) } : undefined; + }).filter((d) => d !== undefined) as Rule["byDay"]; + return { + freq, + interval: Math.max(1, Number(parts.INTERVAL ?? 1) || 1), + ...(parts.COUNT ? { count: Math.max(0, Number(parts.COUNT)) } : {}), + ...(parts.UNTIL ? { until: parseDate(parts.UNTIL, {}, zone) } : {}), + ...(byDay?.length ? { byDay } : {}), + ...(nums(parts.BYMONTHDAY)?.length ? { byMonthDay: nums(parts.BYMONTHDAY) } : {}), + ...(nums(parts.BYMONTH)?.length ? { byMonth: nums(parts.BYMONTH) } : {}), + ...(nums(parts.BYSETPOS)?.length ? { bySetPos: nums(parts.BYSETPOS) } : {}), + wkst: parts.WKST ? Math.max(0, WEEKDAYS.indexOf(parts.WKST.toUpperCase())) : 1, + }; +} + +const DAY = 86_400_000; + +/** Days in a month (1-12). */ +const daysIn = (y: number, m: number) => new Date(Date.UTC(y, m, 0)).getUTCDate(); + +/** Days of a month matching BYDAY / BYMONTHDAY (1-based day numbers, sorted). */ +function monthDays(y: number, m: number, rule: Rule, startDay: number): number[] { + const n = daysIn(y, m); + let days: number[] = []; + if (rule.byMonthDay) { + days = rule.byMonthDay.map((d) => (d > 0 ? d : n + d + 1)).filter((d) => d >= 1 && d <= n); + } + if (rule.byDay) { + const first = new Date(Date.UTC(y, m - 1, 1)).getUTCDay(); + const fromDay: number[] = []; + for (const { day, n: pos } of rule.byDay) { + const all: number[] = []; + for (let d = 1 + ((day - first + 7) % 7); d <= n; d += 7) all.push(d); + if (pos === undefined) fromDay.push(...all); + else { + const pick = pos > 0 ? all[pos - 1] : all[all.length + pos]; + if (pick) fromDay.push(pick); + } + } + days = rule.byMonthDay ? days.filter((d) => fromDay.includes(d)) : fromDay; + } + if (!rule.byMonthDay && !rule.byDay) days = startDay <= n ? [startDay] : []; + days = [...new Set(days)].sort((a, b) => a - b); + if (rule.bySetPos) { + days = rule.bySetPos.map((p) => (p > 0 ? days[p - 1] : days[days.length + p])).filter((d) => d !== undefined) + .sort((a, b) => a - b); + } + return days; +} + +/** + * Civil start times of a rule's occurrences (DTSTART included) up to + * `endCivil`; rules without COUNT skip the periods before `skipBefore`. + */ +export function* occurrences(start: number, rule: Rule, endCivil: number, skipBefore = -Infinity): Generator { + const s = new Date(start); + const [y0, m0, d0] = [s.getUTCFullYear(), s.getUTCMonth() + 1, s.getUTCDate()]; + const time = start - Date.UTC(y0, m0 - 1, d0); + let emitted = 0; + const max = rule.count ?? Infinity; + const untilCivil = rule.until ? rule.until.civil + (rule.until.allDay ? DAY - 1 : 0) : Infinity; + const done = (c: number) => c > endCivil || c > untilCivil || emitted >= max; + + // Without COUNT, whole periods before the window can be skipped. + const canSkip = rule.count === undefined && skipBefore > start; + const periodsToSkip = (periodDays: number) => + canSkip ? Math.max(0, Math.floor((skipBefore - start) / (periodDays * DAY * rule.interval)) - 1) : 0; + + yield start; + emitted++; + + if (rule.freq === "DAILY" || (rule.freq === "WEEKLY" && !rule.byDay)) { + const step = (rule.freq === "DAILY" ? 1 : 7) * rule.interval; + let k = periodsToSkip(rule.freq === "DAILY" ? 1 : 7); + for (let guard = 0; guard < 100_000; guard++) { + k++; + const c = start + k * step * DAY; + if (done(c)) return; + const d = new Date(c); + if (rule.byDay && !rule.byDay.some((b) => b.day === d.getUTCDay())) continue; + if (rule.byMonth && !rule.byMonth.includes(d.getUTCMonth() + 1)) continue; + emitted++; + yield c; + } + return; + } + + if (rule.freq === "WEEKLY") { + const startDow = s.getUTCDay(); + const weekStart = Date.UTC(y0, m0 - 1, d0) - ((startDow - rule.wkst + 7) % 7) * DAY; + const offsets = [...new Set(rule.byDay!.map((b) => (b.day - rule.wkst + 7) % 7))].sort((a, b) => a - b); + let w = periodsToSkip(7); + for (let guard = 0; guard < 100_000; guard++, w++) { + const base = weekStart + w * 7 * rule.interval * DAY; + for (const off of offsets) { + const c = base + off * DAY + time; + if (c <= start) continue; + if (done(c)) return; + if (rule.byMonth && !rule.byMonth.includes(new Date(c).getUTCMonth() + 1)) continue; + emitted++; + yield c; + } + if (base > endCivil) return; + } + return; + } + + // MONTHLY and YEARLY: each period lists its candidate days. + const yearly = rule.freq === "YEARLY"; + const months = yearly ? (rule.byMonth ?? [m0]) : undefined; + let k = 0; + if (canSkip) { + const sb = new Date(skipBefore); + const elapsed = yearly ? sb.getUTCFullYear() - y0 : (sb.getUTCFullYear() - y0) * 12 + sb.getUTCMonth() + 1 - m0; + k = Math.max(0, Math.floor(elapsed / rule.interval) - 1); + } + for (let guard = 0; guard < 20_000; guard++, k++) { + const periods: [number, number][] = yearly + ? months!.map((m) => [y0 + k * rule.interval, m]) + : [[y0 + Math.floor((m0 - 1 + k * rule.interval) / 12), ((m0 - 1 + k * rule.interval) % 12) + 1]]; + if (Date.UTC(periods[0][0], periods[0][1] - 1, 1) > endCivil) return; + for (const [y, m] of periods) { + if (!yearly && rule.byMonth && !rule.byMonth.includes(m)) continue; + for (const day of monthDays(y, m, rule, d0)) { + const c = Date.UTC(y, m - 1, day) + time; + if (c <= start) continue; + if (done(c)) return; + emitted++; + yield c; + } + } + } +} + +// --------------------------------------------------------------------------- +// Events + +export interface Person { + name?: string; + email?: string; + status?: string; +} + +export interface CalEvent { + uid: string; + summary: string; + description: string; + location: string; + url?: string; + status?: string; + start: DateValue; + end?: DateValue; + duration?: number; + rule?: Rule; + ruleText?: string; + rdates: DateValue[]; + exdates: DateValue[]; + recurrenceId?: DateValue; + organizer?: Person; + attendees: Person[]; + /** X-GOOGLE-CONFERENCE and Microsoft's online meeting properties. */ + conference?: string; + transparent: boolean; +} + +export interface Calendar { + name?: string; + zone?: string; + events: CalEvent[]; +} + +function person(p: Prop): Person { + const email = p.value.replace(/^mailto:/i, "").trim() || undefined; + return { name: p.params.CN ? unescapeText(p.params.CN) : undefined, email, status: p.params.PARTSTAT }; +} + +/** All VEVENTs of a calendar file (nested VALARMs ignored). */ +export function parseCalendar(text: string): Calendar { + const lines = unfold(text); + const cal: Calendar = { events: [] }; + let ev: Partial & { props?: Prop[] } | undefined; + let depth = 0; // inside VALARM or other sub-components of an event + for (const line of lines) { + const p = parseProp(line); + if (!p) continue; + if (p.name === "BEGIN") { + const what = p.value.toUpperCase(); + if (what === "VEVENT" && !ev) ev = { props: [] }; + else if (ev) depth++; + continue; + } + if (p.name === "END") { + const what = p.value.toUpperCase(); + if (ev && depth > 0) depth--; + else if (what === "VEVENT" && ev) { + const done = buildEvent(ev.props!, cal.zone); + if (done) cal.events.push(done); + ev = undefined; + } + continue; + } + if (ev) { + if (depth === 0) ev.props!.push(p); + } else if (p.name === "X-WR-CALNAME") cal.name = unescapeText(p.value); + else if (p.name === "X-WR-TIMEZONE") cal.zone = resolveZone(p.value); + } + return cal; +} + +function buildEvent(props: Prop[], calZone?: string): CalEvent | undefined { + const get = (name: string) => props.find((p) => p.name === name); + const dtstart = get("DTSTART"); + const start = dtstart && parseDate(dtstart.value, dtstart.params, calZone); + if (!start) return undefined; + const dtend = get("DTEND"); + const dur = get("DURATION"); + const rrule = get("RRULE"); + const dates = (name: string) => + props.filter((p) => p.name === name).flatMap((p) => + p.value.split(",").map((v) => parseDate(v, p.params, start.zone)).filter((d): d is DateValue => !!d) + ); + const rid = get("RECURRENCE-ID"); + const conference = ["X-GOOGLE-CONFERENCE", "X-MICROSOFT-SKYPETEAMSMEETINGURL", "X-MICROSOFT-ONLINEMEETINGCONFLINK"] + .map((n) => get(n)?.value).find(Boolean); + return { + uid: get("UID")?.value ?? crypto.randomUUID(), + summary: unescapeText(get("SUMMARY")?.value ?? "").trim() || "(No title)", + description: unescapeText(get("DESCRIPTION")?.value ?? ""), + location: unescapeText(get("LOCATION")?.value ?? "").trim(), + url: get("URL")?.value, + status: get("STATUS")?.value.toUpperCase(), + start, + end: dtend ? parseDate(dtend.value, dtend.params, start.zone) : undefined, + duration: dur ? parseDuration(dur.value) : undefined, + rule: rrule ? parseRule(rrule.value, start.zone) : undefined, + ruleText: rrule?.value, + rdates: dates("RDATE"), + exdates: dates("EXDATE"), + recurrenceId: rid ? parseDate(rid.value, rid.params, start.zone) : undefined, + organizer: get("ORGANIZER") ? person(get("ORGANIZER")!) : undefined, + attendees: props.filter((p) => p.name === "ATTENDEE").map(person), + conference, + transparent: get("TRANSP")?.value.toUpperCase() === "TRANSPARENT", + }; +} + +// --------------------------------------------------------------------------- +// Expansion + +export interface Occurrence { + event: CalEvent; + /** Instants, ms. All-day events start and end at local midnight. */ + start: number; + end: number; + allDay: boolean; + recurring: boolean; +} + +/** Length of an event: DTEND − DTSTART, else DURATION, else one day (dates) or nothing (times). */ +function lengthOf(e: CalEvent, localZone: string): { ms: number; days?: number } { + if (e.start.allDay) { + const days = e.end ? Math.max(1, Math.round((e.end.civil - e.start.civil) / DAY)) : Math.max( + 1, + Math.round((e.duration ?? DAY) / DAY), + ); + return { ms: days * DAY, days }; + } + if (e.end) return { ms: Math.max(0, instantOf(e.end, localZone) - instantOf(e.start, localZone)) }; + return { ms: Math.max(0, e.duration ?? 0) }; +} + +function occurrenceAt(e: CalEvent, civil: number, localZone: string, recurring: boolean): Occurrence { + const len = lengthOf(e, localZone); + if (e.start.allDay) { + const start = civilToInstant(civil, localZone); + return { event: e, start, end: civilToInstant(civil + len.days! * DAY, localZone), allDay: true, recurring }; + } + const start = civilToInstant(civil, e.start.zone ?? localZone); + return { event: e, start, end: start + len.ms, allDay: false, recurring }; +} + +/** The key matching a RECURRENCE-ID or EXDATE to a generated occurrence. */ +const keyOf = (d: DateValue, eventStart: DateValue, localZone: string) => + d.allDay || eventStart.allDay + ? `d:${ + new Date(d.allDay ? d.civil : instantToCivil(instantOf(d, localZone), eventStart.zone ?? localZone)) + .toISOString().slice(0, 10) + }` + : `t:${instantOf(d, localZone)}`; + +/** + * Occurrences overlapping [from, to): recurring events expanded with their + * rule, RDATEs and EXDATEs, moved instances (RECURRENCE-ID) replacing the + * ones they override, cancelled events left out. Sorted by start. + */ +export function expand(events: CalEvent[], from: number, to: number, localZone: string): Occurrence[] { + const overrides = new Map>(); + for (const e of events) { + if (!e.recurrenceId) continue; + const master = events.find((m) => m.uid === e.uid && !m.recurrenceId); + const set = overrides.get(e.uid) ?? new Set(); + set.add(keyOf(e.recurrenceId, master?.start ?? e.start, localZone)); + overrides.set(e.uid, set); + } + const out: Occurrence[] = []; + const keep = (o: Occurrence) => + o.end > from && o.start < to || (o.start === o.end && o.start >= from && o.start < to); + for (const e of events) { + if (e.status === "CANCELLED") continue; + if (!e.rule && !e.rdates.length) { + const o = occurrenceAt(e, e.start.civil, localZone, !!e.recurrenceId); + if (keep(o)) out.push(o); + continue; + } + const zone = e.start.allDay ? localZone : e.start.zone ?? localZone; + const len = lengthOf(e, localZone).ms; + // Civil bounds of the window in the event's zone, with a day of margin for offsets. + const fromCivil = instantToCivil(from - len, zone) - DAY; + const toCivil = instantToCivil(to, zone) + DAY; + const skip = new Set([ + ...e.exdates.map((d) => keyOf(d, e.start, localZone)), + ...(overrides.get(e.uid) ?? []), + ]); + // UNTIL is usually in UTC: compare it in the event's own wall clock. + const rule = e.rule?.until && e.rule.until.zone && !e.rule.until.allDay + ? { ...e.rule, until: { civil: instantToCivil(instantOf(e.rule.until, localZone), zone), allDay: false } } + : e.rule; + const civils = rule ? [...occurrences(e.start.civil, rule, toCivil, fromCivil)] : [e.start.civil]; + for (const r of e.rdates) { + civils.push(r.allDay || e.start.allDay ? r.civil : instantToCivil(instantOf(r, localZone), zone)); + } + for (const civil of new Set(civils)) { + if (civil < fromCivil - len || civil > toCivil) continue; + const o = occurrenceAt(e, civil, localZone, true); + const key = e.start.allDay ? `d:${new Date(civil).toISOString().slice(0, 10)}` : `t:${o.start}`; + if (skip.has(key)) continue; + if (keep(o)) out.push(o); + } + } + return out.sort((a, b) => a.start - b.start || Number(b.allDay) - Number(a.allDay) || a.end - b.end); +} diff --git a/extensions/calendar/lib.ts b/extensions/calendar/lib.ts new file mode 100644 index 0000000..1eaab15 --- /dev/null +++ b/extensions/calendar/lib.ts @@ -0,0 +1,281 @@ +// Calendar logic besides parsing: calendar URLs, video calls, day sections, +// time ranges and the "Standup in 12 min" line. + +export * from "./ics.ts"; +import { type CalEvent, instantToCivil, type Occurrence, type Rule } from "./ics.ts"; + +/** Calendar URLs from the preference: separated by spaces, commas or new lines; webcal:// becomes https://. */ +export function parseUrls(pref: unknown): string[] { + const out: string[] = []; + for (const raw of String(pref ?? "").split(/[\s,]+/)) { + const t = raw.trim().replace(/^<|>$/g, ""); + if (!t) continue; + const url = t.replace(/^webcals?:\/\//i, "https://"); + try { + const u = new URL(url); + if ((u.protocol === "https:" || u.protocol === "http:") && !out.includes(u.href)) out.push(u.href); + } catch { /* not a URL: ignored */ } + } + return out; +} + +/** The hosts this extension may reach (keep in sync with backslash.toml). */ +export const ALLOWED_HOSTS = [ + "calendar.google.com", + "outlook.office365.com", + "outlook.office.com", + "outlook.live.com", + "caldav.fastmail.com", + "user.fm", + "*.icloud.com", + "calendar.proton.me", + "calendar.zoho.com", + "calendar.zoho.eu", + "export.calendar.yahoo.com", +]; + +export function hostAllowed(url: string, hosts = ALLOWED_HOSTS): boolean { + let host: string; + try { + host = new URL(url).hostname; + } catch { + return false; + } + return hosts.some((h) => (h.startsWith("*.") ? host === h.slice(2) || host.endsWith(h.slice(1)) : host === h)); +} + +/** A short name for a calendar without X-WR-CALNAME. */ +export function calendarLabel(url: string): string { + try { + const u = new URL(url); + if (u.hostname === "calendar.google.com") { + const id = decodeURIComponent(u.pathname.split("/")[3] ?? ""); + return id.includes("@") && !id.includes("group.calendar") ? id : "Google Calendar"; + } + if (u.hostname.startsWith("outlook.")) return "Outlook"; + if (u.hostname.endsWith("icloud.com")) return "iCloud"; + if (u.hostname === "caldav.fastmail.com" || u.hostname === "user.fm") return "Fastmail"; + if (u.hostname === "calendar.proton.me") return "Proton Calendar"; + return u.hostname; + } catch { + return url; + } +} + +/** A stable key for caching a URL in storage (secret URLs are not used as keys). */ +export async function urlKey(url: string): Promise { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)); + return [...new Uint8Array(hash)].slice(0, 12).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// --------------------------------------------------------------------------- +// Video calls + +export interface Meeting { + url: string; + service: string; +} + +const MEETINGS: [RegExp, string][] = [ + [/https:\/\/meet\.google\.com\/[a-z0-9-]+(?:\?[^\s<>"')\]]*)?/i, "Google Meet"], + [/https:\/\/(?:[\w-]+\.)*zoom\.us\/(?:j|my|w|s)\/[^\s<>"')\]]+/i, "Zoom"], + [/https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s<>"')\]]+/i, "Microsoft Teams"], + [/https:\/\/teams\.live\.com\/meet\/[^\s<>"')\]]+/i, "Microsoft Teams"], + [/https:\/\/meet\.jit\.si\/[^\s<>"')\]]+/i, "Jitsi Meet"], + [/https:\/\/(?:[\w-]+\.)*jitsi\.[a-z.]+\/[^\s<>"')\]]+/i, "Jitsi Meet"], + [/https:\/\/[\w-]+\.webex\.com\/[^\s<>"')\]]+/i, "Webex"], + [/https:\/\/whereby\.com\/[^\s<>"')\]]+/i, "Whereby"], + [/https:\/\/(?:app\.)?slack\.com\/huddle\/[^\s<>"')\]]+/i, "Slack Huddle"], + [/https:\/\/discord\.(?:gg|com)\/[^\s<>"')\]]+/i, "Discord"], +]; + +/** The first video-call link of an event: conference property, then location, URL and description. */ +export function findMeeting(e: Pick): Meeting | undefined { + for (const text of [e.conference, e.location, e.url, e.description]) { + if (!text) continue; + for (const [re, service] of MEETINGS) { + const m = re.exec(text); + if (m) return { url: m[0].replace(/[.,;]+$/, ""), service }; + } + } + return undefined; +} + +/** The location without a video link in it ("Room 4 / https://zoom.us/j/1" → "Room 4"). */ +export function placeOf(location: string): string { + return location.replace(/https?:\/\/\S+/g, "").replace(/^[\s,;/|-]+|[\s,;/|-]+$/g, "").trim(); +} + +/** Descriptions sometimes come as HTML (Google, Outlook): plain text with links kept. */ +export function cleanDescription(text: string): string { + let t = text.replace(/\r/g, ""); + if (/<\/?(?:br|p|div|a|b|i|span|ul|li|html)\b/i.test(t)) { + t = t + .replace(//gi, "\n") + .replace(/<\/(?:p|div|li)>/gi, "\n") + .replace(/]*>/gi, "- ") + .replace( + /]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, + (_, href: string, label: string) => + label.replace(/<[^>]+>/g, "").trim() && label !== href ? `[${label.replace(/<[^>]+>/g, "")}](${href})` : href, + ) + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + .replace(/"/g, '"').replace(/'/g, "'"); + } + // Google's "-::~:~::~:…" join-info block and Teams' underscores are noise. + t = t.replace(/-::~[:~]+::-[\s\S]*?-::~[:~]+::-/g, "").replace(/_{10,}/g, ""); + return t.replace(/\n{3,}/g, "\n\n").trim(); +} + +// --------------------------------------------------------------------------- +// Days and times + +const DAY = 86_400_000; + +/** `YYYY-MM-DD` of an instant in a zone. */ +export function dayKey(at: number, zone: string): string { + return new Date(instantToCivil(at, zone)).toISOString().slice(0, 10); +} + +/** "Today", "Tomorrow", a weekday within a week, else "Mon 9 Nov". */ +export function sectionOf(at: number, now: number, zone: string): string { + const day = dayKey(at, zone); + const today = dayKey(now, zone); + if (day <= today) return "Today"; + const diff = Math.round((Date.parse(day) - Date.parse(today)) / DAY); + const d = new Date(`${day}T12:00:00Z`); + if (diff === 1) return "Tomorrow"; + if (diff < 7) return d.toLocaleDateString("en", { weekday: "long", timeZone: "UTC" }); + return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" }); +} + +export function formatClock(at: number, zone: string, hour12 = false): string { + return new Date(at).toLocaleTimeString(hour12 ? "en-US" : "en-GB", { + timeZone: zone, + hour: hour12 ? "numeric" : "2-digit", + minute: "2-digit", + hour12, + }); +} + +/** "09:30 – 10:00", "All day", "All day · 3 days", "Until 14:00" for events started earlier. */ +export function timeRange(o: Occurrence, zone: string, now: number, hour12 = false): string { + if (o.allDay) { + const days = Math.round((o.end - o.start) / DAY); + return days > 1 ? `All day · ${days} days` : "All day"; + } + if (o.end === o.start) return formatClock(o.start, zone, hour12); + const sameDay = dayKey(o.start, zone) === dayKey(o.end - 1, zone); + const startedBefore = dayKey(o.start, zone) < dayKey(now, zone); + if (startedBefore) { + const endsToday = dayKey(o.end - 1, zone) === dayKey(now, zone); + return `Until ${endsToday ? "" : `${shortDay(o.end - 1, zone)} `}${formatClock(o.end, zone, hour12)}`; + } + const end = sameDay + ? formatClock(o.end, zone, hour12) + : `${shortDay(o.end - 1, zone)} ${formatClock(o.end, zone, hour12)}`; + return `${formatClock(o.start, zone, hour12)} – ${end}`; +} + +const shortDay = (at: number, zone: string) => + new Date(at).toLocaleDateString("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short" }); + +/** "45 min", "1 h", "1 h 30", "2 days". */ +export function formatDuration(ms: number): string { + const min = Math.round(ms / 60000); + if (min < 60) return `${min} min`; + if (min < 24 * 60) { + const h = Math.floor(min / 60), m = min % 60; + return m ? `${h} h ${String(m).padStart(2, "0")}` : `${h} h`; + } + const days = Math.round(min / 1440); + return `${days} day${days > 1 ? "s" : ""}`; +} + +/** "in 12 min", "in 2 h", "in 1 h 15", "now". */ +export function relative(at: number, now: number): string { + const min = Math.round((at - now) / 60000); + if (min <= 0) return "now"; + if (min < 60) return `in ${min} min`; + const h = Math.floor(min / 60), m = min % 60; + return m ? `in ${h} h ${String(m).padStart(2, "0")}` : `in ${h} h`; +} + +export const isNow = (o: Occurrence, now: number) => o.start <= now && now < o.end; + +/** + * The command's subtitle in the root search: what is happening now, else + * the next timed event today ("Standup in 12 min"), else null. + */ +export function subtitleFor(list: Occurrence[], now: number, zone: string): string | null { + const current = list.filter((o) => !o.allDay && isNow(o, now)); + if (current.length) { + return `Now: ${current[0].event.summary}${current.length > 1 ? ` (+${current.length - 1})` : ""}`; + } + const next = list.find((o) => !o.allDay && o.start > now && dayKey(o.start, zone) === dayKey(now, zone)); + return next ? `${next.event.summary} ${relative(next.start, now)}` : null; +} + +/** The footer: "3 events today · next: Standup in 12 min". */ +export function statusFor(list: Occurrence[], now: number, zone: string, calendars: number): string { + const today = dayKey(now, zone); + const todays = list.filter((o) => + (o.allDay ? dayKey(o.start, zone) <= today : dayKey(o.start, zone) === today) && + o.end > now + ); + const parts = [`${todays.length || "No"} event${todays.length === 1 ? "" : "s"} left today`]; + const next = list.find((o) => !o.allDay && o.start > now); + if (next) parts.push(`next: ${next.event.summary} ${relative(next.start, now)}`); + if (calendars > 1) parts.push(`${calendars} calendars`); + return parts.join(" · "); +} + +/** "Weekly on Monday, Wednesday", "Every 2 weeks", "Monthly on the 2nd Tuesday", "Daily, 10 times". */ +export function describeRule(rule: Rule | undefined): string | undefined { + if (!rule) return undefined; + const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + const unit = { DAILY: "day", WEEKLY: "week", MONTHLY: "month", YEARLY: "year" }[rule.freq]; + let text = rule.interval > 1 + ? `Every ${rule.interval} ${unit}s` + : { DAILY: "Daily", WEEKLY: "Weekly", MONTHLY: "Monthly", YEARLY: "Yearly" }[rule.freq]; + const ord = (n: number) => (n === -1 ? "last" : `${n}${n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th"}`); + if (rule.byDay?.length) { + const weekdays = [1, 2, 3, 4, 5]; + const plain = rule.byDay.every((d) => d.n === undefined); + if (plain && rule.byDay.length === 5 && weekdays.every((w) => rule.byDay!.some((d) => d.day === w))) { + text += " on weekdays"; + } else { + text += ` on ${rule.byDay.map((d) => (d.n ? `the ${ord(d.n)} ${DAYS[d.day]}` : DAYS[d.day])).join(", ")}`; + } + } else if (rule.byMonthDay?.length) { + text += ` on day ${rule.byMonthDay.map((d) => (d < 0 ? (d === -1 ? "last" : `${d}`) : String(d))).join(", ")}`; + } + if (rule.count) text += `, ${rule.count} times`; + else if (rule.until) text += `, until ${new Date(rule.until.civil).toISOString().slice(0, 10)}`; + return text; +} + +/** Plain text of an event, for "Copy Event Details". */ +export function eventText(o: Occurrence, zone: string, now: number, meeting?: Meeting): string { + const e = o.event; + const day = new Date(o.start).toLocaleDateString("en-GB", { + timeZone: zone, + weekday: "long", + day: "numeric", + month: "long", + }); + return [ + e.summary, + `${day}, ${timeRange(o, zone, now)}`, + placeOf(e.location) && `Location: ${placeOf(e.location)}`, + meeting && `${meeting.service}: ${meeting.url}`, + ].filter(Boolean).join("\n"); +} + +/** Invited people, and how many did not decline. */ +export function attending(e: CalEvent): { going: number; total: number } { + return { going: e.attendees.filter((a) => a.status !== "DECLINED").length, total: e.attendees.length }; +} + +export const PALETTE = ["blue", "purple", "green", "orange", "pink", "teal", "red", "yellow"] as const; diff --git a/extensions/calendar/lib_test.ts b/extensions/calendar/lib_test.ts new file mode 100644 index 0000000..8f06e26 --- /dev/null +++ b/extensions/calendar/lib_test.ts @@ -0,0 +1,398 @@ +import { assert, assertEquals } from "@std/assert"; +import { + calendarLabel, + cleanDescription, + describeRule, + expand, + findMeeting, + formatDuration, + hostAllowed, + occurrences, + parseCalendar, + parseDate, + parseDuration, + parseProp, + parseRule, + parseUrls, + placeOf, + relative, + resolveZone, + sectionOf, + statusFor, + subtitleFor, + timeRange, + unescapeText, + unfold, +} from "./lib.ts"; +import { eventItem, type Shown } from "./calendars.ts"; + +const PARIS = "Europe/Paris"; +const iso = (ms: number) => new Date(ms).toISOString().slice(0, 16) + "Z"; +const civil = (s: string) => Date.parse(`${s}Z`); + +/** Wraps VEVENT bodies in a calendar. */ +const ics = (...events: string[]) => + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "X-WR-CALNAME:Work", + ...events.flatMap((e) => ["BEGIN:VEVENT", e.trim(), "END:VEVENT"]), + "END:VCALENDAR", + ] + .join("\r\n"); + +const at = (s: string) => Date.parse(s); + +Deno.test("lines: unfolding, properties with quoted parameters, escaped text", () => { + assertEquals(unfold("A:1\r\nB:long\r\n line\r\n\tend\nC:3"), ["A:1", "B:long lineend", "C:3"]); + assertEquals(parseProp('ATTENDEE;CN="Doe, Jane";PARTSTAT=ACCEPTED:mailto:jane@x.com'), { + name: "ATTENDEE", + params: { CN: "Doe, Jane", PARTSTAT: "ACCEPTED" }, + value: "mailto:jane@x.com", + }); + assertEquals(parseProp('DESCRIPTION;ALTREP="http://x.com/a:b":Text: with colon')?.value, "Text: with colon"); + assertEquals(parseProp("dtstart;tzid=Europe/Paris:20261028T090000")?.name, "DTSTART"); + assertEquals(parseProp("no colon"), undefined); + assertEquals(unescapeText("Line\\nTwo\\, three\\; four \\\\ five"), "Line\nTwo, three; four \\ five"); +}); + +Deno.test("dates and time zones", () => { + assertEquals(parseDate("20261028"), { civil: civil("2026-10-28T00:00:00"), allDay: true }); + assertEquals(parseDate("20261028T093000Z"), { civil: civil("2026-10-28T09:30:00"), zone: "UTC", allDay: false }); + assertEquals(parseDate("2026-10-28"), undefined); + assertEquals(parseDate("20261028T093000", { TZID: "Europe/Paris" })?.zone, PARIS); + assertEquals(parseDate("20261028T093000", { VALUE: "DATE" })?.allDay, true); + assertEquals(parseDate("20261028T093000")?.zone, undefined); // floating + assertEquals(resolveZone("Romance Standard Time"), PARIS); + assertEquals(resolveZone("Pacific Standard Time"), "America/Los_Angeles"); + assertEquals(resolveZone("/mozilla.org/20050126_1/Europe/Paris"), PARIS); + assertEquals(resolveZone('"America/New_York"'), "America/New_York"); + assertEquals(resolveZone("(UTC+01:00) Brussels, Copenhagen, Madrid, Paris"), "Etc/GMT-1"); + assertEquals(resolveZone("GMT"), "UTC"); + assertEquals(resolveZone("Nowhere Standard Time"), undefined); + assertEquals(parseDuration("PT1H30M"), 5_400_000); + assertEquals(parseDuration("P1D"), 86_400_000); + assertEquals(parseDuration("P1W"), 7 * 86_400_000); + assertEquals(parseDuration("-PT15M"), -900_000); + assertEquals(parseDuration("P"), undefined); +}); + +Deno.test("recurrence rules", () => { + const r = parseRule("FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU,-1FR;COUNT=6;WKST=SU")!; + assertEquals(r, { + freq: "MONTHLY", + interval: 2, + count: 6, + byDay: [{ day: 2, n: 2 }, { day: 5, n: -1 }], + wkst: 0, + }); + assertEquals(parseRule("FREQ=WEEKLY;UNTIL=20261231T225959Z")?.until?.zone, "UTC"); + assertEquals(parseRule("FREQ=HOURLY"), undefined); + const days = (rule: string, start: string, end: string) => + [...occurrences(civil(start), parseRule(rule)!, civil(end))].map((c) => new Date(c).toISOString().slice(0, 10)); + + assertEquals(days("FREQ=DAILY;INTERVAL=2;COUNT=4", "2026-10-01T09:00:00", "2027-01-01T00:00:00"), [ + "2026-10-01", + "2026-10-03", + "2026-10-05", + "2026-10-07", + ]); + // Mon/Wed/Fri until a date (inclusive), starting on a Wednesday. + assertEquals(days("FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20261012", "2026-10-07T10:00:00", "2027-01-01T00:00:00"), [ + "2026-10-07", + "2026-10-09", + "2026-10-12", + ]); + // Every other week on Tuesday and Thursday. + assertEquals(days("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH;COUNT=5", "2026-10-06T10:00:00", "2027-01-01T00:00:00"), [ + "2026-10-06", + "2026-10-08", + "2026-10-20", + "2026-10-22", + "2026-11-03", + ]); + // Second Tuesday and last Friday of the month. + assertEquals(days("FREQ=MONTHLY;BYDAY=2TU,-1FR", "2026-10-13T10:00:00", "2026-12-31T00:00:00"), [ + "2026-10-13", + "2026-10-30", + "2026-11-10", + "2026-11-27", + "2026-12-08", + "2026-12-25", + ]); + // The 31st only exists in some months. + assertEquals(days("FREQ=MONTHLY;COUNT=4", "2026-10-31T08:00:00", "2028-01-01T00:00:00"), [ + "2026-10-31", + "2026-12-31", + "2027-01-31", + "2027-03-31", + ]); + assertEquals(days("FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3", "2027-01-31T08:00:00", "2028-01-01T00:00:00"), [ + "2027-01-31", + "2027-02-28", + "2027-03-31", + ]); + // Last weekday of the month. + assertEquals(days("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=3", "2026-10-30T17:00:00", "2028-01-01"), [ + "2026-10-30", + "2026-11-30", + "2026-12-31", + ]); + assertEquals(days("FREQ=YEARLY;COUNT=3", "2024-02-29T00:00:00", "2040-01-01T00:00:00"), [ + "2024-02-29", + "2028-02-29", + "2032-02-29", + ]); + assertEquals(days("FREQ=DAILY;BYDAY=SA,SU;COUNT=3", "2026-10-30T09:00:00", "2027-01-01"), [ + "2026-10-30", + "2026-10-31", + "2026-11-01", + ]); +}); + +Deno.test("skipping old periods gives the same occurrences", () => { + for ( + const rule of [ + "FREQ=WEEKLY;BYDAY=MO,TH", + "FREQ=DAILY;INTERVAL=3", + "FREQ=MONTHLY;BYDAY=1WE", + "FREQ=YEARLY", + "FREQ=WEEKLY;INTERVAL=2", + ] + ) { + const r = parseRule(rule)!; + const start = civil("2015-03-02T09:00:00"); + const end = civil("2026-12-31T00:00:00"); + const from = civil("2026-10-01T00:00:00"); + const all = [...occurrences(start, r, end)].filter((c) => c >= from); + const skipped = [...occurrences(start, r, end, from)].filter((c) => c >= from); + assertEquals(skipped, all, rule); + } +}); + +Deno.test("expanding a calendar: zones, DST, exceptions, overrides, cancellations", () => { + const cal = parseCalendar(ics( + // Weekly standup at 09:30 Paris time, across the end of summer time (25 Oct 2026). + `UID:standup +SUMMARY:Standup +DTSTART;TZID=Europe/Paris:20261019T093000 +DTEND;TZID=Europe/Paris:20261019T094500 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE +EXDATE;TZID=Europe/Paris:20261021T093000 +LOCATION:https://meet.google.com/abc-defg-hij`, + // The 28 Oct standup was moved to 11:00. + `UID:standup +RECURRENCE-ID;TZID=Europe/Paris:20261028T093000 +SUMMARY:Standup (moved) +DTSTART;TZID=Europe/Paris:20261028T110000 +DTEND;TZID=Europe/Paris:20261028T111500`, + `UID:cancelled +SUMMARY:Gone +STATUS:CANCELLED +DTSTART:20261027T100000Z +DTEND:20261027T110000Z`, + `UID:offsite +SUMMARY:Offsite +DTSTART;VALUE=DATE:20261029 +DTEND;VALUE=DATE:20261031`, + `UID:until +SUMMARY:Daily until +DTSTART:20261026T150000Z +DURATION:PT30M +RRULE:FREQ=DAILY;UNTIL=20261027T150000Z`, + `UID:count +SUMMARY:Ten times +DTSTART;TZID=America/New_York:20260901T120000 +DTEND;TZID=America/New_York:20260901T130000 +RRULE:FREQ=WEEKLY;COUNT=10`, + `UID:outlook +SUMMARY:Outlook meeting +DTSTART;TZID="Romance Standard Time":20261030T140000 +DTEND;TZID="Romance Standard Time":20261030T150000`, + )); + assertEquals(cal.name, "Work"); + const list = expand(cal.events, at("2026-10-19T00:00:00Z"), at("2026-11-03T00:00:00Z"), PARIS); + const got = list.map((o) => `${o.event.summary}@${iso(o.start)}`); + assertEquals(got, [ + "Standup@2026-10-19T07:30Z", // CEST, UTC+2 + "Ten times@2026-10-20T16:00Z", // EDT, UTC−4 + // 21 Oct excluded by EXDATE + "Standup@2026-10-26T08:30Z", // CET, UTC+1: still 09:30 in Paris + "Daily until@2026-10-26T15:00Z", + "Daily until@2026-10-27T15:00Z", // UNTIL is inclusive + "Ten times@2026-10-27T16:00Z", + "Standup (moved)@2026-10-28T10:00Z", // replaces 28 Oct 09:30 + "Offsite@2026-10-28T23:00Z", // all day from local midnight + "Outlook meeting@2026-10-30T13:00Z", + "Standup@2026-11-02T08:30Z", + ]); + const offsite = list.find((o) => o.event.summary === "Offsite")!; + assertEquals([offsite.allDay, (offsite.end - offsite.start) / 3_600_000], [true, 48]); + // COUNT=10 from 1 September: the last one is 3 November, after New York left summer time. + const ten = expand(cal.events, at("2026-09-01T00:00:00Z"), at("2027-01-01T00:00:00Z"), PARIS) + .filter((o) => o.event.uid === "count"); + assertEquals(ten.length, 10); + assertEquals(iso(ten[9].start), "2026-11-03T17:00Z"); // New York back on EST + assertEquals(iso(ten[0].start), "2026-09-01T16:00Z"); +}); + +Deno.test("floating times and events that started before the window", () => { + const cal = parseCalendar(ics( + `UID:float +SUMMARY:Lunch +DTSTART:20261028T120000 +DTEND:20261028T130000`, + `UID:trip +SUMMARY:Trip +DTSTART;VALUE=DATE:20261025 +DTEND;VALUE=DATE:20261030`, + )); + const list = expand(cal.events, at("2026-10-28T00:00:00+01:00"), at("2026-10-29T00:00:00+01:00"), PARIS); + assertEquals(list.map((o) => o.event.summary), ["Trip", "Lunch"]); + assertEquals(iso(list[1].start), "2026-10-28T11:00Z"); +}); + +Deno.test("video calls and descriptions", () => { + assertEquals(findMeeting({ location: "https://meet.google.com/abc-defg-hij", description: "" }), { + url: "https://meet.google.com/abc-defg-hij", + service: "Google Meet", + }); + assertEquals( + findMeeting({ location: "Room 4", description: "Join: https://acme.zoom.us/j/123456789?pwd=xyz." })?.url, + "https://acme.zoom.us/j/123456789?pwd=xyz", + ); + assertEquals( + findMeeting({ + location: "", + description: "", + }) + ?.service, + "Microsoft Teams", + ); + assertEquals(findMeeting({ location: "https://meet.jit.si/OurRoom", description: "" })?.service, "Jitsi Meet"); + assertEquals( + findMeeting({ + conference: "https://meet.google.com/zzz-zzzz-zzz", + location: "https://zoom.us/j/1", + description: "", + }) + ?.service, + "Google Meet", + ); + assertEquals(findMeeting({ location: "Cafe", description: "no link" }), undefined); + assertEquals(placeOf("Room 4 / https://zoom.us/j/1"), "Room 4"); + assertEquals(placeOf("https://meet.google.com/x"), ""); + assertEquals( + cleanDescription('Agenda
    - one
    The doc & more'), + "Agenda\n- one\n[The doc](https://x.com/doc) & more", + ); + assertEquals(cleanDescription("Hi\n\n-::~:~::~:~::~::-\nJoin with Google Meet: …\n-::~:~::~:~::~::-"), "Hi"); +}); + +Deno.test("sections, ranges and relative times", () => { + const now = at("2026-10-28T10:00:00+01:00"); // Wednesday + assertEquals(sectionOf(at("2026-10-28T18:00:00+01:00"), now, PARIS), "Today"); + assertEquals(sectionOf(at("2026-10-29T09:00:00+01:00"), now, PARIS), "Tomorrow"); + assertEquals(sectionOf(at("2026-10-31T09:00:00+01:00"), now, PARIS), "Saturday"); + assertEquals(sectionOf(at("2026-11-09T09:00:00+01:00"), now, PARIS), "Mon 9 Nov"); + const occ = (start: string, end: string, allDay = false) => ({ + event: parseCalendar(ics("UID:x\nSUMMARY:X\nDTSTART:20261028T090000Z")).events[0], + start: at(start), + end: at(end), + allDay, + recurring: false, + }); + assertEquals(timeRange(occ("2026-10-28T09:30:00+01:00", "2026-10-28T10:15:00+01:00"), PARIS, now), "09:30 – 10:15"); + assertEquals( + timeRange(occ("2026-10-28T09:30:00+01:00", "2026-10-28T10:15:00+01:00"), PARIS, now, true), + "9:30 AM – 10:15 AM", + ); + assertEquals(timeRange(occ("2026-10-27T22:00:00+01:00", "2026-10-28T11:00:00+01:00"), PARIS, now), "Until 11:00"); + assertEquals( + timeRange(occ("2026-10-28T00:00:00+01:00", "2026-10-30T00:00:00+01:00", true), PARIS, now), + "All day · 2 days", + ); + assertEquals( + timeRange(occ("2026-10-28T23:00:00+01:00", "2026-10-29T01:00:00+01:00"), PARIS, now), + "23:00 – Thu 29 Oct 01:00", + ); + assertEquals(relative(now + 12 * 60_000, now), "in 12 min"); + assertEquals(relative(now + 75 * 60_000, now), "in 1 h 15"); + assertEquals(relative(now + 120 * 60_000, now), "in 2 h"); + assertEquals(formatDuration(45 * 60_000), "45 min"); + assertEquals(formatDuration(90 * 60_000), "1 h 30"); + assertEquals(formatDuration(2 * 86_400_000), "2 days"); +}); + +Deno.test("subtitle and status line", () => { + const cal = parseCalendar(ics( + "UID:a\nSUMMARY:Design review\nDTSTART:20261028T083000Z\nDTEND:20261028T093000Z", + "UID:b\nSUMMARY:Standup\nDTSTART:20261028T101200Z\nDTEND:20261028T103000Z", + "UID:c\nSUMMARY:Holiday\nDTSTART;VALUE=DATE:20261028", + )); + const list = expand(cal.events, at("2026-10-27T23:00:00Z"), at("2026-10-28T23:00:00Z"), PARIS); + assertEquals(subtitleFor(list, at("2026-10-28T09:00:00Z"), PARIS), "Now: Design review"); + assertEquals(subtitleFor(list, at("2026-10-28T10:00:00Z"), PARIS), "Standup in 12 min"); + assertEquals(subtitleFor(list, at("2026-10-28T11:00:00Z"), PARIS), null); + assertEquals( + statusFor(list, at("2026-10-28T10:00:00Z"), PARIS, 2), + "2 events left today · next: Standup in 12 min · 2 calendars", + ); +}); + +Deno.test("rule descriptions", () => { + assertEquals(describeRule(parseRule("FREQ=WEEKLY;BYDAY=MO,WE")), "Weekly on Monday, Wednesday"); + assertEquals(describeRule(parseRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU,WE,TH,FR")), "Every 2 weeks on weekdays"); + assertEquals(describeRule(parseRule("FREQ=MONTHLY;BYDAY=2TU")), "Monthly on the 2nd Tuesday"); + assertEquals(describeRule(parseRule("FREQ=MONTHLY;BYDAY=-1FR;COUNT=6")), "Monthly on the last Friday, 6 times"); + assertEquals(describeRule(parseRule("FREQ=DAILY;UNTIL=20261231")), "Daily, until 2026-12-31"); + assertEquals(describeRule(undefined), undefined); +}); + +Deno.test("calendar addresses and hosts", () => { + assertEquals( + parseUrls( + "webcal://p42-caldav.icloud.com/published/2/abc\n https://calendar.google.com/calendar/ical/me%40gmail.com/private-x/basic.ics, not-a-url", + ), + [ + "https://p42-caldav.icloud.com/published/2/abc", + "https://calendar.google.com/calendar/ical/me%40gmail.com/private-x/basic.ics", + ], + ); + assertEquals(parseUrls(undefined), []); + assert(hostAllowed("https://p42-caldav.icloud.com/x")); + assert(hostAllowed("https://outlook.office365.com/owa/calendar/x/calendar.ics")); + assert(!hostAllowed("https://nextcloud.example.com/remote.php/dav")); + assert(!hostAllowed("https://evilicloud.com/x")); + assertEquals( + calendarLabel("https://calendar.google.com/calendar/ical/me%40gmail.com/private-x/basic.ics"), + "me@gmail.com", + ); + assertEquals(calendarLabel("https://outlook.office365.com/owa/calendar/x/calendar.ics"), "Outlook"); +}); + +Deno.test("an event item: in progress, Join Meeting first", () => { + const cal = parseCalendar(ics(`UID:r +SUMMARY:Design review +DTSTART:20261028T090000Z +DTEND:20261028T100000Z +LOCATION:Room 4 +DESCRIPTION:Notes\\nhttps://acme.zoom.us/j/42 +ORGANIZER;CN=Ada:mailto:ada@x.com +ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED:mailto:ada@x.com +ATTENDEE;CN=Bob;PARTSTAT=DECLINED:mailto:bob@x.com +ATTENDEE;CN=Cy:mailto:cy@x.com`)); + const [o] = expand(cal.events, at("2026-10-28T00:00:00Z"), at("2026-10-29T00:00:00Z"), PARIS); + const shown: Shown = { ...o, calendar: { url: "u", index: 0, name: "Work", calendar: cal } }; + const now = at("2026-10-28T09:40:00Z"); + const item = eventItem(shown, { zone: PARIS, now, section: "Today", several: false, hour12: false }); + assertEquals(item.icon, { source: "bs-video", tint: "green" }); + assertEquals(item.subtitle, "10:00 – 11:00"); + assertEquals(item.accessories?.[0], { tag: "Now · ends in 20 min", color: "green" }); + assertEquals(item.actions?.[0].title, "Join Meeting"); + assertEquals((item.actions?.[0] as { target: string }).target, "https://acme.zoom.us/j/42"); + const meta = item.detail?.metadata?.map((m) => `${m.label}=${m.value ?? ""}`) ?? []; + assert(meta.includes("Attendees=2 of 3 going"), meta.join(" | ")); + assert(meta.includes("Location=Room 4")); + assertEquals(item.detail?.stats?.[1], { label: "Duration", value: "1 h" }); +}); diff --git a/extensions/calendar/setup.ts b/extensions/calendar/setup.ts new file mode 100644 index 0000000..1e14870 --- /dev/null +++ b/extensions/calendar/setup.ts @@ -0,0 +1,93 @@ +// What the command shows before a calendar is configured: how to get the +// private iCal address of each service. + +import { copy, Icons, type Item, open } from "@backslash/api"; + +const WHERE = + "Then paste it in **Settings → Extensions → Calendar → Calendar addresses** (several addresses: one per line). " + + "It is kept in your system keyring."; + +const PROVIDERS: { id: string; title: string; subtitle: string; markdown: string; links: [string, string][] }[] = [ + { + id: "google", + title: "Google Calendar", + subtitle: "Settings → your calendar → Integrate calendar → Secret address in iCal format", + markdown: `# Google Calendar + +1. Open **Google Calendar settings** (the gear, then *Settings*). +2. Under **Settings for my calendars**, click the calendar to show. +3. In **Integrate calendar**, copy the **Secret address in iCal format** + (it ends with \`/basic.ics\`). + +${WHERE} + +> The secret address gives read access to your events to whoever has it. +> If it leaks, use **Reset** next to it in Google Calendar.`, + links: [ + ["Open Google Calendar Settings", "https://calendar.google.com/calendar/r/settings"], + [ + "Google Help: Secret Address", + "https://support.google.com/calendar/answer/37648#zippy=%2Cget-your-calendar-view-only", + ], + ], + }, + { + id: "outlook", + title: "Outlook & Microsoft 365", + subtitle: "Settings → Calendar → Shared calendars → Publish a calendar → ICS", + markdown: `# Outlook and Microsoft 365 + +1. In Outlook on the web, open **Settings → Calendar → Shared calendars**. +2. Under **Publish a calendar**, pick the calendar and **Can view all details**, then **Publish**. +3. Copy the **ICS** link. + +${WHERE}`, + links: [ + ["Open Outlook Calendar Settings", "https://outlook.office.com/calendar/options/calendar/SharedCalendars"], + ["Open Outlook.com Calendar Settings", "https://outlook.live.com/calendar/options/calendar/SharedCalendars"], + ], + }, + { + id: "icloud", + title: "iCloud Calendar", + subtitle: "Share the calendar as a Public Calendar and copy its webcal:// link", + markdown: `# iCloud Calendar + +1. On icloud.com/calendar (or in Calendar on a Mac or iPhone), share the calendar. +2. Turn on **Public Calendar** and copy the link (\`webcal://p…-caldav.icloud.com/…\`). + +${WHERE} \`webcal://\` links work as they are.`, + links: [["Open iCloud Calendar", "https://www.icloud.com/calendar/"]], + }, + { + id: "fastmail", + title: "Fastmail, Proton, Zoho, Yahoo", + subtitle: "Their “share / export as iCal” link", + markdown: `# Other services + +- **Fastmail**: Settings → Calendars → the calendar → **Share** → *Share with others* → copy the **iCal (.ics)** link. +- **Proton Calendar**: Settings → Calendars → **Share via link** → *Full view*. +- **Zoho** and **Yahoo**: the calendar's **Export / iCal** address. + +${WHERE} + +Self-hosted servers (Nextcloud, Radicale…) can't be reached: the extension +may only connect to the services listed here.`, + links: [["Fastmail Help", "https://www.fastmail.help/hc/en-us/articles/1500000278242"]], + }, +]; + +export function setupItems(): Item[] { + return PROVIDERS.map((p) => ({ + id: `setup:${p.id}`, + title: p.title, + subtitle: p.subtitle, + icon: { source: Icons.calendar, tint: "blue" }, + section: "Add a Calendar", + detail: { markdown: p.markdown }, + actions: [ + ...p.links.map(([title, url]) => open(url, title)), + copy("Settings → Extensions → Calendar → Calendar addresses", "Copy Where to Paste It", { section: "Copy" }), + ], + })); +} diff --git a/extensions/obsidian/backslash.toml b/extensions/obsidian/backslash.toml new file mode 100644 index 0000000..4d37a03 --- /dev/null +++ b/extensions/obsidian/backslash.toml @@ -0,0 +1,73 @@ +[extension] +id = "obsidian" +title = "Obsidian" +description = "Search your Obsidian notes by title, tag and text, preview them, open today's daily note and capture ideas" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# Vaults can live anywhere in your home folder, so reading it is needed to +# search them; nothing is ever written: notes are created and appended to +# through Obsidian itself (obsidian:// links). +[permissions] +read = ["~"] +env = ["HOME"] + +[[commands]] +name = "search" +title = "Search Obsidian Notes" +description = "Titles, aliases, #tags and full text of every vault" +icon = "bs-search" +keywords = ["obsidian", "notes", "vault", "markdown", "wiki", "zettelkasten", "notes", "chercher", "coffre"] + +[[commands]] +name = "daily" +title = "Open Obsidian Daily Note" +description = "Today's note, created from your template if needed" +icon = "bs-calendar" +mode = "no-view" +keywords = ["obsidian", "daily", "journal", "today", "note du jour", "journal", "aujourd'hui"] + +[[commands]] +name = "capture" +title = "Capture to Obsidian" +description = "Append a line to today's daily note or to your inbox note" +icon = "bs-bolt" +mode = "no-view" +keywords = ["obsidian", "capture", "inbox", "jot", "append", "noter", "ajouter", "idée"] +[[commands.arguments]] +name = "text" +placeholder = "Idea, task, link…" +required = true + +[[commands]] +name = "vaults" +title = "Open Obsidian Vault" +description = "Your vaults with their note counts" +icon = "bs-archive" +keywords = ["obsidian", "vault", "coffre", "open"] + +[[preferences]] +name = "vault" +title = "Vault for daily notes and capture" +description = "Vault name; empty: the one opened last" + +[[preferences]] +name = "captureTo" +title = "Capture into" +description = "“daily” (today's daily note) or “inbox” (the note below)" +default = "daily" + +[[preferences]] +name = "inbox" +title = "Inbox note" +description = "Path in the vault, without .md, used when capturing into the inbox" +default = "Inbox" + +[[preferences]] +name = "timestamp" +title = "Prefix captures with the time" +description = "“- 14:05 your text”" +type = "boolean" +default = true diff --git a/extensions/obsidian/commands/capture.ts b/extensions/obsidian/commands/capture.ts new file mode 100644 index 0000000..031839a --- /dev/null +++ b/extensions/obsidian/commands/capture.ts @@ -0,0 +1,17 @@ +import { openExternal, run } from "@backslash/api"; +import { appendUri, captureLine, pickVault, stripMd } from "../lib.ts"; +import { loadVaults, todayNote } from "../vault.ts"; + +// Appends one line through Obsidian itself (obsidian://new with append and +// silent): no write access to your vault is needed, and sync sees the change. +export default run<{ text: string }>(async ({ args, preferences }) => { + const text = String(args.text ?? "").trim(); + if (!text) throw new Error("Type what to capture after the command"); + const vault = pickVault(await loadVaults(), preferences.vault); + if (!vault) throw new Error("No Obsidian vault found: open one in Obsidian first"); + const inbox = String(preferences.captureTo ?? "daily").toLowerCase().startsWith("inbox"); + const path = inbox ? `${String(preferences.inbox || "Inbox").replace(/^\/+/, "")}` : (await todayNote(vault)).path; + const line = captureLine(text, new Date(), preferences.timestamp !== false); + await openExternal(appendUri(vault.name, path, `\n${line}`)); + return `Added to ${stripMd(path)}`; +}); diff --git a/extensions/obsidian/commands/daily.ts b/extensions/obsidian/commands/daily.ts new file mode 100644 index 0000000..027d47f --- /dev/null +++ b/extensions/obsidian/commands/daily.ts @@ -0,0 +1,17 @@ +import { openExternal, run } from "@backslash/api"; +import { obsidianUri, pickVault, stripMd } from "../lib.ts"; +import { loadVaults, todayNote } from "../vault.ts"; + +// Today's note exists: open it. Otherwise let Obsidian create it, so the +// Daily notes plugin applies its folder and template. +export default run(async ({ preferences }) => { + const vault = pickVault(await loadVaults(), preferences.vault); + if (!vault) throw new Error("No Obsidian vault found: open one in Obsidian first"); + const today = await todayNote(vault); + await openExternal( + today.exists + ? obsidianUri("open", { vault: vault.name, file: stripMd(today.path) }) + : obsidianUri("daily", { vault: vault.name }), + ); + return today.exists ? `Opening ${stripMd(today.path)}` : `Creating today's note in ${vault.name}`; +}); diff --git a/extensions/obsidian/commands/search.ts b/extensions/obsidian/commands/search.ts new file mode 100644 index 0000000..d97372c --- /dev/null +++ b/extensions/obsidian/commands/search.ts @@ -0,0 +1,81 @@ +import { action, type Context, Icons, type Item, list, open, refresh } from "@backslash/api"; +import { obsidianUri, score, snippet } from "../lib.ts"; +import { indexVault, loadVaults, NO_VAULT, noteItem, todayNote } from "../vault.ts"; + +let footer = ""; + +export default async (_ctx: Context) => { + const vaults = await loadVaults(); + const several = vaults.length > 1; + return list({ + placeholder: several + ? "Search notes in all vaults by title, alias, #tag or text…" + : "Search notes by title, alias, #tag or text…", + filter: several + ? { options: [{ value: "all", title: "All Vaults" }, ...vaults.map((v) => ({ value: v.path, title: v.name }))] } + : undefined, + empty: vaults.length + ? { title: "No matching note", description: "Press Enter on “Create” to start one", icon: Icons.note } + : NO_VAULT, + status: () => footer, + actions: [action("Reload Vaults", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + async search(query, { filter }) { + const shown = !filter || filter === "all" ? vaults : vaults.filter((v) => v.path === filter); + if (!shown.length) return []; + const notes = (await Promise.all(shown.map((v) => indexVault(v)))).flat(); + const q = query.trim(); + const items: Item[] = []; + if (!q) { + footer = `${notes.length} notes${several ? ` in ${shown.length} vaults` : ` · ${shown[0].name}`}`; + const daily = await todayNote(shown[0]); + items.push({ + id: "daily", + title: daily.exists ? "Open Today's Daily Note" : "Create Today's Daily Note", + subtitle: daily.path, + icon: { source: Icons.calendar, tint: "purple" }, + section: "Daily", + accessories: several ? [{ text: shown[0].name }] : [], + actions: [ + open( + daily.exists + ? obsidianUri("open", { vault: shown[0].name, file: daily.path.replace(/\.md$/, "") }) + : obsidianUri("daily", { vault: shown[0].name }), + daily.exists ? "Open Daily Note" : "Create Daily Note", + ), + ], + }); + const recent = [...notes].sort((a, b) => b.modified - a.modified).slice(0, 40); + for (const n of recent) items.push(noteItem(n, { several, section: "Recently Modified" })); + return items; + } + const ranked = notes + .map((n) => ({ n, s: score({ ...n, content: n.body }, q) })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || b.n.modified - a.n.modified); + footer = `${ranked.length} of ${notes.length} notes`; + for (const { n, s } of ranked.slice(0, 60)) { + // Content-only matches show where the words are. + const inContent = s < 3 * q.split(/\s+/).length; + items.push( + noteItem(n, { + several, + section: inContent ? "Text Matches" : "Notes", + subtitle: inContent ? snippet(n.body, q) : undefined, + }), + ); + } + const target = shown[0]; + items.push({ + id: "create", + title: `Create “${q}”`, + subtitle: several ? `New note in ${target.name}` : "New note", + icon: { source: Icons.plus, tint: "green" }, + section: "Create", + actions: [ + open(obsidianUri("new", { vault: target.name, name: q }), "Create Note in Obsidian", { icon: Icons.plus }), + ], + }); + return items; + }, + }); +}; diff --git a/extensions/obsidian/commands/vaults.ts b/extensions/obsidian/commands/vaults.ts new file mode 100644 index 0000000..949eb83 --- /dev/null +++ b/extensions/obsidian/commands/vaults.ts @@ -0,0 +1,60 @@ +import { copy, Icons, list, meta, open } from "@backslash/api"; +import { ago, obsidianUri } from "../lib.ts"; +import { indexVault, loadVaults, NO_VAULT, tildify } from "../vault.ts"; + +export default list({ + placeholder: "Open an Obsidian vault…", + empty: NO_VAULT, + status: (items) => `${items.length} vault${items.length === 1 ? "" : "s"}`, + async *items() { + const vaults = await loadVaults(); + const item = (v: (typeof vaults)[number], stats?: { notes: number; tags: number; words: string }) => ({ + id: v.path, + title: v.name, + subtitle: tildify(v.path), + icon: { source: Icons.archive, tint: "purple" as const }, + accessories: [ + ...(v.open ? [{ tag: "Open", color: "green" as const }] : []), + ...(stats ? [{ text: `${stats.notes} notes` }] : []), + ...(v.ts ? [{ text: ago(v.ts), tooltip: "Last opened" }] : []), + ], + detail: { + stats: stats + ? [{ label: "Notes", value: stats.notes }, { label: "Tags", value: stats.tags }, { + label: "Words", + value: stats.words, + }] + : [], + metadata: [ + meta.text("Path", tildify(v.path), { icon: Icons.folder }), + ...(v.ts + ? [ + meta.text( + "Last opened", + new Date(v.ts).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }), + { icon: Icons.clock }, + ), + ] + : []), + ], + }, + actions: [ + open(obsidianUri("open", { vault: v.name }), "Open in Obsidian"), + open(v.path, "Show in Folder", { icon: Icons.folder }), + copy(v.path, "Copy Path", { shortcut: "c" }), + ], + }); + yield vaults.map((v) => item(v)); + // Counting notes reads every file: show the vaults first. + const stats = await Promise.all(vaults.map(async (v) => { + const notes = await indexVault(v); + const words = notes.reduce((n, x) => n + x.body.split(/\s+/).filter(Boolean).length, 0); + return { + notes: notes.length, + tags: new Set(notes.flatMap((n) => n.tags)).size, + words: words >= 1000 ? `${Math.round(words / 1000)}k` : String(words), + }; + })); + yield vaults.map((v, i) => item(v, stats[i])); + }, +}); diff --git a/extensions/obsidian/lib.ts b/extensions/obsidian/lib.ts new file mode 100644 index 0000000..0064b9c --- /dev/null +++ b/extensions/obsidian/lib.ts @@ -0,0 +1,388 @@ +// Obsidian logic: vault discovery, markdown parsing (frontmatter, tags, +// links), daily note paths in moment.js format, and obsidian:// URIs. + +export interface Vault { + id: string; + name: string; + path: string; + /** Last opened, ms. */ + ts: number; + open: boolean; +} + +/** Where Obsidian keeps `obsidian.json`: native, Flatpak and Snap installs. */ +export const CONFIG_PATHS = [ + ".config/obsidian/obsidian.json", + ".var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json", + "snap/obsidian/current/.config/obsidian/obsidian.json", +]; + +/** Vaults of an `obsidian.json`, most recently opened first. */ +export function parseVaults(json: string): Vault[] { + let data: { vaults?: Record }; + try { + data = JSON.parse(json); + } catch { + return []; + } + return Object.entries(data.vaults ?? {}) + .filter(([, v]) => typeof v?.path === "string" && v.path) + .map(([id, v]) => ({ + id, + name: basename(v.path!.replace(/\/+$/, "")), + path: v.path!.replace(/\/+$/, ""), + ts: Number(v.ts ?? 0), + open: v.open === true, + })) + .sort((a, b) => Number(b.open) - Number(a.open) || b.ts - a.ts); +} + +/** Merges vault lists (several installs), keeping the first of each path. */ +export function mergeVaults(lists: Vault[][]): Vault[] { + const seen = new Set(); + const out: Vault[] = []; + for (const v of lists.flat().sort((a, b) => Number(b.open) - Number(a.open) || b.ts - a.ts)) { + if (seen.has(v.path)) continue; + seen.add(v.path); + out.push(v); + } + return out; +} + +/** The vault a preference names (case-insensitive), else the last opened. */ +export function pickVault(vaults: Vault[], name: unknown): Vault | undefined { + const n = String(name ?? "").trim().toLowerCase(); + return (n && vaults.find((v) => v.name.toLowerCase() === n || v.path.toLowerCase() === n)) || vaults[0]; +} + +export const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1); +export const dirname = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : ""); +export const stripMd = (p: string) => p.replace(/\.md$/i, ""); + +// --------------------------------------------------------------------------- +// Markdown + +export interface Frontmatter { + raw: string; + /** Scalar values and lists, as strings. */ + fields: Record; +} + +/** Splits `---\nyaml\n---\nbody`. */ +export function splitFrontmatter(text: string): { frontmatter: Frontmatter; body: string } { + const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text); + if (!m) return { frontmatter: { raw: "", fields: {} }, body: text }; + return { frontmatter: { raw: m[1], fields: parseYamlish(m[1]) }, body: text.slice(m[0].length) }; +} + +const unquote = (s: string) => s.trim().replace(/^(["'])(.*)\1$/, "$2"); + +/** + * The flat subset of YAML frontmatter uses: `key: value`, `key: [a, b]` and + * `key:` followed by `- item` lines. Nested maps are kept as text. + */ +export function parseYamlish(yaml: string): Record { + const out: Record = {}; + let listKey: string | undefined; + for (const line of yaml.split(/\r?\n/)) { + if (!line.trim() || line.trim().startsWith("#")) continue; + const item = /^\s*-\s+(.*)$/.exec(line); + if (item && listKey) { + (out[listKey] as string[]).push(unquote(item[1])); + continue; + } + const kv = /^([A-Za-z0-9_\- ]+?)\s*:\s*(.*)$/.exec(line); + if (!kv || /^\s/.test(line)) continue; + const [, key, value] = kv; + if (!value.trim()) { + out[key] = []; + listKey = key; + } else if (/^\[.*\]$/.test(value.trim())) { + out[key] = value.trim().slice(1, -1).split(",").map(unquote).filter(Boolean); + listKey = undefined; + } else { + out[key] = unquote(value); + listKey = undefined; + } + } + return out; +} + +const asList = (v: string | string[] | undefined): string[] => + v === undefined ? [] : Array.isArray(v) ? v : v.split(/[,\s]+/).filter(Boolean); + +/** `#tags` in the text, outside code blocks and spans; nested tags (`#a/b`) kept whole. */ +export function inlineTags(body: string): string[] { + const text = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, ""); + const out = new Set(); + for (const m of text.matchAll(/(?:^|[\s(])#([\p{L}\p{N}_\-/]*\p{L}[\p{L}\p{N}_\-/]*)/gu)) out.add(m[1]); + return [...out]; +} + +/** Frontmatter `tags` (or `tag`) then inline tags, without `#`, unique. */ +export function noteTags(fm: Frontmatter, body: string): string[] { + const front = [...asList(fm.fields.tags), ...asList(fm.fields.tag)].map((t) => t.replace(/^#/, "")); + return [...new Set([...front, ...inlineTags(body)])].filter(Boolean); +} + +export function noteAliases(fm: Frontmatter): string[] { + return [...asList(fm.fields.aliases), ...asList(fm.fields.alias)]; +} + +/** + * Targets of the links of a note, normalized like Obsidian resolves them: + * `[[Note]]`, `[[folder/Note|label]]`, `[[Note#heading]]`, `![[embed]]`, + * `[text](Note.md)`. Returned lowercased, without `.md`. + */ +export function linkTargets(body: string): string[] { + const out: string[] = []; + for (const m of body.matchAll(/!?\[\[([^\]|#^]+)(?:[#^][^\]|]*)?(?:\|[^\]]*)?\]\]/g)) out.push(m[1].trim()); + for (const m of body.matchAll(/\[[^\]]*\]\(([^)\s]+\.md)(?:#[^)]*)?\)/gi)) { + if (!/^[a-z]+:\/\//i.test(m[1])) out.push(decodeURIComponent(m[1])); + } + return out.map((t) => stripMd(t).replace(/^\.?\//, "").toLowerCase()); +} + +/** + * How many notes link to each note. A link `[[Name]]` counts for every note + * named Name; `[[folder/Name]]` for the note at that path. + */ +export function backlinkCounts(notes: { path: string; links: string[] }[]): Map { + const byName = new Map(); + for (const n of notes) { + const name = basename(stripMd(n.path)).toLowerCase(); + byName.set(name, [...(byName.get(name) ?? []), n.path]); + } + const byPath = new Map(notes.map((n) => [stripMd(n.path).toLowerCase(), n.path])); + const counts = new Map(); + for (const n of notes) { + const targets = new Set(); + for (const link of n.links) { + const exact = byPath.get(link); + const matches = exact ? [exact] : byName.get(basename(link)) ?? []; + for (const p of matches) if (p !== n.path) targets.add(p); + } + for (const t of targets) counts.set(t, (counts.get(t) ?? 0) + 1); + } + return counts; +} + +/** Obsidian-only syntax made readable in the launcher's markdown. */ +export function previewMarkdown(body: string, max = 5000): string { + let t = body + .replace(/%%[\s\S]*?%%/g, "") // comments + .replace(/!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g, "_(embedded: $1)_") + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "**$2**") + .replace(/\[\[([^\]]+)\]\]/g, (_, target: string) => `**${target.replace(/#\^?/, " › ")}**`) + .replace(/==([^=]+)==/g, "**$1**") + .replace(/^> \[!(\w+)\][+-]?\s*/gm, (_, kind: string) => `> **${kind[0].toUpperCase()}${kind.slice(1)}:** `) + .trim(); + if (t.length > max) t = `${t.slice(0, max)}\n\n…`; + return t || "_Empty note._"; +} + +export function wordCount(text: string): number { + return (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length; +} + +export function firstLine(body: string): string { + for (const line of body.split("\n")) { + const t = line.replace(/^\s*[-*] \[[ x]\]\s+|^#+\s*|^[-*>]\s+|^\d+\.\s+/i, "").replace( + /\[\[([^\]|]+\|)?([^\]]+)\]\]/g, + "$2", + ) + .replace(/[*_`=]/g, "").trim(); + if (t && !/^%%/.test(t)) return t.length > 90 ? `${t.slice(0, 90)}…` : t; + } + return ""; +} + +export const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); + +/** + * Relevance of a note for the words typed: title and aliases first, then + * tags and path, then content. 0 when a word is nowhere. + */ +export function score( + note: { title: string; path: string; aliases: string[]; tags: string[]; content?: string }, + query: string, +): number { + const words = fold(query).split(/\s+/).filter(Boolean); + if (!words.length) return 1; + const title = fold(note.title); + const aliases = note.aliases.map(fold); + const tags = note.tags.map(fold); + const path = fold(note.path); + const content = note.content ? fold(note.content) : ""; + let total = fold(query).trim() === title ? 20 : 0; + for (const w of words) { + if (title.startsWith(w)) total += 8; + else if (title.includes(w)) total += 6; + else if (aliases.some((a) => a.includes(w))) total += 5; + else if (tags.some((t) => t.startsWith(w.replace(/^#/, "")))) total += 4; + else if (path.includes(w)) total += 3; + else if (content.includes(w)) total += 1; + else return 0; + } + return total; +} + +/** A short excerpt of the content around the first word found, for subtitles. */ +export function snippet(content: string, query: string, width = 80): string | undefined { + const words = fold(query).split(/\s+/).filter((w) => w.length > 1); + const flat = content + .replace(/\[\[([^\]|]+\|)?([^\]]+)\]\]/g, "$2") + .replace(/^#+\s+|^\s*[-*] \[[ x]\]\s+/gim, "") + .replace(/==|\*\*|__|`/g, "") + .replace(/\s+/g, " "); + const folded = fold(flat); + for (const w of words) { + const i = folded.indexOf(w); + if (i !== -1) { + const start = Math.max(0, i - 25); + const text = flat.slice(start, start + width).trim(); + return `${start > 0 ? "…" : ""}${text}${start + width < flat.length ? "…" : ""}`; + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Daily notes + +export interface DailyConfig { + folder: string; + format: string; + template?: string; +} + +/** `.obsidian/daily-notes.json`; Obsidian's defaults when missing. */ +export function parseDailyConfig(json: string | undefined): DailyConfig { + try { + const c = JSON.parse(json ?? "{}"); + return { + folder: String(c.folder ?? "").replace(/^\/+|\/+$/g, ""), + format: String(c.format || "YYYY-MM-DD"), + ...(c.template ? { template: String(c.template) } : {}), + }; + } catch { + return { folder: "", format: "YYYY-MM-DD" }; + } +} + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th"; + return `${n}${s}`; +} + +/** ISO week number (moment's `W`, and `w` for ISO locales). */ +export function isoWeek(d: Date): { week: number; year: number } { + const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = t.getUTCDay() || 7; + t.setUTCDate(t.getUTCDate() + 4 - day); + const start = new Date(Date.UTC(t.getUTCFullYear(), 0, 1)); + return { week: Math.ceil(((t.getTime() - start.getTime()) / 86_400_000 + 1) / 7), year: t.getUTCFullYear() }; +} + +/** Formats a local date with the moment.js tokens daily notes use (`YYYY-MM-DD`, `dddd, MMMM Do YYYY`, `[Week] W`…). */ +export function formatMoment(d: Date, format: string): string { + const pad = (n: number, w = 2) => String(n).padStart(w, "0"); + const week = isoWeek(d); + const tokens: Record string> = { + YYYY: () => String(d.getFullYear()), + GGGG: () => String(week.year), + YY: () => pad(d.getFullYear() % 100), + MMMM: () => MONTHS[d.getMonth()], + MMM: () => MONTHS[d.getMonth()].slice(0, 3), + MM: () => pad(d.getMonth() + 1), + M: () => String(d.getMonth() + 1), + DDDD: () => pad(dayOfYear(d), 3), + DDD: () => String(dayOfYear(d)), + DD: () => pad(d.getDate()), + Do: () => ordinal(d.getDate()), + D: () => String(d.getDate()), + dddd: () => DAYS[d.getDay()], + ddd: () => DAYS[d.getDay()].slice(0, 3), + dd: () => DAYS[d.getDay()].slice(0, 2), + d: () => String(d.getDay()), + E: () => String(d.getDay() || 7), + WW: () => pad(week.week), + W: () => String(week.week), + ww: () => pad(week.week), + w: () => String(week.week), + HH: () => pad(d.getHours()), + H: () => String(d.getHours()), + mm: () => pad(d.getMinutes()), + ss: () => pad(d.getSeconds()), + Q: () => String(Math.floor(d.getMonth() / 3) + 1), + }; + const re = /\[([^\]]*)\]|YYYY|GGGG|YY|MMMM|MMM|MM|M|DDDD|DDD|DD|Do|D|dddd|ddd|dd|d|E|WW|W|ww|w|HH|H|mm|ss|Q/g; + return format.replace(re, (tok, literal) => (literal !== undefined ? literal : tokens[tok]?.() ?? tok)); +} + +function dayOfYear(d: Date): number { + return Math.round( + (Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - Date.UTC(d.getFullYear(), 0, 1)) / 86_400_000, + ) + + 1; +} + +/** The vault-relative path of the daily note for a date, with `.md`. */ +export function dailyNotePath(config: DailyConfig, date: Date): string { + const name = formatMoment(date, config.format); + return `${config.folder ? `${config.folder}/` : ""}${name}.md`; +} + +// --------------------------------------------------------------------------- +// obsidian:// URIs (https://help.obsidian.md/Extending+Obsidian/Obsidian+URI) + +/** `obsidian://open?vault=My%20Vault&file=Folder%2FNote`: every value percent-encoded, spaces as %20. */ +export function obsidianUri(action: string, params: Record): string { + const query = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== false) + .map(([k, v]) => `${k}=${v === true ? "true" : encodeURIComponent(String(v))}`) + .join("&"); + return `obsidian://${action}${query ? `?${query}` : ""}`; +} + +export const openUri = (vault: string, path: string) => obsidianUri("open", { vault, file: stripMd(path) }); + +/** Appends text to a note (created if missing) without bringing Obsidian to the front. */ +export const appendUri = (vault: string, path: string, line: string) => + obsidianUri("new", { vault, file: stripMd(path), content: line, append: true, silent: true }); + +/** The line added by a quick capture: "- 14:05 text", or "- text". */ +export function captureLine(text: string, now: Date, timestamp: boolean): string { + const t = text.trim().replace(/\s*\n\s*/g, " "); + if (!timestamp) return `- ${t}`; + return `- ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} ${t}`; +} + +/** "now", "5m", "3h", "2d", or the date. */ +export function ago(ms: number, now = Date.now()): string { + const m = Math.floor((now - ms) / 60000); + if (m < 1) return "now"; + if (m < 60) return `${m}m`; + if (m < 1440) return `${Math.floor(m / 60)}h`; + if (m < 7 * 1440) return `${Math.floor(m / 1440)}d`; + return new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short", year: "2-digit" }); +} + +/** Directories never searched: Obsidian's own, its trash, git, dependencies. */ +export const skipDir = (name: string) => name.startsWith(".") || name === "node_modules"; diff --git a/extensions/obsidian/lib_test.ts b/extensions/obsidian/lib_test.ts new file mode 100644 index 0000000..36c5ef1 --- /dev/null +++ b/extensions/obsidian/lib_test.ts @@ -0,0 +1,181 @@ +import { assert, assertEquals } from "@std/assert"; +import { + appendUri, + backlinkCounts, + captureLine, + dailyNotePath, + firstLine, + formatMoment, + inlineTags, + isoWeek, + linkTargets, + mergeVaults, + noteAliases, + noteTags, + obsidianUri, + openUri, + parseDailyConfig, + parseVaults, + parseYamlish, + pickVault, + previewMarkdown, + score, + skipDir, + snippet, + splitFrontmatter, +} from "./lib.ts"; +import { indexVault, noteItem } from "./vault.ts"; + +const CONFIG = JSON.stringify({ + vaults: { + a1: { path: "/home/ada/Notes/Work", ts: 100 }, + b2: { path: "/home/ada/Sync/Personal/", ts: 300, open: true }, + c3: { path: "/home/ada/Old", ts: 200 }, + d4: { ts: 5 }, + }, +}); + +Deno.test("vaults from obsidian.json", () => { + const vaults = parseVaults(CONFIG); + assertEquals(vaults.map((v) => v.name), ["Personal", "Old", "Work"]); + assertEquals(vaults[0].path, "/home/ada/Sync/Personal"); + assertEquals(parseVaults("not json"), []); + assertEquals(pickVault(vaults, "work")?.name, "Work"); + assertEquals(pickVault(vaults, "")?.name, "Personal"); + assertEquals(pickVault(vaults, "nope")?.name, "Personal"); + const flatpak = parseVaults(JSON.stringify({ vaults: { x: { path: "/home/ada/Notes/Work", ts: 999 } } })); + assertEquals(mergeVaults([vaults, flatpak]).map((v) => v.name), ["Personal", "Work", "Old"]); +}); + +Deno.test("frontmatter, tags and aliases", () => { + const text = [ + "---", + "title: Design review", + "tags: [project, 'meeting']", + "aliases:", + " - DR", + " - Review", + "status: draft", + "nested:", + " key: value", + "---", + "Notes for #team/design and #2026 not #42, `#code` too.", + "```", + "#not-a-tag", + "```", + ].join("\n"); + const { frontmatter, body } = splitFrontmatter(text); + assertEquals(frontmatter.fields.title, "Design review"); + assertEquals(frontmatter.fields.status, "draft"); + assertEquals(noteAliases(frontmatter), ["DR", "Review"]); + assertEquals(noteTags(frontmatter, body), ["project", "meeting", "team/design"]); + assertEquals(parseYamlish("tags: a, b\n# comment\nempty:\n"), { tags: "a, b", empty: [] }); + assertEquals(noteTags(splitFrontmatter("---\ntags: a b\n---\n").frontmatter, ""), ["a", "b"]); + assertEquals(splitFrontmatter("no frontmatter").body, "no frontmatter"); + assertEquals(inlineTags("(#inline) and#not"), ["inline"]); +}); + +Deno.test("links and backlinks", () => { + const body = "See [[Project X]], [[folder/Meeting|the meeting]], [[Note#Heading]], ![[diagram.png]] " + + "and [md](Other%20Note.md) but not [web](https://x.com/a.md)"; + assertEquals(linkTargets(body), ["project x", "folder/meeting", "note", "diagram.png", "other note"]); + const counts = backlinkCounts([ + { path: "Project X.md", links: ["meeting"] }, + { path: "folder/Meeting.md", links: ["project x"] }, + { path: "Daily/2026-10-28.md", links: ["project x", "project x", "folder/meeting"] }, + { path: "Other/Meeting.md", links: [] }, + ]); + assertEquals(counts.get("Project X.md"), 2); // counted once per linking note + assertEquals(counts.get("folder/Meeting.md"), 2); // by name from Project X, by path from the daily note + assertEquals(counts.get("Other/Meeting.md"), 1); // [[Meeting]] matches every note named so +}); + +Deno.test("preview and first line", () => { + assertEquals( + previewMarkdown("Go to [[Page|this page]] and [[Other#Part]] ==now== %%hidden%%"), + "Go to **this page** and **Other › Part** **now**", + ); + assertEquals(previewMarkdown("> [!warning] Careful"), "> **Warning:** Careful"); + assertEquals(previewMarkdown("![[img.png]]"), "_(embedded: img.png)_"); + assertEquals(previewMarkdown(""), "_Empty note._"); + assertEquals(firstLine("\n## Plan\n"), "Plan"); + assertEquals(firstLine("- [ ] call [[Bob|Robert]]"), "call Robert"); +}); + +Deno.test("search scoring and snippets", () => { + const note = (title: string, content = "", tags: string[] = [], aliases: string[] = []) => ({ + title, + path: `x/${title}.md`, + tags, + aliases, + content, + }); + const exact = score(note("Design review"), "design review"); + const prefix = score(note("Design system"), "design"); + const alias = score(note("DR-2", "", [], ["Design meeting"]), "design"); + const text = score(note("Weekly", "we talked about design"), "design"); + assert(exact > prefix && prefix > alias && alias > text && text > 0); + assert(score(note("Weekly", "", ["design"]), "#design") > 0); + assertEquals(score(note("Weekly", "nothing"), "design"), 0); + assert(score(note("Réunion"), "reunion") > 0); + assertEquals( + snippet("A long text where the important word appears once.", "important"), + "A long text where the important word appears once.", + ); + assertEquals(snippet("# Launch\nPlan with [[Ideas|ideas]] ==soon==", "plan"), "Launch Plan with ideas soon"); + assert(snippet("x ".repeat(50) + "needle " + "y ".repeat(50), "needle")!.startsWith("…")); +}); + +Deno.test("daily notes in moment.js format", () => { + const d = new Date(2026, 9, 28, 14, 5); // Wednesday 28 October 2026 + assertEquals(formatMoment(d, "YYYY-MM-DD"), "2026-10-28"); + assertEquals(formatMoment(d, "dddd, MMMM Do YYYY"), "Wednesday, October 28th YYYY".replace("YYYY", "2026")); + assertEquals(formatMoment(d, "DD.MM.YY ddd"), "28.10.26 Wed"); + assertEquals(formatMoment(d, "[Week] W [of] GGGG"), "Week 44 of 2026"); + assertEquals(formatMoment(new Date(2026, 0, 1), "D/M Do"), "1/1 1st"); + assertEquals(formatMoment(new Date(2026, 0, 12), "Do"), "12th"); + assertEquals(formatMoment(new Date(2026, 0, 22), "Do"), "22nd"); + assertEquals(isoWeek(new Date(2027, 0, 1)), { week: 53, year: 2026 }); + assertEquals(parseDailyConfig(undefined), { folder: "", format: "YYYY-MM-DD" }); + const cfg = parseDailyConfig('{"folder":"/Journal/Daily/","format":"YYYY/MM/YYYY-MM-DD","template":"T"}'); + assertEquals(dailyNotePath(cfg, d), "Journal/Daily/2026/10/2026-10-28.md"); + assertEquals(dailyNotePath(parseDailyConfig('{"format":""}'), d), "2026-10-28.md"); +}); + +Deno.test("obsidian:// URIs", () => { + assertEquals(openUri("My Vault", "Folder/Note é.md"), "obsidian://open?vault=My%20Vault&file=Folder%2FNote%20%C3%A9"); + assertEquals( + appendUri("V", "Inbox", "- 14:05 buy milk & eggs"), + "obsidian://new?vault=V&file=Inbox&content=-%2014%3A05%20buy%20milk%20%26%20eggs&append=true&silent=true", + ); + assertEquals(obsidianUri("daily", { vault: "V", x: undefined, y: false }), "obsidian://daily?vault=V"); + assertEquals(captureLine(" buy\nmilk ", new Date(2026, 9, 28, 9, 7), true), "- 09:07 buy milk"); + assertEquals(captureLine("idea", new Date(), false), "- idea"); + assert(skipDir(".obsidian") && skipDir(".trash") && skipDir("node_modules") && !skipDir("Projects")); +}); + +Deno.test("indexing a vault on disk", async () => { + const root = await Deno.makeTempDir(); + try { + await Deno.mkdir(`${root}/.obsidian`); + await Deno.mkdir(`${root}/.trash`); + await Deno.mkdir(`${root}/Projects`); + await Deno.writeTextFile(`${root}/.obsidian/x.md`, "skip"); + await Deno.writeTextFile(`${root}/.trash/Deleted.md`, "skip"); + await Deno.writeTextFile(`${root}/Projects/Launch.md`, "---\ntags: [work]\n---\n# Launch\nPlan with [[Ideas]]"); + await Deno.writeTextFile(`${root}/Ideas.md`, "Some #idea for [[Launch]] and [[Launch|again]]"); + await Deno.writeTextFile(`${root}/image.png`, ""); + const vault = { id: "v", name: "Test", path: root, ts: 0, open: true }; + const notes = await indexVault(vault, 0); + assertEquals(notes.map((n) => n.path).sort(), ["Ideas.md", "Projects/Launch.md"]); + const launch = notes.find((n) => n.title === "Launch")!; + assertEquals([launch.tags, launch.backlinks, launch.links], [["work"], 1, ["ideas"]]); + const item = noteItem(launch, { several: false, section: "Notes" }); + assertEquals(item.subtitle, "Launch"); + assertEquals(item.actions?.[0].title, "Open in Obsidian"); + assertEquals((item.actions?.[0] as { target: string }).target, "obsidian://open?vault=Test&file=Projects%2FLaunch"); + assertEquals(item.detail?.metadata?.map((m) => m.label).slice(0, 2), ["Folder", "Tags"]); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); diff --git a/extensions/obsidian/vault.ts b/extensions/obsidian/vault.ts new file mode 100644 index 0000000..f0a879f --- /dev/null +++ b/extensions/obsidian/vault.ts @@ -0,0 +1,252 @@ +// Reads vaults from disk and turns notes into launcher items. + +import { + type Action, + action, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + paste, +} from "@backslash/api"; +import { + ago, + backlinkCounts, + basename, + CONFIG_PATHS, + type DailyConfig, + dailyNotePath, + dirname, + firstLine, + type Frontmatter, + linkTargets, + mergeVaults, + noteAliases, + noteTags, + obsidianUri, + openUri, + parseDailyConfig, + parseVaults, + previewMarkdown, + skipDir, + splitFrontmatter, + stripMd, + type Vault, + wordCount, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +export const tildify = (p: string) => (HOME && p.startsWith(`${HOME}/`) ? `~${p.slice(HOME.length)}` : p); + +export async function loadVaults(): Promise { + const lists = await Promise.all(CONFIG_PATHS.map(async (rel) => { + try { + return parseVaults(await Deno.readTextFile(`${HOME}/${rel}`)); + } catch { + return []; + } + })); + // Vaults that were deleted or moved stay in obsidian.json: keep the ones on disk. + const all = mergeVaults(lists); + const present = await Promise.all(all.map((v) => Deno.stat(v.path).then((s) => s.isDirectory, () => false))); + return all.filter((_, i) => present[i]); +} + +export interface NoteFile { + vault: Vault; + /** Relative to the vault, with `.md`. */ + path: string; + title: string; + modified: number; + created: number; + size: number; + body: string; + frontmatter: Frontmatter; + tags: string[]; + aliases: string[]; + links: string[]; + backlinks: number; +} + +const MAX_FILE = 2 * 1024 * 1024; +const MAX_NOTES = 20_000; + +async function walk(root: string, rel = "", out: string[] = [], depth = 0): Promise { + if (depth > 24 || out.length >= MAX_NOTES) return out; + let entries: Deno.DirEntry[]; + try { + entries = await Array.fromAsync(Deno.readDir(rel ? `${root}/${rel}` : root)); + } catch { + return out; + } + for (const e of entries) { + const path = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory && !skipDir(e.name)) await walk(root, path, out, depth + 1); + else if (e.isFile && /\.md$/i.test(e.name)) out.push(path); + } + return out; +} + +// One index per vault for the life of the command: searching as you type +// must not read thousands of files on each keystroke. +const cache = new Map }>(); + +export function indexVault(vault: Vault, maxAge = 60_000): Promise { + const hit = cache.get(vault.path); + if (hit && Date.now() - hit.at < maxAge) return hit.notes; + const notes = (async () => { + const paths = await walk(vault.path); + const out: NoteFile[] = []; + for (let i = 0; i < paths.length; i += 64) { + const batch = await Promise.all( + paths.slice(i, i + 64).map(async (path) => { + const full = `${vault.path}/${path}`; + try { + const stat = await Deno.stat(full); + const text = stat.size > MAX_FILE ? "" : await Deno.readTextFile(full); + const { frontmatter, body } = splitFrontmatter(text); + return { + vault, + path, + title: basename(stripMd(path)), + modified: stat.mtime?.getTime() ?? 0, + created: stat.birthtime?.getTime() ?? 0, + size: stat.size, + body, + frontmatter, + tags: noteTags(frontmatter, body), + aliases: noteAliases(frontmatter), + links: linkTargets(body), + backlinks: 0, + } satisfies NoteFile; + } catch { + return undefined; + } + }), + ); + for (const n of batch) if (n) out.push(n); + } + const counts = backlinkCounts(out); + for (const n of out) n.backlinks = counts.get(n.path) ?? 0; + return out; + })(); + cache.set(vault.path, { at: Date.now(), notes }); + notes.catch(() => cache.delete(vault.path)); + return notes; +} + +export async function dailyConfig(vault: Vault): Promise { + const text = await Deno.readTextFile(`${vault.path}/.obsidian/daily-notes.json`).catch(() => undefined); + return parseDailyConfig(text); +} + +export async function todayNote(vault: Vault, now = new Date()): Promise<{ path: string; exists: boolean }> { + const path = dailyNotePath(await dailyConfig(vault), now); + const exists = await Deno.stat(`${vault.path}/${path}`).then(() => true, () => false); + return { path, exists }; +} + +// --- Items ------------------------------------------------------------------- + +const when = (ms: number) => new Date(ms).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }); + +/** Frontmatter lines worth showing, besides tags and aliases. */ +function frontmatterLines(fm: Frontmatter): Metadata[] { + return Object.entries(fm.fields) + .filter(([k]) => !/^(tags?|aliases?|cssclass(es)?|position)$/i.test(k)) + .slice(0, 6) + .map(([k, v]) => + Array.isArray(v) ? meta.tags(k, v.slice(0, 6)) : meta.text(k, v.length > 60 ? `${v.slice(0, 60)}…` : v) + ); +} + +export function noteMetadata(n: NoteFile, several: boolean): Metadata[] { + const lines: Metadata[] = []; + if (several) lines.push(meta.text("Vault", n.vault.name, { icon: Icons.archive, color: "purple" })); + const folder = dirname(n.path); + lines.push(meta.text("Folder", folder || "/", { icon: Icons.folder })); + if (n.tags.length) { + lines.push(meta.tags("Tags", n.tags.slice(0, 8).map((t) => ({ text: `#${t}`, color: "purple" as const })))); + } + if (n.aliases.length) lines.push(meta.tags("Aliases", n.aliases.slice(0, 6))); + lines.push(...frontmatterLines(n.frontmatter)); + lines.push(meta.separator()); + lines.push(meta.text("Backlinks", n.backlinks, { icon: Icons.link, color: n.backlinks ? "purple" : undefined })); + lines.push(meta.text("Links", n.links.length, { icon: Icons.arrowRight })); + lines.push(meta.text("Words", wordCount(n.body))); + lines.push(meta.text("Modified", when(n.modified), { icon: Icons.clock })); + if (n.created && Math.abs(n.created - n.modified) > 60_000) { + lines.push(meta.text("Created", when(n.created), { icon: Icons.calendar })); + } + return lines; +} + +export function noteActions(n: NoteFile, several: boolean, o: { page?: boolean } = {}): Action[] { + const full = `${n.vault.path}/${n.path}`; + const uri = openUri(n.vault.name, n.path); + const text = n.body.trim(); + return [ + open(uri, "Open in Obsidian", { icon: Icons.external }), + ...(o.page ? [] : [action("View Note", () => notePage(n, several), { icon: Icons.eye, shortcut: "o" })]), + open(obsidianUri("open", { vault: n.vault.name, file: stripMd(n.path), paneType: "tab" }), "Open in New Tab", { + icon: Icons.window, + shortcut: "t", + section: "Open", + }), + open(full, "Open in Default App", { icon: Icons.file, section: "Open" }), + open(`${n.vault.path}/${dirname(n.path)}`.replace(/\/$/, ""), "Show in Folder", { + icon: Icons.folder, + section: "Open", + }), + copy(`[[${n.title}]]`, "Copy Wikilink", { shortcut: "c", section: "Copy" }), + copy(uri, "Copy Obsidian URL", { icon: Icons.link, section: "Copy" }), + copy(text, "Copy Content", { section: "Copy" }), + paste(text, "Paste Content", { shortcut: "v", section: "Copy" }), + copy(full, "Copy File Path", { section: "Copy" }), + ]; +} + +export function notePage(n: NoteFile, several: boolean): DetailSpec { + return detail({ + title: n.title, + markdown: `# ${n.title}\n\n${previewMarkdown(n.body, 20_000)}`, + metadata: noteMetadata(n, several), + status: `${n.vault.name} · ${wordCount(n.body)} words · ${n.backlinks} backlink${n.backlinks === 1 ? "" : "s"}`, + actions: noteActions(n, several, { page: true }), + }); +} + +export function noteItem(n: NoteFile, o: { several: boolean; section?: string; subtitle?: string }): Item { + const folder = dirname(n.path); + return { + id: `${n.vault.path}/${n.path}`, + title: n.title, + subtitle: o.subtitle ?? (firstLine(n.body) || folder || undefined), + icon: { source: Icons.note, tint: "purple" }, + section: o.section, + accessories: [ + ...n.tags.slice(0, 2).map((t) => ({ tag: `#${t}`, color: "purple" as const })), + ...(n.backlinks ? [{ icon: Icons.link, text: String(n.backlinks), tooltip: `${n.backlinks} backlinks` }] : []), + ...(o.several ? [{ text: n.vault.name }] : folder ? [{ text: folder, tooltip: "Folder" }] : []), + { text: ago(n.modified), tooltip: `Modified ${when(n.modified)}` }, + ], + detail: { + markdown: previewMarkdown(n.body, 3000), + metadata: noteMetadata(n, o.several), + }, + actions: noteActions(n, o.several), + }; +} + +export const NO_VAULT = { + title: "No Obsidian vault found", + description: `Open a vault in Obsidian once: it is listed in ${ + tildify(`${HOME}/${CONFIG_PATHS[0]}`) + } (or the Flatpak config).`, + icon: Icons.archive, +}; diff --git a/extensions/passwords/backends.ts b/extensions/passwords/backends.ts new file mode 100644 index 0000000..f1b9bb7 --- /dev/null +++ b/extensions/passwords/backends.ts @@ -0,0 +1,297 @@ +// Talks to the password manager CLIs. Secrets are fetched only when an action +// asks for one, handed to the clipboard, and forgotten. + +import { + type BackendId, + BACKENDS, + type BwStatus, + type Entry, + expandHome, + keepassWrongPassword, + opSignedOut, + parseBwFolders, + parseBwItems, + parseBwStatus, + parseKeepassList, + parseOpItems, + parsePassShow, + parsePassTree, + passEntries, + safeError, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +/** Runs a CLI; stdout trimmed. Errors carry stderr's first line, scrubbed of anything secret-looking. */ +export async function cli( + program: string, + args: string[], + o: { stdin?: string; env?: Record; signal?: AbortSignal } = {}, +): Promise { + const child = new Deno.Command(program, { + args, + env: o.env, + stdin: o.stdin === undefined ? "null" : "piped", + stdout: "piped", + stderr: "piped", + signal: o.signal, + }).spawn(); + if (o.stdin !== undefined) { + const w = child.stdin.getWriter(); + await w.write(new TextEncoder().encode(o.stdin)); + await w.close(); + } + const { code, stdout, stderr } = await child.output(); + const out = new TextDecoder().decode(stdout); + // stderr only: stdout may hold a secret even when the program fails. + if (code !== 0) throw new Error(safeError(program, new TextDecoder().decode(stderr) || `exited with ${code}`)); + return out.replace(/\r?\n$/, ""); +} + +/** Whether a program can be started (installed and on PATH). */ +async function installed(program: string): Promise { + try { + await new Deno.Command(program, { args: ["--version"], stdout: "null", stderr: "null", stdin: "null" }).output(); + return true; + } catch { + return false; + } +} + +let available: Promise | undefined; + +/** Managers installed on this computer, detected once per run. */ +export function installedBackends(): Promise { + available ??= Promise.all(BACKENDS.map(async (b) => ((await installed(b.program)) ? b.id : undefined))) + .then((ids) => ids.filter((id): id is BackendId => id !== undefined)); + return available; +} + +// --- Changes (an unlock) reload the list -------------------------------------- + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +export function nextChange(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); +} + +/** What a backend has to show: its entries, or why it can't yet. */ +export type Loaded = + | { backend: BackendId; state: "ready"; entries: Entry[] } + | { backend: BackendId; state: "locked" | "signed-out" | "unconfigured"; detail?: string } + | { backend: BackendId; state: "error"; detail: string }; + +// --- pass ------------------------------------------------------------------ + +export const passStore = () => Deno.env.get("PASSWORD_STORE_DIR") || `${HOME}/.password-store`; + +async function walkStore(root: string, rel = "", out: { path: string; modified?: number }[] = []) { + for await (const e of Deno.readDir(rel ? `${root}/${rel}` : root)) { + if (e.name.startsWith(".")) continue; + const path = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory) await walkStore(root, path, out); + else if (e.name.endsWith(".gpg")) { + const stat = await Deno.stat(`${root}/${path}`).catch(() => undefined); + out.push({ path, modified: stat?.mtime?.getTime() }); + } + } + return out; +} + +async function loadPass(signal: AbortSignal): Promise { + try { + return { backend: "pass", state: "ready", entries: passEntries(await walkStore(passStore())) }; + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + return { backend: "pass", state: "unconfigured", detail: "No password store: run `pass init `" }; + } + // A store outside ~/.password-store can't be read directly: ask pass. + const tree = await cli("pass", ["ls"], { signal }); + return { + backend: "pass", + state: "ready", + entries: passEntries(parsePassTree(tree).map((p) => ({ path: `${p}.gpg` }))), + }; + } +} + +let passOtp: Promise | undefined; +/** pass-otp is an extension: `pass otp --help` only works when it's installed. */ +export const hasPassOtp = () => passOtp ??= cli("pass", ["otp", "--help"]).then(() => true, () => false); + +// --- Bitwarden --------------------------------------------------------------- + +let bwSession: string | undefined; + +export function bwSessionKey(prefs: Record): string | undefined { + return bwSession || String(prefs.bwSession ?? "").trim() || Deno.env.get("BW_SESSION") || undefined; +} + +const bwArgs = (prefs: Record) => { + const s = bwSessionKey(prefs); + return [...(s ? ["--session", s] : []), "--nointeraction"]; +}; + +export async function bwStatus(prefs: Record): Promise<{ status: BwStatus; email?: string }> { + return parseBwStatus(await cli("bw", ["status", ...bwArgs(prefs)])); +} + +async function loadBitwarden(prefs: Record, signal: AbortSignal): Promise { + const { status, email } = await bwStatus(prefs); + if (status === "unauthenticated") return { backend: "bitwarden", state: "signed-out" }; + if (status !== "unlocked") return { backend: "bitwarden", state: "locked", detail: email }; + const [items, folders] = await Promise.all([ + cli("bw", ["list", "items", ...bwArgs(prefs)], { signal }), + cli("bw", ["list", "folders", ...bwArgs(prefs)], { signal }).catch(() => "[]"), + ]); + return { backend: "bitwarden", state: "ready", entries: parseBwItems(items, parseBwFolders(folders)) }; +} + +/** `bw unlock`, the master password passed through the environment (not the command line). */ +export async function bwUnlock(password: string): Promise { + const session = await cli("bw", ["unlock", "--raw", "--passwordenv", "BACKSLASH_BW_PASSWORD", "--nointeraction"], { + env: { BACKSLASH_BW_PASSWORD: password }, + }); + if (!session.trim()) throw new Error("Bitwarden did not return a session"); + bwSession = session.trim(); + notifyChanged(); +} + +export function bwLock() { + bwSession = undefined; + notifyChanged(); +} + +// --- 1Password --------------------------------------------------------------- + +async function loadOnePassword(signal: AbortSignal): Promise { + try { + const json = await cli("op", ["item", "list", "--format", "json"], { signal }); + return { backend: "1password", state: "ready", entries: parseOpItems(json || "[]") }; + } catch (e) { + const message = (e as Error).message; + if (opSignedOut(message)) return { backend: "1password", state: "signed-out", detail: message }; + throw e; + } +} + +// --- KeePassXC --------------------------------------------------------------- + +let keepassPassword: string | undefined; + +export function keepassDb(prefs: Record): string | undefined { + const p = String(prefs.keepassDb ?? "").trim(); + return p ? expandHome(p, HOME) : undefined; +} + +function keepassArgs(prefs: Record): string[] { + const key = String(prefs.keepassKeyFile ?? "").trim(); + return ["-q", ...(key ? ["-k", expandHome(key, HOME)] : [])]; +} + +async function keepassList(prefs: Record, password: string, signal?: AbortSignal) { + const db = keepassDb(prefs)!; + return parseKeepassList( + await cli("keepassxc-cli", ["ls", ...keepassArgs(prefs), "-R", "-f", db], { stdin: `${password}\n`, signal }), + ); +} + +async function loadKeepass(prefs: Record, signal: AbortSignal): Promise { + const db = keepassDb(prefs); + if (!db) return { backend: "keepassxc", state: "unconfigured" }; + if (keepassPassword === undefined) return { backend: "keepassxc", state: "locked", detail: db }; + try { + return { backend: "keepassxc", state: "ready", entries: await keepassList(prefs, keepassPassword, signal) }; + } catch (e) { + if (keepassWrongPassword((e as Error).message)) { + keepassPassword = undefined; + return { backend: "keepassxc", state: "locked", detail: db }; + } + throw e; + } +} + +/** Checks the password by listing the database; kept in memory only, for this run. */ +export async function keepassUnlock(prefs: Record, password: string): Promise { + const entries = await keepassList(prefs, password).catch((e) => { + throw new Error(keepassWrongPassword(e.message) ? "Wrong password or key file" : e.message); + }); + keepassPassword = password; + notifyChanged(); + return entries.length; +} + +export function keepassLock() { + keepassPassword = undefined; + notifyChanged(); +} + +// --- Loading ------------------------------------------------------------------- + +export async function load(id: BackendId, prefs: Record, signal: AbortSignal): Promise { + try { + switch (id) { + case "pass": + return await loadPass(signal); + case "bitwarden": + return await loadBitwarden(prefs, signal); + case "1password": + return await loadOnePassword(signal); + case "keepassxc": + return await loadKeepass(prefs, signal); + } + } catch (e) { + if (signal.aborted) throw e; + return { backend: id, state: "error", detail: (e as Error).message }; + } +} + +// --- Secrets, on demand ---------------------------------------------------------- + +export type Secret = "password" | "username" | "totp"; + +/** Reads one secret of an entry. Never logged, never stored. */ +export async function reveal(entry: Entry, what: Secret, prefs: Record): Promise { + let value = ""; + switch (entry.backend) { + case "pass": { + if (what === "totp") value = await cli("pass", ["otp", "code", entry.ref]); + else { + const content = parsePassShow(await cli("pass", ["show", entry.ref]), entry); + value = (what === "password" ? content.password : content.username) ?? ""; + } + break; + } + case "bitwarden": + value = await cli("bw", ["get", what, entry.ref, ...bwArgs(prefs)]); + break; + case "1password": { + if (what === "totp") { + const [, vault, item] = /^op:\/\/([^/]*)\/(.+)$/.exec(entry.ref) ?? []; + value = await cli("op", ["item", "get", item, ...(vault ? ["--vault", vault] : []), "--otp"]); + } else value = await cli("op", ["read", "--no-newline", `${entry.ref}/${what}`]); + break; + } + case "keepassxc": { + if (keepassPassword === undefined) throw new Error("Unlock the KeePassXC database first"); + const db = keepassDb(prefs)!; + const args = what === "totp" + ? ["show", ...keepassArgs(prefs), "-t", db, entry.ref] + : ["show", ...keepassArgs(prefs), "-a", what === "password" ? "Password" : "UserName", db, entry.ref]; + value = await cli("keepassxc-cli", args, { stdin: `${keepassPassword}\n` }); + break; + } + } + value = value.replace(/\r?\n$/, ""); + if (!value) throw new Error(`No ${what === "totp" ? "one-time code" : what} in “${entry.title}”`); + return value; +} diff --git a/extensions/passwords/backslash.toml b/extensions/passwords/backslash.toml new file mode 100644 index 0000000..0a43846 --- /dev/null +++ b/extensions/passwords/backslash.toml @@ -0,0 +1,55 @@ +[extension] +id = "passwords" +title = "Passwords" +description = "Copy passwords, usernames and one-time codes from pass, Bitwarden, 1Password and KeePassXC" +version = "1.0.0" +author = "Backslash" +icon = "bs-key" +license = "MIT" + +# Only the managers' own command-line tools are run; secrets are read when you +# copy them and never stored or shown. ~/.password-store is read to list pass +# entries without decrypting anything. +[permissions] +run = ["pass", "bw", "op", "keepassxc-cli"] +read = ["~/.password-store"] +env = ["HOME", "PASSWORD_STORE_DIR", "BW_SESSION"] + +[[commands]] +name = "search" +title = "Search Passwords" +description = "Enter copies the password, Ctrl+Shift+C the username, Ctrl+T the one-time code" +icon = "bs-key" +keywords = [ + "password", + "passwords", + "login", + "otp", + "totp", + "2fa", + "pass", + "bitwarden", + "1password", + "keepass", + "keepassxc", + "mot de passe", + "mots de passe", + "identifiant", + "code", +] + +[[preferences]] +name = "bwSession" +title = "Bitwarden session" +description = "Optional: the output of `bw unlock --raw`, to stay unlocked. Without it you type your master password once per use." +type = "secret" + +[[preferences]] +name = "keepassDb" +title = "KeePassXC database" +description = "Path of your .kdbx file, e.g. ~/Passwords.kdbx. Its password is asked each time, never saved." + +[[preferences]] +name = "keepassKeyFile" +title = "KeePassXC key file" +description = "Optional key file of the database" diff --git a/extensions/passwords/commands/search.ts b/extensions/passwords/commands/search.ts new file mode 100644 index 0000000..7e70ca1 --- /dev/null +++ b/extensions/passwords/commands/search.ts @@ -0,0 +1,81 @@ +import { type Context, Icons, type Item, list, open } from "@backslash/api"; +import { hasPassOtp, installedBackends, load, type Loaded, nextChange } from "../backends.ts"; +import { type BackendId, sortEntries } from "../lib.ts"; +import { backendOf, entryItem, stateItem } from "../ui.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + const ids = await installedBackends(); + const several = ids.length > 1; + return list({ + placeholder: "Search passwords by name, username or website…", + filter: several + ? { options: [{ value: "all", title: "All" }, ...ids.map((id) => ({ value: id, title: backendOf(id).name }))] } + : undefined, + empty: ids.length ? { title: "No matching entry", icon: Icons.key } : { + title: "No password manager found", + description: "Install pass, the Bitwarden CLI (bw), the 1Password CLI (op) or KeePassXC (keepassxc-cli)", + icon: Icons.key, + }, + actions: ids.length ? [] : [open("https://www.passwordstore.org", "About pass")], + status: () => footer, + // Each manager shows up as soon as it answers (Bitwarden takes a few seconds); + // unlocking one reloads the list. + async *items({ filter, preferences, signal }) { + const shown: BackendId[] = filter && filter !== "all" ? ids.filter((id) => id === filter) : ids; + const many = shown.length > 1; + const passOtp = shown.includes("pass") ? await hasPassOtp() : false; + while (!signal.aborted) { + const results = new Map(); + const render = (): Item[] => { + const items: Item[] = []; + let count = 0; + for (const id of shown) { + const r = results.get(id); + if (!r) continue; + if (r.state !== "ready") { + items.push(stateItem(r, { ...ctx, preferences }, many)); + continue; + } + count += r.entries.length; + for (const e of sortEntries(r.entries)) { + items.push( + entryItem(e, { + prefs: preferences, + several: many, + passOtp, + section: e.favorite ? "Favorites" : many ? backendOf(id).name : undefined, + }), + ); + } + } + const waiting = shown.filter((id) => !results.has(id)).map((id) => backendOf(id).name); + const states = shown.map((id) => results.get(id)).filter((r) => r && r.state !== "ready") + .map((r) => + `${backendOf(r!.backend).name} ${r!.state === "error" ? "failed" : r!.state.replace("-", " ")}` + ); + footer = [ + `${count} entr${count === 1 ? "y" : "ies"}`, + ...states, + ...(waiting.length ? [`loading ${waiting.join(", ")}…`] : []), + ].join(" · "); + // Favorites first, whatever the manager. + return [...items.filter((i) => i.section === "Favorites"), ...items.filter((i) => i.section !== "Favorites")]; + }; + const pending = new Map(shown.map((id) => [id, load(id, preferences, signal).then((r) => results.set(id, r))])); + // Fast managers answer together (no flicker), slow ones (Bitwarden) follow. + await Promise.race([Promise.all(pending.values()), new Promise((r) => setTimeout(r, 400))]); + for (const id of [...pending.keys()]) if (results.has(id)) pending.delete(id); + yield render(); + while (pending.size) { + const done = await Promise.race([...pending].map(([id, p]) => p.then(() => id))); + pending.delete(done); + yield render(); + } + if (!shown.length) yield []; + await nextChange(signal); + } + }, + }); +}; diff --git a/extensions/passwords/lib.ts b/extensions/passwords/lib.ts new file mode 100644 index 0000000..3aec701 --- /dev/null +++ b/extensions/passwords/lib.ts @@ -0,0 +1,260 @@ +// Password manager CLIs: parsing what `pass`, `bw`, `op` and `keepassxc-cli` +// print. Only metadata (names, usernames, URLs) is kept; secrets are read +// on demand, copied, and never stored or displayed. + +export type BackendId = "pass" | "bitwarden" | "1password" | "keepassxc"; + +export interface Backend { + id: BackendId; + name: string; + program: string; + tint: "orange" | "blue" | "teal" | "green"; +} + +export const BACKENDS: Backend[] = [ + { id: "pass", name: "pass", program: "pass", tint: "orange" }, + { id: "bitwarden", name: "Bitwarden", program: "bw", tint: "blue" }, + { id: "1password", name: "1Password", program: "op", tint: "teal" }, + { id: "keepassxc", name: "KeePassXC", program: "keepassxc-cli", tint: "green" }, +]; + +/** One entry of any manager, without its secrets. */ +export interface Entry { + backend: BackendId; + /** What the backend needs to fetch the secrets: path, id, or `op://vault/item`. */ + ref: string; + title: string; + username?: string; + url?: string; + /** Folder, vault or group. */ + folder?: string; + /** Known to have a one-time code; undefined when only decrypting would tell. */ + totp?: boolean; + category?: string; + favorite?: boolean; + modified?: number; +} + +export function hostOf(url: string | undefined): string | undefined { + if (!url) return undefined; + try { + const u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`); + return u.hostname.replace(/^www\./, "") || undefined; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// pass (https://www.passwordstore.org) + +/** Entry names from the store's `.gpg` files (relative paths). */ +export function passEntries(files: { path: string; modified?: number }[]): Entry[] { + return files + .filter((f) => f.path.endsWith(".gpg") && !f.path.split("/").some((s) => s.startsWith("."))) + .map((f) => { + const name = f.path.replace(/\.gpg$/, ""); + const parts = name.split("/"); + const title = parts.pop()!; + const folder = parts.join("/") || undefined; + // "github.com/ada": the entry is the username, its folder the site. + const site = parts.length && /\.[a-z]{2,}$/i.test(parts[parts.length - 1]) ? parts[parts.length - 1] : undefined; + const isSite = /\.[a-z]{2,}$/i.test(title); + return { + backend: "pass" as const, + ref: name, + title, + folder, + username: site && !isSite ? title : undefined, + url: isSite ? title : site, + modified: f.modified, + }; + }) + .sort((a, b) => a.ref.localeCompare(b.ref)); +} + +/** Entry names from `pass ls` (a `tree` drawing), when the store is elsewhere. */ +export function parsePassTree(output: string): string[] { + // "│ ├── name": 4 characters per level, then the branch; tree may use no-break spaces. + // deno-lint-ignore no-control-regex + const lines = output.replace(/\x1b\[[0-9;]*m/g, "").replace(/\u00a0/g, " ").split("\n"); + const LINE = /^((?:[│|` ] {3})*)[├└|`][─-]{2} (.+)$/; + const parsed = lines.slice(1).map((l) => LINE.exec(l)).filter((m) => m !== null) + .map((m) => ({ depth: m[1].length / 4, name: m[2].trim() })); + const out: string[] = []; + const stack: string[] = []; + parsed.forEach(({ depth, name }, i) => { + stack.length = depth; + stack.push(name); + const isFolder = (parsed[i + 1]?.depth ?? -1) > depth; + if (!isFolder) out.push(stack.join("/")); + }); + return out; +} + +export interface PassContent { + password: string; + username?: string; + url?: string; + otp: boolean; +} + +/** `pass show`: the password, then `key: value` lines (login, user, url, otpauth://…). */ +export function parsePassShow(text: string, entry?: Entry): PassContent { + const [password = "", ...rest] = text.replace(/\r/g, "").split("\n"); + const field = (...names: string[]) => { + for (const line of rest) { + const m = /^([\w -]+?)\s*:\s*(.+)$/.exec(line); + if (m && names.includes(m[1].toLowerCase())) return m[2].trim(); + } + return undefined; + }; + return { + password, + username: field("login", "user", "username", "email", "e-mail") ?? entry?.username, + url: field("url", "website", "site") ?? entry?.url, + otp: rest.some((l) => l.trim().startsWith("otpauth://")), + }; +} + +// --------------------------------------------------------------------------- +// Bitwarden CLI (https://bitwarden.com/help/cli/) + +export type BwStatus = "unauthenticated" | "locked" | "unlocked" | "unknown"; + +export function parseBwStatus(json: string): { status: BwStatus; email?: string } { + try { + const s = JSON.parse(json); + const status = ["unauthenticated", "locked", "unlocked"].includes(s.status) ? s.status : "unknown"; + return { status, email: s.userEmail ?? undefined }; + } catch { + return { status: "unknown" }; + } +} + +interface BwItem { + id: string; + name: string; + type: number; + folderId?: string | null; + favorite?: boolean; + revisionDate?: string; + login?: { username?: string | null; password?: string | null; totp?: string | null; uris?: { uri?: string }[] }; +} + +/** Login items of `bw list items`; passwords and TOTP seeds are dropped here. */ +export function parseBwItems(json: string, folders: Record = {}): Entry[] { + const items = JSON.parse(json) as BwItem[]; + return items + .filter((i) => i.type === 1) + .map((i) => ({ + backend: "bitwarden" as const, + ref: i.id, + title: i.name, + username: i.login?.username || undefined, + url: i.login?.uris?.find((u) => u.uri)?.uri, + folder: i.folderId ? folders[i.folderId] : undefined, + totp: !!i.login?.totp, + favorite: !!i.favorite, + modified: i.revisionDate ? Date.parse(i.revisionDate) : undefined, + })); +} + +export function parseBwFolders(json: string): Record { + try { + const folders = JSON.parse(json) as { id: string | null; name: string }[]; + return Object.fromEntries(folders.filter((f) => f.id).map((f) => [f.id!, f.name])); + } catch { + return {}; + } +} + +// --------------------------------------------------------------------------- +// 1Password CLI (https://developer.1password.com/docs/cli/) + +interface OpItem { + id: string; + title: string; + category: string; + favorite?: boolean; + updated_at?: string; + additional_information?: string; + vault?: { id: string; name: string }; + urls?: { href: string; primary?: boolean }[]; +} + +/** `op item list --format json`; `ref` is `op:///` for `op read`. */ +export function parseOpItems(json: string): Entry[] { + const items = JSON.parse(json) as OpItem[]; + return items + .filter((i) => i.category !== "DOCUMENT") + .map((i) => { + const login = i.category === "LOGIN"; + return { + backend: "1password" as const, + ref: `op://${i.vault?.id ?? ""}/${i.id}`, + title: i.title, + username: login && i.additional_information ? i.additional_information : undefined, + url: (i.urls?.find((u) => u.primary) ?? i.urls?.[0])?.href, + folder: i.vault?.name, + category: i.category.replace(/_/g, " ").toLowerCase().replace(/^\w/, (c) => c.toUpperCase()), + favorite: !!i.favorite, + modified: i.updated_at ? Date.parse(i.updated_at) : undefined, + }; + }); +} + +export function opSignedOut(message: string): boolean { + return /not currently signed in|no accounts configured|sign ?in|authorization|session expired|unauthorized/i.test( + message, + ); +} + +// --------------------------------------------------------------------------- +// KeePassXC CLI + +/** `keepassxc-cli ls -R -f`: one path per line, groups end with "/". The recycle bin is left out. */ +export function parseKeepassList(output: string): Entry[] { + return output.split("\n") + .map((l) => l.trimEnd()) + .filter((l) => l && !l.endsWith("/") && !/^\[empty\]$/i.test(l.trim())) + .filter((l) => !/^(Recycle Bin|Corbeille|Papierkorb)\//.test(l)) + .map((path) => { + const parts = path.split("/"); + const title = parts.pop()!; + return { backend: "keepassxc" as const, ref: path, title, folder: parts.join("/") || undefined }; + }); +} + +/** `keepassxc-cli show` prints `Key: value` lines (with `-a` only the values). */ +export function parseKeepassShow(output: string): Record { + const out: Record = {}; + for (const line of output.split("\n")) { + const m = /^(\w[\w ]*?):\s?(.*)$/.exec(line); + if (m) out[m[1]] = m[2]; + } + return out; +} + +export function keepassWrongPassword(message: string): boolean { + return /invalid credentials|wrong key|HMAC mismatch|error while reading the database/i.test(message); +} + +// --------------------------------------------------------------------------- + +/** Expands `~/` with the home folder. */ +export const expandHome = (p: string, home: string) => (p.startsWith("~/") ? `${home}${p.slice(1)}` : p); + +/** Removes anything that looks like a secret from a CLI error before showing it. */ +export function safeError(program: string, stderr: string): string { + const first = stderr.split("\n").map((l) => l.trim()).find(Boolean) ?? "failed"; + const clean = first.replace(/(password|session|token)[=:]\s*\S+/gi, "$1=…").slice(0, 200); + return `${program}: ${clean}`; +} + +/** Favorites first, then by name; entries of several managers interleaved alphabetically. */ +export function sortEntries(entries: Entry[]): Entry[] { + return [...entries].sort((a, b) => + Number(!!b.favorite) - Number(!!a.favorite) || a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) + ); +} diff --git a/extensions/passwords/lib_test.ts b/extensions/passwords/lib_test.ts new file mode 100644 index 0000000..513a2c5 --- /dev/null +++ b/extensions/passwords/lib_test.ts @@ -0,0 +1,206 @@ +import { assert, assertEquals, assertRejects } from "@std/assert"; +import { + hostOf, + keepassWrongPassword, + opSignedOut, + parseBwFolders, + parseBwItems, + parseBwStatus, + parseKeepassList, + parseKeepassShow, + parseOpItems, + parsePassShow, + parsePassTree, + passEntries, + safeError, + sortEntries, +} from "./lib.ts"; + +Deno.test("pass entries from the store's files", () => { + const entries = passEntries([ + { path: "github.com/ada.gpg", modified: 1 }, + { path: "email/fastmail.com.gpg" }, + { path: "wifi.gpg" }, + { path: ".git/config" }, + { path: ".extensions/otp.bash" }, + { path: "notes.txt" }, + ]); + assertEquals(entries.map((e) => [e.ref, e.title, e.folder, e.username, e.url]), [ + ["email/fastmail.com", "fastmail.com", "email", undefined, "fastmail.com"], + ["github.com/ada", "ada", "github.com", "ada", "github.com"], + ["wifi", "wifi", undefined, undefined, undefined], + ]); +}); + +Deno.test("pass ls tree", () => { + const tree = "Password Store\n├── email\n│ └── fastmail.com\n├── github.com\n│ ├── ada\n│ └── bot\n└── wifi\n"; + assertEquals(parsePassTree(tree), ["email/fastmail.com", "github.com/ada", "github.com/bot", "wifi"]); + const nbsp = "Password Store\n└── a\n    └── b\n"; + assertEquals(parsePassTree(nbsp), ["a/b"]); + assertEquals(parsePassTree("Password Store\n"), []); +}); + +Deno.test("pass show content", () => { + const c = parsePassShow("hunter2\nLogin: ada\nURL: https://x.com\notpauth://totp/x?secret=A\n"); + assertEquals(c, { password: "hunter2", username: "ada", url: "https://x.com", otp: true }); + const fallback = parsePassShow("pw", { backend: "pass", ref: "github.com/ada", title: "ada", username: "ada" }); + assertEquals([fallback.username, fallback.otp], ["ada", false]); +}); + +Deno.test("Bitwarden status, items and folders: secrets dropped", () => { + assertEquals(parseBwStatus('{"status":"locked","userEmail":"a@b.c"}'), { status: "locked", email: "a@b.c" }); + assertEquals(parseBwStatus("garbage").status, "unknown"); + const folders = parseBwFolders('[{"id":"f1","name":"Work"},{"id":null,"name":"No Folder"}]'); + assertEquals(folders, { f1: "Work" }); + const items = parseBwItems( + JSON.stringify([ + { + id: "1", + name: "GitHub", + type: 1, + folderId: "f1", + favorite: true, + revisionDate: "2026-10-01T00:00:00Z", + login: { username: "ada", password: "SECRET", totp: "otpauth://x", uris: [{ uri: "https://github.com" }] }, + }, + { id: "2", name: "Note", type: 2 }, + ]), + folders, + ); + assertEquals(items.length, 1); + assertEquals([items[0].title, items[0].username, items[0].folder, items[0].totp, items[0].url], [ + "GitHub", + "ada", + "Work", + true, + "https://github.com", + ]); + assert(!JSON.stringify(items).includes("SECRET")); +}); + +Deno.test("1Password items", () => { + const items = parseOpItems(JSON.stringify([ + { + id: "abc", + title: "Gmail", + category: "LOGIN", + additional_information: "ada@gmail.com", + vault: { id: "v1", name: "Personal" }, + urls: [{ href: "https://accounts.google.com" }, { href: "https://mail.google.com", primary: true }], + }, + { + id: "def", + title: "API", + category: "API_CREDENTIAL", + additional_information: "token", + vault: { id: "v1", name: "P" }, + }, + { id: "ghi", title: "Scan", category: "DOCUMENT" }, + ])); + assertEquals(items.map((i) => [i.ref, i.username, i.url, i.category]), [ + ["op://v1/abc", "ada@gmail.com", "https://mail.google.com", "Login"], + ["op://v1/def", undefined, undefined, "Api credential"], + ]); + assert(opSignedOut("[ERROR] 2026/10/28 You are not currently signed in.")); + assert(!opSignedOut("[ERROR] item not found")); +}); + +Deno.test("KeePassXC list and show", () => { + const entries = parseKeepassList("Internet/\nInternet/GitHub\n [empty]\nEmail\nRecycle Bin/\nRecycle Bin/Old\n"); + assertEquals(entries.map((e) => [e.ref, e.title, e.folder]), [ + ["Internet/GitHub", "GitHub", "Internet"], + ["Email", "Email", undefined], + ]); + assertEquals(parseKeepassShow("Title: GitHub\nUserName: ada\nURL: https://github.com\nNotes: \n"), { + Title: "GitHub", + UserName: "ada", + URL: "https://github.com", + Notes: "", + }); + assert(keepassWrongPassword("Error while reading the database: Invalid credentials were provided")); +}); + +Deno.test("helpers", () => { + assertEquals(hostOf("https://www.github.com/login"), "github.com"); + assertEquals(hostOf("github.com"), "github.com"); + assertEquals(hostOf(undefined), undefined); + assertEquals(safeError("bw", "Invalid session=abc123 given\nmore"), "bw: Invalid session=… given"); + const sorted = sortEntries([ + { backend: "pass", ref: "b", title: "beta" }, + { backend: "pass", ref: "a", title: "Alpha" }, + { backend: "bitwarden", ref: "z", title: "zulu", favorite: true }, + ]); + assertEquals(sorted.map((e) => e.title), ["zulu", "Alpha", "beta"]); +}); + +// End to end against fake CLIs (testdata/bin) that behave like the real ones. +Deno.test("backends with fake CLIs", async () => { + const bin = new URL("./testdata/bin", import.meta.url).pathname; + Deno.env.set("PATH", `${bin}:${Deno.env.get("PATH")}`); + const store = await Deno.makeTempDir(); + Deno.env.set("PASSWORD_STORE_DIR", store); + Deno.env.delete("BW_SESSION"); + const b = await import("./backends.ts"); + const signal = new AbortController().signal; + const prefs: Record = { keepassDb: "/tmp/db.kdbx" }; + try { + await Deno.mkdir(`${store}/github.com`); + await Deno.writeTextFile(`${store}/github.com/ada.gpg`, ""); + assertEquals((await b.installedBackends()).sort(), ["1password", "bitwarden", "keepassxc", "pass"]); + + // pass: listed from the files, decrypted on demand. + const pass = await b.load("pass", prefs, signal); + assert(pass.state === "ready"); + assertEquals(pass.entries.map((e) => e.ref), ["github.com/ada"]); + assertEquals(await b.reveal(pass.entries[0], "password", prefs), "s3cr3t-github.com/ada"); + assertEquals(await b.reveal(pass.entries[0], "username", prefs), "ada@example.com"); + assertEquals(await b.hasPassOtp(), true); + assertEquals(await b.reveal(pass.entries[0], "totp", prefs), "123456"); + + // Bitwarden: locked until unlocked with the master password. + assertEquals((await b.load("bitwarden", prefs, signal)).state, "locked"); + await assertRejects(() => b.bwUnlock("wrong"), Error, "Invalid master password"); + await b.bwUnlock("hunter2"); + const bw = await b.load("bitwarden", prefs, signal); + assert(bw.state === "ready"); + assertEquals(bw.entries.map((e) => [e.title, e.folder, e.totp]), [["GitHub", "Work", true]]); + assertEquals(await b.reveal(bw.entries[0], "password", prefs), "pw-i1"); + assertEquals(await b.reveal(bw.entries[0], "totp", prefs), "654321"); + b.bwLock(); + assertEquals((await b.load("bitwarden", prefs, signal)).state, "locked"); + + // 1Password: op read with the item's reference. + const op = await b.load("1password", prefs, signal); + assert(op.state === "ready"); + assertEquals(await b.reveal(op.entries[0], "password", prefs), "op-secret-for-op://v1/abc/password"); + assertEquals(await b.reveal(op.entries[0], "totp", prefs), "112233"); + + // KeePassXC: the password goes on stdin and stays in memory. + assertEquals((await b.load("keepassxc", {}, signal)).state, "unconfigured"); + assertEquals((await b.load("keepassxc", prefs, signal)).state, "locked"); + await assertRejects(() => b.keepassUnlock(prefs, "nope"), Error, "Wrong password"); + assertEquals(await b.keepassUnlock(prefs, "kdbx-pass"), 3); + const kp = await b.load("keepassxc", prefs, signal); + assert(kp.state === "ready"); + assertEquals(kp.entries.map((e) => e.ref), ["Internet/GitHub", "Internet/Forum", "Email"]); + assertEquals(await b.reveal(kp.entries[0], "password", prefs), "kp-Internet/GitHub"); + assertEquals(await b.reveal(kp.entries[0], "username", prefs), "ada-Internet/GitHub"); + assertEquals(await b.reveal(kp.entries[0], "totp", prefs), "998877"); + b.keepassLock(); + await assertRejects(() => b.reveal(kp.entries[0], "password", prefs), Error, "Unlock"); + + // Items carry no secret: every secret action is a callback. + const { entryItem } = await import("./ui.ts"); + const item = entryItem(bw.entries[0], { prefs, several: true, passOtp: true }); + assertEquals(item.actions?.map((a) => a.title).slice(0, 5), [ + "Copy Password", + "Paste Password", + "Copy Username", + "Paste Username", + "Copy One-Time Code", + ]); + assert(!JSON.stringify(item).includes("pw-")); + } finally { + await Deno.remove(store, { recursive: true }); + } +}); diff --git a/extensions/passwords/testdata/bin/bw b/extensions/passwords/testdata/bin/bw new file mode 100755 index 0000000..45b9b5a --- /dev/null +++ b/extensions/passwords/testdata/bin/bw @@ -0,0 +1,26 @@ +#!/bin/sh +# Fake Bitwarden CLI: unlocked when a session is passed. +session="" +prev="" +for a in "$@"; do [ "$prev" = "--session" ] && session="$a"; prev="$a"; done +case "$1" in + --version) echo "2026.1.0" ;; + status) + if [ -n "$session" ]; then echo '{"status":"unlocked","userEmail":"ada@example.com"}' + else echo '{"status":"locked","userEmail":"ada@example.com"}'; fi ;; + unlock) + eval "pw=\$$4" + [ "$pw" = "hunter2" ] || { echo "Invalid master password." >&2; exit 1; } + echo "SESSIONKEY" ;; + list) + [ -n "$session" ] || { echo "Vault is locked." >&2; exit 1; } + if [ "$2" = "folders" ]; then echo '[{"id":"f1","name":"Work"},{"id":null,"name":"No Folder"}]' + else echo '[{"id":"i1","name":"GitHub","type":1,"folderId":"f1","favorite":true,"login":{"username":"ada","password":"pw-github","totp":"otpauth://x","uris":[{"uri":"https://github.com"}]}},{"id":"i2","name":"Card","type":3}]'; fi ;; + get) + case "$2" in + password) echo "pw-$3" ;; + username) echo "ada" ;; + totp) echo "654321" ;; + esac ;; + *) exit 1 ;; +esac diff --git a/extensions/passwords/testdata/bin/keepassxc-cli b/extensions/passwords/testdata/bin/keepassxc-cli new file mode 100755 index 0000000..14bb6f6 --- /dev/null +++ b/extensions/passwords/testdata/bin/keepassxc-cli @@ -0,0 +1,15 @@ +#!/bin/sh +# Fake keepassxc-cli: the password comes on stdin. +[ "$1" = "--version" ] && { echo "2.7.9"; exit 0; } +read -r pw +[ "$pw" = "kdbx-pass" ] || { echo "Error while reading the database: Invalid credentials were provided" >&2; exit 1; } +case "$1" in + ls) printf 'Internet/\nInternet/GitHub\nInternet/Forum\nEmail\nRecycle Bin/\nRecycle Bin/Old\n' ;; + show) + for a in "$@"; do last="$a"; done + case "$*" in + *-t*) echo "998877" ;; + *UserName*) echo "ada-$last" ;; + *) echo "kp-$last" ;; + esac ;; +esac diff --git a/extensions/passwords/testdata/bin/op b/extensions/passwords/testdata/bin/op new file mode 100755 index 0000000..386d6a5 --- /dev/null +++ b/extensions/passwords/testdata/bin/op @@ -0,0 +1,10 @@ +#!/bin/sh +# Fake 1Password CLI. +case "$1" in + --version) echo "2.30.0" ;; + item) + if [ "$2" = "list" ]; then echo '[{"id":"abc","title":"Gmail","category":"LOGIN","additional_information":"ada@gmail.com","vault":{"id":"v1","name":"Personal"},"urls":[{"href":"https://mail.google.com","primary":true}],"updated_at":"2026-10-01T10:00:00Z"}]' + else echo "112233"; fi ;; + read) echo "op-secret-for-$3" ;; + *) exit 1 ;; +esac diff --git a/extensions/passwords/testdata/bin/pass b/extensions/passwords/testdata/bin/pass new file mode 100755 index 0000000..e863407 --- /dev/null +++ b/extensions/passwords/testdata/bin/pass @@ -0,0 +1,9 @@ +#!/bin/sh +# Fake `pass` for the tests. +case "$1" in + --version) echo "v1.7.4" ;; + ls) printf 'Password Store\n├── email\n│ └── fastmail.com\n└── github.com\n └── ada\n' ;; + show) printf 's3cr3t-%s\nlogin: ada@example.com\nurl: https://github.com/login\notpauth://totp/x?secret=ABC\n' "$2" ;; + otp) [ "$2" = "--help" ] && exit 0; echo "123456" ;; + *) exit 1 ;; +esac diff --git a/extensions/passwords/ui.ts b/extensions/passwords/ui.ts new file mode 100644 index 0000000..f0ccc1a --- /dev/null +++ b/extensions/passwords/ui.ts @@ -0,0 +1,323 @@ +// Entries as launcher items. Secrets never appear in an item: actions fetch +// them when run, put them on the clipboard and close the launcher. + +import { + type Action, + action, + clipboard, + close, + type Context, + detail, + type DetailSpec, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + type Metadata, + open, + pop, + refresh, + toast, +} from "@backslash/api"; +import { + bwLock, + bwUnlock, + HOME, + keepassDb, + keepassLock, + keepassUnlock, + type Loaded, + passStore, + reveal, + type Secret, +} from "./backends.ts"; +import { type BackendId, BACKENDS, type Entry, hostOf } from "./lib.ts"; + +export const backendOf = (id: BackendId) => BACKENDS.find((b) => b.id === id)!; + +const LABEL: Record = { password: "Password", username: "Username", totp: "One-time code" }; + +function secretAction(entry: Entry, what: Secret, prefs: Record, how: "copy" | "paste", o: { + shortcut?: string; + section?: string; +} = {}): Action { + const title = `${how === "copy" ? "Copy" : "Paste"} ${what === "totp" ? "One-Time Code" : LABEL[what]}`; + return action(title, async () => { + const value = await reveal(entry, what, prefs); + if (how === "paste") { + await clipboard.paste(value); + return close(); + } + await clipboard.write(value); + return close(`${LABEL[what]} copied`); + }, { + icon: what === "totp" + ? Icons.timer + : what === "username" + ? Icons.person + : how === "paste" + ? Icons.paste + : Icons.key, + ...o, + }); +} + +export interface ItemOptions { + prefs: Record; + several: boolean; + passOtp: boolean; + section?: string; +} + +/** Whether to offer a one-time code: known for Bitwarden, possible elsewhere. */ +function offersTotp(entry: Entry, passOtp: boolean): boolean { + if (entry.backend === "pass") return passOtp; + if (entry.backend === "bitwarden") return entry.totp === true; + return true; +} + +function entryMetadata(entry: Entry, o: ItemOptions): Metadata[] { + const b = backendOf(entry.backend); + const lines: Metadata[] = [meta.tags("Manager", [{ text: b.name, color: b.tint }])]; + if (entry.username) lines.push(meta.text("Username", entry.username, { icon: Icons.person })); + else if (entry.backend === "pass" || entry.backend === "keepassxc") { + lines.push(meta.text("Username", "Read when copied", { icon: Icons.person })); + } + lines.push(meta.text("Password", "••••••••••••", { icon: Icons.lock })); + if (entry.backend === "bitwarden") { + lines.push(meta.text("One-time code", entry.totp ? "Yes" : "No", { + icon: Icons.timer, + color: entry.totp ? "green" : undefined, + })); + } else if (offersTotp(entry, o.passOtp)) { + lines.push(meta.text("One-time code", "If the entry has one", { icon: Icons.timer })); + } + const host = hostOf(entry.url); + if (entry.url && host) { + lines.push(meta.link("Website", host, /^https?:/i.test(entry.url) ? entry.url : `https://${host}`)); + } + if (entry.folder) { + const label = entry.backend === "1password" ? "Vault" : entry.backend === "keepassxc" ? "Group" : "Folder"; + lines.push(meta.text(label, entry.folder, { icon: Icons.folder })); + } + if (entry.category) lines.push(meta.text("Category", entry.category)); + if (entry.favorite) lines.push(meta.text("Favorite", "Yes", { icon: Icons.starFilled, color: "yellow" })); + if (entry.modified) { + lines.push(meta.text("Modified", new Date(entry.modified).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.clock, + })); + } + return lines; +} + +export function entryItem(entry: Entry, o: ItemOptions): Item { + const b = backendOf(entry.backend); + const host = hostOf(entry.url); + const url = entry.url && host ? (/^https?:/i.test(entry.url) ? entry.url : `https://${host}`) : undefined; + const totp = offersTotp(entry, o.passOtp); + return { + id: `${entry.backend}:${entry.ref}`, + title: entry.title, + subtitle: entry.username && entry.username !== entry.title ? entry.username : entry.folder, + icon: { source: Icons.key, tint: b.tint }, + section: o.section, + keywords: [entry.folder ?? "", entry.username ?? "", host ?? "", entry.category ?? "", b.name].filter(Boolean), + accessories: [ + ...(entry.favorite ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Favorite" }] : []), + ...(entry.totp ? [{ icon: Icons.timer, tooltip: "Has a one-time code" }] : []), + ...(host && host !== entry.title ? [{ text: host }] : []), + ...(o.several ? [{ tag: b.name, color: b.tint }] : []), + ], + detail: { metadata: entryMetadata(entry, o) }, + actions: [ + secretAction(entry, "password", o.prefs, "copy"), + secretAction(entry, "password", o.prefs, "paste", { shortcut: "v" }), + secretAction(entry, "username", o.prefs, "copy", { shortcut: "c", section: "Username" }), + secretAction(entry, "username", o.prefs, "paste", { section: "Username" }), + ...(totp + ? [secretAction(entry, "totp", o.prefs, "copy", { shortcut: "t", section: "One-Time Code" })] + : []), + ...(url ? [open(url, "Open Website", { icon: Icons.globe, shortcut: "o", section: "Website" })] : []), + ...lockActions(entry.backend), + ], + }; +} + +function lockActions(id: BackendId): Action[] { + if (id === "bitwarden") { + return [action("Lock Bitwarden", () => { + bwLock(); + return refresh(); + }, { icon: Icons.lock, section: "Manager" })]; + } + if (id === "keepassxc") { + return [action("Lock KeePassXC Database", () => { + keepassLock(); + return refresh(); + }, { icon: Icons.lock, section: "Manager" })]; + } + return []; +} + +// --- Unlocking and setup --------------------------------------------------------- + +export function bitwardenUnlockForm(email?: string): FormSpec { + return form({ + title: "Unlock Bitwarden", + submitTitle: "Unlock", + fields: [ + field.password("password", email ? `Master password for ${email}` : "Master password", { required: true }), + ], + async submit(values) { + await bwUnlock(String(values.password ?? "")); + await toast("Bitwarden unlocked", { style: "success", message: "Until the launcher closes" }); + return pop(); + }, + }); +} + +export function keepassUnlockForm(prefs: Record): FormSpec { + const db = keepassDb(prefs) ?? ""; + return form({ + title: `Unlock ${db.split("/").pop()}`, + submitTitle: "Unlock", + fields: [field.password("password", "Database password", { required: true })], + async submit(values) { + const n = await keepassUnlock(prefs, String(values.password ?? "")); + await toast(`${n} entries unlocked`, { style: "success", message: "The password is kept in memory only" }); + return pop(); + }, + }); +} + +const tilde = (p: string) => (HOME && p.startsWith(`${HOME}/`) ? `~${p.slice(HOME.length)}` : p); + +function help(title: string, markdown: string, actions: Action[] = []): DetailSpec { + return detail({ title, markdown, actions }); +} + +const BW_LOGIN = help( + "Log in to Bitwarden", + `# Log in to Bitwarden once + +The Bitwarden CLI is installed but not logged in. In a terminal: + +\`\`\`sh +bw config server https://vault.example.com # only for a self-hosted or EU server +bw login +\`\`\` + +Then come back: Backslash asks for your master password to **unlock** the vault +(\`bw unlock\`) and keeps the session in memory until the launcher closes. + +To stay unlocked longer, run \`bw unlock --raw\` and paste the session key in +**Settings → Extensions → Passwords → Bitwarden session**: it is kept in your +system keyring.`, + [open("https://bitwarden.com/help/cli/", "Bitwarden CLI Documentation")], +); + +const OP_SIGNIN = (message?: string) => + help( + "Sign in to 1Password", + `# Connect the 1Password CLI + +The \`op\` command is installed but not signed in${message ? ` (\`${message.replace(/`/g, "'")}\`)` : ""}. + +The simplest way: in the **1Password app → Settings → Developer**, turn on +**Integrate with 1Password CLI**. \`op\` then asks the app, which unlocks with +your system authentication, and Backslash works with no further setup. + +Sessions created with \`eval $(op signin)\` live in a terminal's environment and +are not seen by the launcher.`, + [open("https://developer.1password.com/docs/cli/app-integration/", "1Password CLI App Integration")], + ); + +const KEEPASS_SETUP = help( + "Choose a KeePassXC database", + `# Choose your database + +Set the path of your \`.kdbx\` file in **Settings → Extensions → Passwords → +KeePassXC database** (and a key file if you use one). + +Backslash asks for the database password when you open the list, checks it with +\`keepassxc-cli\`, and keeps it **in memory only** until the launcher closes. +It is never saved.`, +); + +/** The row shown for a manager that can't list its entries yet. */ +export function stateItem(l: Exclude, ctx: Context, several: boolean): Item { + const b = backendOf(l.backend); + const base = { + id: `state:${l.backend}`, + section: several ? b.name : "Setup", + accessories: several ? [{ tag: b.name, color: b.tint }] : [], + }; + const prefs = ctx.preferences; + if (l.state === "error") { + return { + ...base, + title: `${b.name} failed`, + subtitle: l.detail, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`\`\`\n${l.detail}\n\`\`\`` }, + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh })], + }; + } + switch (l.backend) { + case "bitwarden": + return l.state === "signed-out" + ? { + ...base, + title: "Log in to Bitwarden", + subtitle: "Run `bw login` once in a terminal", + icon: { source: Icons.person, tint: b.tint }, + actions: [action("Show How", () => BW_LOGIN, { icon: Icons.info })], + } + : { + ...base, + title: "Unlock Bitwarden", + subtitle: l.detail ? `Vault of ${l.detail} is locked` : "Your vault is locked", + icon: { source: Icons.lock, tint: b.tint }, + actions: [action("Unlock", () => bitwardenUnlockForm(l.detail), { icon: Icons.key })], + }; + case "1password": + return { + ...base, + title: "Sign in to 1Password", + subtitle: "Turn on the 1Password app's CLI integration", + icon: { source: Icons.person, tint: b.tint }, + actions: [ + action("Show How", () => OP_SIGNIN(l.detail), { icon: Icons.info }), + action("Try Again", () => refresh(), { + icon: Icons.refresh, + }), + ], + }; + case "keepassxc": + return l.state === "unconfigured" + ? { + ...base, + title: "Choose a KeePassXC database", + subtitle: "Set its path in the extension's settings", + icon: { source: Icons.settings, tint: b.tint }, + actions: [action("Show How", () => KEEPASS_SETUP, { icon: Icons.info })], + } + : { + ...base, + title: `Unlock ${String(l.detail ?? "").split("/").pop()}`, + subtitle: tilde(String(l.detail ?? "")), + icon: { source: Icons.lock, tint: b.tint }, + actions: [action("Unlock", () => keepassUnlockForm(prefs), { icon: Icons.key })], + }; + case "pass": + return { + ...base, + title: "Set up pass", + subtitle: l.detail ?? `No store in ${tilde(passStore())}`, + icon: { source: Icons.settings, tint: b.tint }, + actions: [open("https://www.passwordstore.org/#setup", "Open pass Setup Guide")], + }; + } +} diff --git a/extensions/quick-notes/backslash.toml b/extensions/quick-notes/backslash.toml new file mode 100644 index 0000000..f8997ab --- /dev/null +++ b/extensions/quick-notes/backslash.toml @@ -0,0 +1,46 @@ +[extension] +id = "quick-notes" +title = "Quick Notes" +description = "Jot, find, pin and paste notes kept as plain markdown files, ready for Obsidian, your editor or sync" +version = "1.0.0" +author = "Backslash" +icon = "bs-note" +license = "MIT" + +# Notes are .md files in a folder you choose, inside ~/Documents (default ~/Documents/Notes). +[permissions] +read = ["~/Documents"] +write = ["~/Documents"] +env = ["HOME"] + +[[commands]] +name = "notes" +title = "Search Quick Notes" +description = "Your notes, pinned first; edit, copy, paste, pin" +icon = "bs-note" +keywords = ["notes", "note", "memo", "markdown", "jot", "scratchpad", "bloc-notes", "mémo", "pense-bête"] + +[[commands]] +name = "new" +title = "New Quick Note" +description = "Write a note with a title and markdown text" +icon = "bs-plus" +keywords = ["note", "new", "create", "write", "nouvelle note", "créer", "écrire"] + +[[commands]] +name = "capture" +title = "Capture Quick Note" +description = "Save a one-line note without leaving the search bar" +icon = "bs-bolt" +mode = "no-view" +keywords = ["note", "capture", "jot", "quick", "noter", "rapide"] +[[commands.arguments]] +name = "text" +placeholder = "Call the bank about the card" +required = true + +[[preferences]] +name = "folder" +title = "Notes folder" +description = "Inside ~/Documents. Created when you save your first note." +default = "~/Documents/Notes" diff --git a/extensions/quick-notes/commands/capture.ts b/extensions/quick-notes/commands/capture.ts new file mode 100644 index 0000000..7ee423f --- /dev/null +++ b/extensions/quick-notes/commands/capture.ts @@ -0,0 +1,12 @@ +import { run } from "@backslash/api"; +import { titleFromText } from "../lib.ts"; +import { notesDir, saveNote } from "../notes.ts"; + +// "Capture Quick Note Call the bank about the card": saved at once, no form. +export default run<{ text: string }>(async (ctx) => { + const text = String(ctx.args.text ?? "").trim(); + if (!text) throw new Error("Type the note after the command"); + // The text is kept whole in the body, so copying the note gives it back. + const file = await saveNote(notesDir(ctx), { title: titleFromText(text), body: text }); + return `Saved “${file.replace(/\.md$/, "")}”`; +}); diff --git a/extensions/quick-notes/commands/new.ts b/extensions/quick-notes/commands/new.ts new file mode 100644 index 0000000..1ad2fa8 --- /dev/null +++ b/extensions/quick-notes/commands/new.ts @@ -0,0 +1,7 @@ +import type { Context } from "@backslash/api"; +import { titleFromText } from "../lib.ts"; +import { noteForm, notesDir } from "../notes.ts"; + +// Text typed before opening the command becomes the title. +export default (ctx: Context) => + noteForm(notesDir(ctx), { title: ctx.query ? titleFromText(ctx.query) : "", standalone: true }); diff --git a/extensions/quick-notes/commands/notes.ts b/extensions/quick-notes/commands/notes.ts new file mode 100644 index 0000000..d13ec21 --- /dev/null +++ b/extensions/quick-notes/commands/notes.ts @@ -0,0 +1,48 @@ +import { action, type Context, Icons, list, open } from "@backslash/api"; +import { ageSection, checklist, sortNotes, tildify, titleFromText } from "../lib.ts"; +import { HOME, live, loadNotes, noteForm, noteItem, notesDir, pinnedFiles } from "../notes.ts"; + +let total = 0; +let pins = 0; + +export default (ctx: Context) => { + const dir = notesDir(ctx); + const newNote = action("New Note", () => noteForm(dir, { title: ctx.query ? titleFromText(ctx.query) : "" }), { + icon: Icons.plus, + shortcut: "n", + }); + return list({ + placeholder: "Search notes by title, text or #tag…", + filter: { + options: [ + { value: "all", title: "All Notes" }, + { value: "pinned", title: "Pinned" }, + { value: "tasks", title: "Checklists" }, + ], + }, + empty: { + title: "No notes yet", + description: `Press Ctrl+N to write one. Notes are markdown files in ${tildify(dir, HOME)}.`, + icon: Icons.note, + }, + actions: [newNote, open(dir, "Show Notes Folder", { icon: Icons.folder })], + status: (items) => + `${items.length === total ? total : `${items.length} of ${total}`} note${total === 1 ? "" : "s"}${ + pins ? ` · ${pins} pinned` : "" + } · ${tildify(dir, HOME)}`, + // Reloads after an edit, a pin or a deletion made from this list. + items: ({ signal, filter }) => + live(async () => { + const [notes, pinned] = await Promise.all([loadNotes(dir), pinnedFiles()]); + total = notes.length; + pins = notes.filter((n) => pinned.includes(n.file)).length; + let shown = sortNotes(notes, pinned); + if (filter === "pinned") shown = shown.filter((n) => pinned.includes(n.file)); + if (filter === "tasks") shown = shown.filter((n) => checklist(n.body).total > 0); + return shown.map((n) => { + const isPinned = pinned.includes(n.file); + return noteItem(n, dir, isPinned, isPinned ? "Pinned" : ageSection(n.modified)); + }); + }, signal), + }); +}; diff --git a/extensions/quick-notes/lib.ts b/extensions/quick-notes/lib.ts new file mode 100644 index 0000000..1dca5da --- /dev/null +++ b/extensions/quick-notes/lib.ts @@ -0,0 +1,189 @@ +// Quick Notes logic: plain markdown files, one per note, named after the +// note's title, so Obsidian, an editor or a sync tool can use them as is. + +export interface Note { + /** File name, with `.md`. */ + file: string; + title: string; + /** The text without its frontmatter. */ + body: string; + /** The raw frontmatter block (with its `---` lines), kept when editing. */ + frontmatter: string; + tags: string[]; + modified: number; + created: number; +} + +/** Characters no file name should hold on Linux, Windows or Android sync targets. */ +// deno-lint-ignore no-control-regex +const FORBIDDEN = /[\/\\:*?"<>|#^\[\]\u0000-\u001f]/g; + +/** A safe file name for a title: "Groceries / week 3?" → "Groceries - week 3.md". */ +export function fileNameFor(title: string): string { + const base = title + .replace(FORBIDDEN, (c) => (c === "/" || c === "\\" || c === ":" || c === "|" ? "-" : "")) + .replace(/\s+/g, " ") + .replace(/^[.\s-]+|[.\s]+$/g, "") + .slice(0, 120) + .trim(); + return `${base || "Untitled"}.md`; +} + +/** `name.md`, or `name 2.md`, `name 3.md`… when taken (case-insensitive). */ +export function uniqueFileName(wanted: string, taken: Iterable, self?: string): string { + const lower = new Set([...taken].map((t) => t.toLowerCase())); + if (self) lower.delete(self.toLowerCase()); + if (!lower.has(wanted.toLowerCase())) return wanted; + const stem = wanted.replace(/\.md$/i, ""); + for (let i = 2;; i++) { + const candidate = `${stem} ${i}.md`; + if (!lower.has(candidate.toLowerCase())) return candidate; + } +} + +/** Splits `---\nyaml\n---\nbody`. */ +export function splitFrontmatter(text: string): { frontmatter: string; body: string } { + const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text); + if (!m) return { frontmatter: "", body: text }; + return { frontmatter: m[0], body: text.slice(m[0].length) }; +} + +/** The `tags:` of a frontmatter (inline list, block list or comma separated). */ +export function frontmatterTags(frontmatter: string): string[] { + const lines = frontmatter.split(/\r?\n/); + const i = lines.findIndex((l) => /^tags?\s*:/i.test(l)); + if (i === -1) return []; + const inline = lines[i].replace(/^tags?\s*:\s*/i, "").trim(); + if (inline) { + return inline.replace(/^\[|\]$/g, "").split(",").map((t) => t.trim().replace(/^["'#]+|["']+$/g, "")) + .filter(Boolean); + } + const out: string[] = []; + for (const l of lines.slice(i + 1)) { + const m = /^\s*-\s*(.+)$/.exec(l); + if (!m) break; + out.push(m[1].trim().replace(/^["'#]+|["']+$/g, "")); + } + return out; +} + +/** `#tags` in the text, outside code. */ +export function inlineTags(body: string): string[] { + const text = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, ""); + const out = new Set(); + for (const m of text.matchAll(/(?:^|\s)#([\p{L}\p{N}_\-/]*\p{L}[\p{L}\p{N}_\-/]*)/gu)) out.add(m[1]); + return [...out]; +} + +export function parseNote(file: string, text: string, modified = 0, created = 0): Note { + const { frontmatter, body } = splitFrontmatter(text); + const tags = [...new Set([...frontmatterTags(frontmatter), ...inlineTags(body)])]; + return { file, title: file.replace(/\.md$/i, ""), body, frontmatter, tags, modified, created: created || modified }; +} + +/** The file's text: the frontmatter kept as it was, then the body. */ +export function serialize(note: { frontmatter?: string; body: string }): string { + const body = note.body.replace(/\r\n/g, "\n").replace(/\s+$/, ""); + const fm = note.frontmatter ?? ""; + return `${fm}${body}\n`; +} + +/** The first line of meaningful text, for subtitles. */ +export function firstLine(body: string): string { + for (const line of body.split("\n")) { + const t = line.replace(/^\s*[-*] \[[ x]\]\s+|^#+\s*|^[-*>]\s+|^\d+\.\s+/i, "").replace(/[*_`]/g, "").trim(); + if (t) return t.length > 90 ? `${t.slice(0, 90)}…` : t; + } + return ""; +} + +export function wordCount(text: string): number { + return (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length; +} + +/** Checklist progress: `- [x]` done out of all `- [ ]`. */ +export function checklist(body: string): { done: number; total: number } { + const all = body.match(/^\s*[-*] \[[ xX]\]/gm) ?? []; + return { done: all.filter((l) => /\[[xX]\]/.test(l)).length, total: all.length }; +} + +/** "Today", "Yesterday", "This Week", "This Month", "Older". */ +export function ageSection(ms: number, now = new Date()): string { + const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + if (ms >= day) return "Today"; + if (ms >= day - 86_400_000) return "Yesterday"; + if (ms >= day - 6 * 86_400_000) return "This Week"; + if (ms >= day - 30 * 86_400_000) return "This Month"; + return "Older"; +} + +/** "now", "5m", "3h", "2d", or the date. */ +export function ago(ms: number, now = Date.now()): string { + const m = Math.floor((now - ms) / 60000); + if (m < 1) return "now"; + if (m < 60) return `${m}m`; + if (m < 1440) return `${Math.floor(m / 60)}h`; + if (m < 7 * 1440) return `${Math.floor(m / 1440)}d`; + return new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +/** + * How well a note matches the words typed: 0 for no match, title matches + * above body matches. Every word must be somewhere. + */ +export function matchScore(note: Note, query: string): number { + const words = fold(query).split(/\s+/).filter(Boolean); + if (!words.length) return 1; + const title = fold(note.title); + const body = fold(note.body); + const tags = note.tags.map(fold); + let score = 0; + for (const w of words) { + if (title.startsWith(w)) score += 6; + else if (title.includes(w)) score += 4; + else if (tags.some((t) => t.startsWith(w.replace(/^#/, "")))) score += 3; + else if (body.includes(w)) score += 1; + else return 0; + } + return score; +} + +export const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); + +/** Pinned first (in pin order), then the most recently modified. */ +export function sortNotes(notes: Note[], pinned: string[]): Note[] { + const rank = (n: Note) => { + const i = pinned.indexOf(n.file); + return i === -1 ? Infinity : i; + }; + return [...notes].sort((a, b) => rank(a) - rank(b) || b.modified - a.modified); +} + +/** A title from text typed in the search bar: its first line, shortened at a word. */ +export function titleFromText(text: string, max = 60): string { + const line = text.trim().split("\n")[0].replace(/^#+\s*/, "") + // #tags stay in the text, not in the file name. + .replace(/(^|\s)#[\p{L}\p{N}_\-/]+/gu, "").replace(/\s+/g, " ").trim() || "Untitled"; + if (line.length <= max) return line; + const cut = line.slice(0, max); + return (cut.lastIndexOf(" ") > 20 ? cut.slice(0, cut.lastIndexOf(" ")) : cut).trim() + "…"; +} + +/** Expands a leading `~` with `$HOME`. */ +export function expandHome(path: string, home: string): string { + if (path === "~") return home; + if (path.startsWith("~/")) return `${home}${path.slice(1)}`; + return path; +} + +/** Shortens `$HOME/…` to `~/…` for display. */ +export function tildify(path: string, home: string): string { + return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +/** The detail preview: long notes are cut so the panel stays fast. */ +export function preview(body: string, max = 4000): string { + const t = body.trim(); + if (!t) return "_Empty note._"; + return t.length > max ? `${t.slice(0, max)}\n\n…` : t; +} diff --git a/extensions/quick-notes/lib_test.ts b/extensions/quick-notes/lib_test.ts new file mode 100644 index 0000000..e5e3e3d --- /dev/null +++ b/extensions/quick-notes/lib_test.ts @@ -0,0 +1,114 @@ +import { assert, assertEquals } from "@std/assert"; +import { + ageSection, + checklist, + expandHome, + fileNameFor, + firstLine, + frontmatterTags, + inlineTags, + matchScore, + parseNote, + serialize, + sortNotes, + splitFrontmatter, + tildify, + titleFromText, + uniqueFileName, + wordCount, +} from "./lib.ts"; +import { loadNotes, noteItem, saveNote } from "./notes.ts"; + +Deno.test("file names from titles", () => { + assertEquals(fileNameFor("Groceries"), "Groceries.md"); + assertEquals(fileNameFor("Groceries / week 3?"), "Groceries - week 3.md"); + assertEquals(fileNameFor(" ..hidden "), "hidden.md"); + assertEquals(fileNameFor("a:b|c"), "a-b-c.md"); + assertEquals(fileNameFor("#tag [x]"), "tag x.md"); + assertEquals(fileNameFor(""), "Untitled.md"); + assertEquals(fileNameFor("Réunion d'équipe"), "Réunion d'équipe.md"); + assertEquals(uniqueFileName("Ideas.md", ["ideas.md", "Ideas 2.md"]), "Ideas 3.md"); + assertEquals(uniqueFileName("Ideas.md", ["Other.md"]), "Ideas.md"); + assertEquals(uniqueFileName("Ideas.md", ["Ideas.md"], "Ideas.md"), "Ideas.md"); // renaming to itself +}); + +Deno.test("frontmatter and tags", () => { + const text = '---\ntitle: X\ntags: [work, "urgent"]\n---\nHello #home and #2026-plan, not `#code` nor # heading\n'; + const { frontmatter, body } = splitFrontmatter(text); + assertEquals(frontmatter, '---\ntitle: X\ntags: [work, "urgent"]\n---\n'); + assert(body.startsWith("Hello")); + assertEquals(frontmatterTags(frontmatter), ["work", "urgent"]); + assertEquals(frontmatterTags("---\ntags:\n - a\n - '#b'\nother: 1\n---\n"), ["a", "b"]); + assertEquals(frontmatterTags("---\ntags: a, b\n---\n"), ["a", "b"]); + assertEquals(inlineTags(body), ["home", "2026-plan"]); + assertEquals(inlineTags("#123 is not a tag, #été is"), ["été"]); + const note = parseNote("Plan.md", text, 5); + assertEquals(note.title, "Plan"); + assertEquals(note.tags, ["work", "urgent", "home", "2026-plan"]); + assertEquals(note.created, 5); + assertEquals(serialize(note), text); + assertEquals(serialize({ body: "a\r\nb\n\n\n" }), "a\nb\n"); +}); + +Deno.test("subtitles, counts and checklists", () => { + assertEquals(firstLine("\n\n# Heading\ntext"), "Heading"); + assertEquals(firstLine("- [ ] **buy** milk"), "buy milk"); + assertEquals(firstLine(""), ""); + assertEquals(wordCount("Hello, world! C'est l'été."), 4); + assertEquals(checklist("- [x] a\n- [ ] b\n * [X] c\n[ ] no"), { done: 2, total: 3 }); + assertEquals(titleFromText("Call the bank"), "Call the bank"); + assertEquals(titleFromText("# Title\nbody"), "Title"); + assertEquals(titleFromText("Call the bank #todo #home"), "Call the bank"); + const long = titleFromText("This is a very long note that goes on and on about many different things", 40); + assert(long.endsWith("…") && long.length <= 41, long); +}); + +Deno.test("sections, matching and order", () => { + const now = new Date(2026, 9, 28, 15, 0); + const at = (d: number, h = 12) => new Date(2026, 9, d, h).getTime(); + assertEquals( + [at(28, 9), at(27), at(24), at(5), at(1) - 40 * 86_400_000].map((t) => ageSection(t, now)), + ["Today", "Yesterday", "This Week", "This Month", "Older"], + ); + const a = parseNote("Groceries.md", "milk, eggs #home", at(20)); + const b = parseNote("Ideas.md", "Groceries app idea", at(27)); + const c = parseNote("Work.md", "standup", at(28)); + assert(matchScore(a, "groc") > matchScore(b, "groc")); + assert(matchScore(b, "groceries app") > 0); + assertEquals(matchScore(c, "groceries"), 0); + assert(matchScore(a, "#home") > 0); + assert(matchScore(a, "Grôceries") > 0); + assertEquals(sortNotes([a, b, c], ["Groceries.md"]).map((n) => n.file), ["Groceries.md", "Work.md", "Ideas.md"]); +}); + +Deno.test("paths", () => { + assertEquals(expandHome("~/Documents/Notes", "/home/ada"), "/home/ada/Documents/Notes"); + assertEquals(expandHome("/srv/notes", "/home/ada"), "/srv/notes"); + assertEquals(tildify("/home/ada/Documents/Notes", "/home/ada"), "~/Documents/Notes"); +}); + +Deno.test("saving, renaming and loading notes on disk", async () => { + const dir = await Deno.makeTempDir(); + try { + const f1 = await saveNote(dir, { title: "Groceries", body: "- [ ] milk\n- [x] eggs" }); + assertEquals(f1, "Groceries.md"); + assertEquals(await Deno.readTextFile(`${dir}/Groceries.md`), "- [ ] milk\n- [x] eggs\n"); + const f2 = await saveNote(dir, { title: "Groceries", body: "other" }); + assertEquals(f2, "Groceries 2.md"); + // Keep the frontmatter, rename the file with the title. + await Deno.writeTextFile(`${dir}/Plan.md`, "---\ntags: [work]\n---\nold\n"); + const plan = (await loadNotes(dir)).find((n) => n.file === "Plan.md")!; + const f3 = await saveNote(dir, { title: "Plan 2027", body: "new" }, plan); + assertEquals(f3, "Plan 2027.md"); + assertEquals(await Deno.readTextFile(`${dir}/Plan 2027.md`), "---\ntags: [work]\n---\nnew\n"); + const notes = await loadNotes(dir); + assertEquals(notes.map((n) => n.file).sort(), ["Groceries 2.md", "Groceries.md", "Plan 2027.md"]); + const item = noteItem(notes.find((n) => n.file === "Groceries.md")!, dir, true, "Pinned"); + assertEquals(item.icon, { source: "bs-pin", tint: "yellow" }); + assertEquals(item.accessories?.[0].text, "1/2"); + assertEquals(item.actions?.slice(0, 3).map((a) => a.title), ["Edit Note", "Copy Note", "Paste Note"]); + assertEquals(await loadNotes(`${dir}/missing`), []); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/extensions/quick-notes/notes.ts b/extensions/quick-notes/notes.ts new file mode 100644 index 0000000..a77b769 --- /dev/null +++ b/extensions/quick-notes/notes.ts @@ -0,0 +1,288 @@ +// Reading and writing the notes folder, and the notes as launcher items. + +import { + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + type Metadata, + open, + paste, + pop, + storage, + toast, +} from "@backslash/api"; +import { + ago, + checklist, + expandHome, + fileNameFor, + firstLine, + type Note, + parseNote, + preview, + serialize, + tildify, + uniqueFileName, + wordCount, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +export const DEFAULT_FOLDER = "~/Documents/Notes"; + +export function notesDir(ctx: Pick): string { + const raw = String(ctx.preferences.folder ?? "").trim() || DEFAULT_FOLDER; + return expandHome(raw, HOME).replace(/\/+$/, ""); +} + +function denied(dir: string): Error { + return new Error( + `Quick Notes may only use folders inside ~/Documents; ${tildify(dir, HOME)} is outside. ` + + "Change the folder in Settings → Extensions → Quick Notes.", + ); +} + +export async function loadNotes(dir: string): Promise { + const notes: Note[] = []; + try { + for await (const e of Deno.readDir(dir)) { + if (!e.isFile || !/\.md$/i.test(e.name) || e.name.startsWith(".")) continue; + const path = `${dir}/${e.name}`; + try { + const [text, stat] = await Promise.all([Deno.readTextFile(path), Deno.stat(path)]); + notes.push(parseNote(e.name, text, stat.mtime?.getTime() ?? 0, stat.birthtime?.getTime() ?? 0)); + } catch { /* unreadable file: skipped */ } + } + } catch (e) { + if (e instanceof Deno.errors.NotFound) return []; + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) throw denied(dir); + throw e; + } + return notes; +} + +/** Writes a note, renaming its file when the title changed. Returns the file name. */ +export async function saveNote( + dir: string, + values: { title: string; body: string }, + previous?: Note, +): Promise { + try { + await Deno.mkdir(dir, { recursive: true }); + const taken: string[] = []; + for await (const e of Deno.readDir(dir)) taken.push(e.name); + const wanted = fileNameFor(values.title.trim() || firstLine(values.body) || "Untitled"); + const file = previous && previous.file.toLowerCase() === wanted.toLowerCase() + ? previous.file + : uniqueFileName(wanted, taken, previous?.file); + await Deno.writeTextFile(`${dir}/${file}`, serialize({ frontmatter: previous?.frontmatter, body: values.body })); + if (previous && previous.file !== file) { + await Deno.remove(`${dir}/${previous.file}`).catch(() => {}); + await renamePinned(previous.file, file); + } + notifyChanged(); + return file; + } catch (e) { + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) throw denied(dir); + throw e; + } +} + +export async function deleteNote(dir: string, note: Note): Promise { + await Deno.remove(`${dir}/${note.file}`); + await setPinned(note.file, false); + notifyChanged(); +} + +// --- Pins (kept in storage: the files stay untouched) ------------------------ + +export async function pinnedFiles(): Promise { + const p = await storage.get("pinned").catch(() => undefined); + return Array.isArray(p) ? p : []; +} + +export async function setPinned(file: string, on: boolean): Promise { + const all = (await pinnedFiles()).filter((f) => f !== file); + await storage.set("pinned", on ? [...all, file] : all); + notifyChanged(); +} + +async function renamePinned(from: string, to: string) { + const all = await pinnedFiles(); + if (all.includes(from)) await storage.set("pinned", all.map((f) => (f === from ? to : f))); +} + +// --- Live reload: views of this command share one process ------------------- + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +// --- Views ------------------------------------------------------------------- + +/** `standalone`: the form is the command itself, so saving closes the launcher instead of going back. */ +export function noteForm( + dir: string, + options: { note?: Note; title?: string; body?: string; standalone?: boolean } = {}, +): FormSpec { + const note = options.note; + return form({ + title: note ? `Edit “${note.title}”` : "New Note", + submitTitle: note ? "Save Note" : "Create Note", + fields: [ + field.text("title", "Title", { + required: true, + placeholder: "Groceries, meeting notes, ideas…", + value: note?.title ?? options.title ?? "", + }), + field.textArea("body", "Note", { + placeholder: "Markdown: **bold**, - [ ] tasks, #tags", + value: note?.body ?? options.body ?? "", + }), + ], + async submit(values) { + const title = String(values.title ?? "").trim(); + const body = String(values.body ?? ""); + const file = await saveNote(dir, { title, body }, note); + await toast(note ? "Note saved" : "Note created", { style: "success", message: file }); + return options.standalone ? close(`Saved “${file.replace(/\.md$/, "")}”`) : pop(); + }, + }); +} + +function noteMetadata(note: Note, dir: string, pinned: boolean): Metadata[] { + const tasks = checklist(note.body); + const lines: Metadata[] = []; + if (pinned) lines.push(meta.text("Pinned", "Yes", { icon: Icons.pin, color: "yellow" })); + if (note.tags.length) { + lines.push(meta.tags("Tags", note.tags.map((t) => ({ text: `#${t}`, color: "blue" as const })))); + } + if (tasks.total) { + lines.push(meta.progress("Checklist", tasks.done / tasks.total, `${tasks.done} of ${tasks.total} done`)); + } + lines.push( + meta.text("Modified", new Date(note.modified).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + ); + if (note.created && Math.abs(note.created - note.modified) > 60_000) { + lines.push(meta.text("Created", new Date(note.created).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.calendar, + })); + } + lines.push(meta.separator()); + lines.push(meta.text("Words", wordCount(note.body))); + lines.push(meta.text("Characters", note.body.trim().length)); + lines.push(meta.link("File", tildify(`${dir}/${note.file}`, HOME), `file://${dir}/${encodeURIComponent(note.file)}`)); + return lines; +} + +export function notePage(note: Note, dir: string, pinned: boolean): DetailSpec { + return detail({ + title: note.title, + markdown: `# ${note.title}\n\n${note.body.trim() || "_Empty note._"}`, + metadata: noteMetadata(note, dir, pinned), + status: `${wordCount(note.body)} words · modified ${ago(note.modified)}`, + actions: noteActions(note, dir, pinned, { page: true }), + }); +} + +export function noteActions(note: Note, dir: string, pinned: boolean, o: { page?: boolean } = {}): Action[] { + const path = `${dir}/${note.file}`; + const text = note.body.trim(); + return [ + action("Edit Note", () => noteForm(dir, { note }), { icon: Icons.text, shortcut: "e" }), + copy(text, "Copy Note", { shortcut: "c" }), + paste(text, "Paste Note", { shortcut: "v" }), + ...(o.page + ? [] + : [action("View Note", () => notePage(note, dir, pinned), { icon: Icons.eye, shortcut: "o" })]), + action(pinned ? "Unpin Note" : "Pin Note", async () => { + await setPinned(note.file, !pinned); + await toast(pinned ? "Unpinned" : "Pinned to the top", { style: "success" }); + return o.page ? pop() : undefined; + }, { icon: Icons.pin, shortcut: "p", section: "Note" }), + action("New Note", () => noteForm(dir), { icon: Icons.plus, shortcut: "n", section: "Note" }), + open(path, "Open in Default App", { icon: Icons.external, section: "Open" }), + open(dir, "Show Notes Folder", { icon: Icons.folder, section: "Open" }), + copy(note.title, "Copy Title", { section: "Copy" }), + copy(`# ${note.title}\n\n${text}`, "Copy as Markdown with Title", { section: "Copy" }), + copy(path, "Copy File Path", { section: "Copy" }), + action("Delete Note", async () => { + await deleteNote(dir, note); + await toast(`Deleted “${note.title}”`, { style: "success" }); + return o.page ? pop() : undefined; + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${note.title}”? The file is removed.`, + section: "Danger", + shortcut: "Delete", + }), + ]; +} + +/** Words of the body as search keywords, so filtering finds notes by content. */ +function contentKeywords(note: Note, max = 200): string[] { + const words = new Set(); + for (const w of note.body.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_-]{2,}/gu) ?? []) { + words.add(w); + if (words.size >= max) break; + } + return [...words]; +} + +export function noteItem(note: Note, dir: string, pinned: boolean, section?: string): Item { + const tasks = checklist(note.body); + return { + id: note.file, + title: note.title, + subtitle: firstLine(note.body) || undefined, + icon: pinned ? { source: Icons.pin, tint: "yellow" } : { source: Icons.note, tint: tasks.total ? "green" : "blue" }, + section, + keywords: [...note.tags.map((t) => `#${t}`), ...contentKeywords(note)], + accessories: [ + ...note.tags.slice(0, 2).map((t) => ({ tag: `#${t}`, color: "blue" as const })), + ...(tasks.total + ? [{ + icon: { source: tasks.done === tasks.total ? Icons.squareCheck : Icons.square, tint: "green" as const }, + text: `${tasks.done}/${tasks.total}`, + tooltip: "Checklist", + }] + : []), + { text: ago(note.modified), tooltip: new Date(note.modified).toLocaleString() }, + ], + detail: { + markdown: preview(note.body), + metadata: noteMetadata(note, dir, pinned), + }, + actions: noteActions(note, dir, pinned), + }; +} diff --git a/extensions/web-search/backslash.toml b/extensions/web-search/backslash.toml new file mode 100644 index 0000000..d50ca4e --- /dev/null +++ b/extensions/web-search/backslash.toml @@ -0,0 +1,51 @@ +[extension] +id = "web-search" +title = "Web Search" +description = "Search the web with live suggestions, 50 !bangs and your recent searches, on the engine you prefer" +version = "1.0.0" +author = "Backslash" +icon = "bs-search" +license = "MIT" + +# Suggestions only: searches open in your browser. +[permissions] +net = ["duckduckgo.com", "suggestqueries.google.com"] + +[[commands]] +name = "search" +title = "Search the Web" +description = "Live suggestions; “!yt lofi”, “!gh deno”, “!w Rust” search a site directly" +icon = "bs-search" +keywords = [ + "web", + "search", + "google", + "duckduckgo", + "ddg", + "bang", + "internet", + "recherche", + "rechercher", + "chercher", + "moteur", +] + +[[preferences]] +name = "engine" +title = "Search engine" +description = "DuckDuckGo, Google, Brave, Startpage, Kagi, Ecosia, Qwant or Bing" +default = "DuckDuckGo" + +[[preferences]] +name = "suggestions" +title = "Live suggestions" +description = "Ask DuckDuckGo (Google suggest when Google is your engine) as you type" +type = "boolean" +default = true + +[[preferences]] +name = "history" +title = "Remember recent searches" +description = "Kept in the extension's private storage on this computer" +type = "boolean" +default = true diff --git a/extensions/web-search/commands/search.ts b/extensions/web-search/commands/search.ts new file mode 100644 index 0000000..9a25458 --- /dev/null +++ b/extensions/web-search/commands/search.ts @@ -0,0 +1,255 @@ +import { + type Action, + action, + close, + copy, + Icons, + type Item, + list, + meta, + openExternal, + refresh, + storage, + submenu, +} from "@backslash/api"; +import { + ago, + asUrl, + type Bang, + BANGS, + completeBang, + type Engine, + engineOf, + ENGINES, + fill, + forget, + type HistoryEntry, + hostOf, + matchHistory, + type Parsed, + parseQuery, + parseSuggestions, + remember, + searchUrl, + type SuggestSource, + suggestUrl, +} from "../lib.ts"; + +const KEY = "history"; + +async function history(): Promise { + const h = await storage.get(KEY).catch(() => undefined); + return Array.isArray(h) ? h : []; +} + +/** Opens the URL and records the query, unless history is off. */ +function go( + url: string, + query: string, + title: string, + keep: boolean, + options: { shortcut?: string; section?: string; icon?: string } = {}, +): Action { + return action(title, async () => { + if (keep && query.trim()) await storage.set(KEY, remember(await history(), query)); + await openExternal(url); + return close(); + }, { icon: Icons.external, ...options }); +} + +/** "Search with Google, Kagi…" for the same words. */ +function otherEngines(terms: string, current: Engine, keep: boolean): Action { + return submenu( + "Search With", + ENGINES.filter((e) => e.id !== current.id).map((e) => + go(fill(e.url, terms), terms, e.name, keep, { icon: Icons.search }) + ), + { icon: Icons.search, shortcut: "e", section: "Search" }, + ); +} + +async function suggestions(source: SuggestSource, terms: string, signal: AbortSignal): Promise { + try { + const res = await fetch(suggestUrl(source, terms), { signal, headers: { Accept: "application/json" } }); + if (!res.ok) return undefined; + return parseSuggestions(await res.json(), terms); + } catch (e) { + if (signal.aborted) throw e; + console.error(`suggestions: ${e}`); + return undefined; + } +} + +function bangItem(bang: Bang, terms: string, keep: boolean, section: string): Item { + const url = terms ? fill(bang.url, terms) : bang.home; + return { + id: `bang:${bang.trigger}`, + title: terms ? `Search ${bang.name} for “${terms}”` : bang.name, + subtitle: terms ? hostOf(url) : `!${bang.trigger} · ${hostOf(bang.home)}`, + icon: { source: Icons.bolt, tint: "orange" }, + section, + keywords: [bang.trigger, ...(bang.aliases ?? []), bang.category], + accessories: [{ tag: `!${bang.trigger}`, color: "orange" }, { text: bang.category }], + detail: { + metadata: [ + meta.text("Bang", `!${bang.trigger}`, { icon: Icons.bolt, color: "orange" }), + ...(bang.aliases?.length ? [meta.tags("Also", bang.aliases.map((a) => `!${a}`))] : []), + meta.text("Site", bang.name, { icon: Icons.globe }), + meta.link("Opens", hostOf(url), url), + meta.text("Category", bang.category), + ], + }, + actions: [ + terms + ? go(url, `!${bang.trigger} ${terms}`, `Search ${bang.name}`, keep) + : go(bang.home, "", `Open ${bang.name}`, false), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`!${bang.trigger}`, "Copy Bang", { section: "Copy" }), + ], + }; +} + +let footer = ""; + +export default list({ + placeholder: "Search the web… (!g, !yt, !gh, !w and 50 other bangs)", + empty: { + title: "Type to search the web", + description: "Prefix with a !bang to search a site directly", + icon: Icons.globe, + }, + status: () => footer, + async search(query, { preferences, signal }) { + const engine = engineOf(preferences.engine); + const keep = preferences.history !== false; + const source: SuggestSource = engine.id === "google" ? "google" : "duckduckgo"; + const all = keep ? await history() : []; + const q = query.trim(); + const items: Item[] = []; + + if (!q) { + footer = `${engine.name} · ${all.length} recent search${all.length === 1 ? "" : "es"}`; + for (const h of all.slice(0, 25)) items.push(historyItem(h, engine, keep, "Recent Searches")); + if (!all.length) { + for (const t of ["g", "yt", "gh", "w", "aw", "mdn", "maps", "deepl"]) { + const bang = BANGS.find((x) => x.trigger === t)!; + items.push(bangItem(bang, "", keep, "Popular Bangs")); + } + } + return items; + } + + const parsed = parseQuery(q); + + // "!" or "!yo": complete the bang. + if (parsed.partialBang !== undefined && !parsed.bang) { + const found = completeBang(parsed.partialBang); + footer = found.length ? `${found.length} bang${found.length === 1 ? "" : "s"}` : "Unknown bang"; + for (const bang of found) items.push(bangItem(bang, parsed.terms, keep, "Bangs")); + if (!found.length && parsed.terms) { + items.push(searchItem(parsed.terms, engine, keep, `Search ${engine.name}`)); + } + return items; + } + + const url = asUrl(q); + if (url) { + items.push({ + id: "url", + title: `Open ${hostOf(url)}`, + subtitle: url, + icon: { source: Icons.link, tint: "blue" }, + section: "Address", + actions: [go(url, "", "Open in Browser", false), copy(url, "Copy URL", { shortcut: "c" })], + }); + } + + if (parsed.bang) { + items.push(bangItem(parsed.bang, parsed.terms, keep, `!${parsed.bang.trigger} · ${parsed.bang.name}`)); + } else items.push(searchItem(parsed.terms, engine, keep, url ? "Search" : engine.name)); + + const words = parsed.terms; + const suggested = preferences.suggestions === false || !words ? [] : await suggestions(source, words, signal); + for (const s of suggested ?? []) { + const target: Parsed = { terms: s, bang: parsed.bang }; + const full = parsed.bang ? `!${parsed.bang.trigger} ${s}` : s; + items.push({ + id: `s:${s}`, + title: s, + icon: { source: Icons.search, tint: "gray" }, + section: "Suggestions", + accessories: parsed.bang ? [{ tag: `!${parsed.bang.trigger}`, color: "orange" }] : [], + actions: [ + go( + searchUrl(target, engine), + full, + parsed.bang ? `Search ${parsed.bang.name}` : `Search ${engine.name}`, + keep, + ), + ...(parsed.bang ? [] : [otherEngines(s, engine, keep)]), + copy(s, "Copy Suggestion", { shortcut: "c", section: "Copy" }), + ], + }); + } + + const recent = matchHistory(all, q).filter((h) => h.query.toLowerCase() !== q.toLowerCase()).slice(0, 8); + for (const h of recent) items.push(historyItem(h, engine, keep, "Recent Searches")); + + footer = suggested === undefined + ? `${engine.name} · suggestions unavailable (offline?)` + : `${engine.name} · suggestions from ${source === "google" ? "Google" : "DuckDuckGo"}`; + return items; + }, +}); + +function searchItem(terms: string, engine: Engine, keep: boolean, section: string): Item { + const url = fill(engine.url, terms); + return { + id: "search", + title: terms, + subtitle: `Search ${engine.name}`, + icon: { source: Icons.search, tint: "blue" }, + section, + accessories: [{ text: hostOf(engine.home) }], + actions: [ + go(url, terms, `Search ${engine.name}`, keep), + otherEngines(terms, engine, keep), + copy(url, "Copy Search URL", { shortcut: "c", section: "Copy" }), + ], + }; +} + +function historyItem(h: HistoryEntry, engine: Engine, keep: boolean, section: string): Item { + const parsed = parseQuery(h.query); + const url = searchUrl(parsed, engine); + return { + id: `h:${h.query}`, + title: h.query, + subtitle: parsed.bang ? parsed.bang.name : undefined, + icon: { source: Icons.history, tint: "gray" }, + section, + accessories: [ + ...(h.count > 1 ? [{ text: `${h.count}×`, tooltip: `Searched ${h.count} times` }] : []), + { text: ago(h.at), tooltip: new Date(h.at).toLocaleString() }, + ], + actions: [ + go(url, h.query, parsed.bang ? `Search ${parsed.bang.name}` : `Search ${engine.name}`, keep), + ...(parsed.bang ? [] : [otherEngines(parsed.terms, engine, keep)]), + copy(h.query, "Copy Query", { shortcut: "c", section: "Copy" }), + action("Remove from History", async () => { + await storage.set(KEY, forget(await history(), h.query)); + return refresh(); + }, { icon: Icons.trash, destructive: true, section: "History", shortcut: "Delete" }), + action("Clear Search History", async () => { + await storage.set(KEY, []); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: "Clear all recent searches?", + section: "History", + shortcut: "Delete", + }), + ], + }; +} diff --git a/extensions/web-search/lib.ts b/extensions/web-search/lib.ts new file mode 100644 index 0000000..a064665 --- /dev/null +++ b/extensions/web-search/lib.ts @@ -0,0 +1,351 @@ +// Web search logic: engines, !bangs, suggestion parsing and search history. + +export interface Engine { + id: string; + name: string; + /** `%s` is replaced by the encoded query. */ + url: string; + home: string; +} + +export const ENGINES: Engine[] = [ + { id: "duckduckgo", name: "DuckDuckGo", url: "https://duckduckgo.com/?q=%s", home: "https://duckduckgo.com" }, + { id: "google", name: "Google", url: "https://www.google.com/search?q=%s", home: "https://www.google.com" }, + { id: "brave", name: "Brave Search", url: "https://search.brave.com/search?q=%s", home: "https://search.brave.com" }, + { + id: "startpage", + name: "Startpage", + url: "https://www.startpage.com/do/search?q=%s", + home: "https://www.startpage.com", + }, + { id: "kagi", name: "Kagi", url: "https://kagi.com/search?q=%s", home: "https://kagi.com" }, + { id: "ecosia", name: "Ecosia", url: "https://www.ecosia.org/search?q=%s", home: "https://www.ecosia.org" }, + { id: "qwant", name: "Qwant", url: "https://www.qwant.com/?q=%s", home: "https://www.qwant.com" }, + { id: "bing", name: "Bing", url: "https://www.bing.com/search?q=%s", home: "https://www.bing.com" }, +]; + +/** The engine a preference names ("Google", "brave", "kagi"); DuckDuckGo by default. */ +export function engineOf(pref: unknown): Engine { + const p = String(pref ?? "").toLowerCase().replace(/[^a-z]/g, ""); + return ENGINES.find((e) => e.id === p || e.name.toLowerCase().replace(/[^a-z]/g, "") === p) ?? + ENGINES.find((e) => p && e.id.startsWith(p)) ?? ENGINES[0]; +} + +export interface Bang { + /** Without the "!": "g", "yt". */ + trigger: string; + name: string; + url: string; + home: string; + category: string; + aliases?: string[]; +} + +const b = (trigger: string, name: string, url: string, home: string, category: string, aliases: string[] = []) => ({ + trigger, + name, + url, + home, + category, + aliases, +}); + +export const BANGS: Bang[] = [ + // Search engines + b("g", "Google", "https://www.google.com/search?q=%s", "https://www.google.com", "Search", ["google"]), + b("ddg", "DuckDuckGo", "https://duckduckgo.com/?q=%s", "https://duckduckgo.com", "Search", ["d"]), + b("brave", "Brave Search", "https://search.brave.com/search?q=%s", "https://search.brave.com", "Search"), + b("sp", "Startpage", "https://www.startpage.com/do/search?q=%s", "https://www.startpage.com", "Search"), + b("kagi", "Kagi", "https://kagi.com/search?q=%s", "https://kagi.com", "Search"), + b("b", "Bing", "https://www.bing.com/search?q=%s", "https://www.bing.com", "Search", ["bing"]), + b("img", "Google Images", "https://www.google.com/search?tbm=isch&q=%s", "https://images.google.com", "Search", [ + "gi", + "images", + ]), + b("news", "Google News", "https://news.google.com/search?q=%s", "https://news.google.com", "Search", ["gn"]), + b("scholar", "Google Scholar", "https://scholar.google.com/scholar?q=%s", "https://scholar.google.com", "Search"), + b("maps", "Google Maps", "https://www.google.com/maps/search/%s", "https://www.google.com/maps", "Maps", ["gm"]), + b("osm", "OpenStreetMap", "https://www.openstreetmap.org/search?query=%s", "https://www.openstreetmap.org", "Maps"), + // Knowledge + b( + "w", + "Wikipedia", + "https://en.wikipedia.org/wiki/Special:Search?search=%s", + "https://en.wikipedia.org", + "Knowledge", + [ + "wiki", + "wen", + ], + ), + b( + "wfr", + "Wikipédia (français)", + "https://fr.wikipedia.org/wiki/Special:Search?search=%s", + "https://fr.wikipedia.org", + "Knowledge", + ), + b( + "wde", + "Wikipedia (Deutsch)", + "https://de.wikipedia.org/wiki/Special:Search?search=%s", + "https://de.wikipedia.org", + "Knowledge", + ), + b( + "wes", + "Wikipedia (español)", + "https://es.wikipedia.org/wiki/Special:Search?search=%s", + "https://es.wikipedia.org", + "Knowledge", + ), + b( + "wt", + "Wiktionary", + "https://en.wiktionary.org/wiki/Special:Search?search=%s", + "https://en.wiktionary.org", + "Knowledge", + ), + b("wa", "Wolfram|Alpha", "https://www.wolframalpha.com/input?i=%s", "https://www.wolframalpha.com", "Knowledge"), + b("imdb", "IMDb", "https://www.imdb.com/find/?q=%s", "https://www.imdb.com", "Knowledge"), + b( + "ud", + "Urban Dictionary", + "https://www.urbandictionary.com/define.php?term=%s", + "https://www.urbandictionary.com", + "Knowledge", + ), + // Translation + b( + "deepl", + "DeepL Translator", + "https://www.deepl.com/translator#auto/en/%s", + "https://www.deepl.com/translator", + "Translation", + ), + b( + "gt", + "Google Translate", + "https://translate.google.com/?sl=auto&text=%s", + "https://translate.google.com", + "Translation", + [ + "translate", + ], + ), + b("wr", "WordReference", "https://www.wordreference.com/enfr/%s", "https://www.wordreference.com", "Translation"), + b( + "larousse", + "Larousse", + "https://www.larousse.fr/dictionnaires/francais/%s", + "https://www.larousse.fr", + "Translation", + ), + // Developers + b("gh", "GitHub", "https://github.com/search?q=%s&type=repositories", "https://github.com", "Developers", ["github"]), + b("so", "Stack Overflow", "https://stackoverflow.com/search?q=%s", "https://stackoverflow.com", "Developers"), + b( + "mdn", + "MDN Web Docs", + "https://developer.mozilla.org/en-US/search?q=%s", + "https://developer.mozilla.org", + "Developers", + ), + b("caniuse", "Can I use", "https://caniuse.com/?search=%s", "https://caniuse.com", "Developers"), + b("npm", "npm", "https://www.npmjs.com/search?q=%s", "https://www.npmjs.com", "Developers"), + b("jsr", "JSR", "https://jsr.io/packages?search=%s", "https://jsr.io", "Developers"), + b("crates", "crates.io", "https://crates.io/search?q=%s", "https://crates.io", "Developers", ["crate"]), + b("docs", "Docs.rs", "https://docs.rs/releases/search?query=%s", "https://docs.rs", "Developers", ["docsrs"]), + b("rust", "Rust std", "https://doc.rust-lang.org/std/?search=%s", "https://doc.rust-lang.org/std", "Developers"), + b("pypi", "PyPI", "https://pypi.org/search/?q=%s", "https://pypi.org", "Developers", ["pip"]), + b("py", "Python docs", "https://docs.python.org/3/search.html?q=%s", "https://docs.python.org/3", "Developers"), + b("go", "Go packages", "https://pkg.go.dev/search?q=%s", "https://pkg.go.dev", "Developers"), + b("dh", "Docker Hub", "https://hub.docker.com/search?q=%s", "https://hub.docker.com", "Developers", ["docker"]), + b("hn", "Hacker News", "https://hn.algolia.com/?q=%s", "https://news.ycombinator.com", "Developers"), + // Linux + b("aw", "ArchWiki", "https://wiki.archlinux.org/index.php?search=%s", "https://wiki.archlinux.org", "Linux", [ + "archwiki", + ]), + b("aur", "AUR", "https://aur.archlinux.org/packages?K=%s", "https://aur.archlinux.org", "Linux"), + b("archpkg", "Arch packages", "https://archlinux.org/packages/?q=%s", "https://archlinux.org/packages", "Linux", [ + "ap", + ]), + b("flathub", "Flathub", "https://flathub.org/apps/search?q=%s", "https://flathub.org", "Linux", ["fh"]), + b("nix", "Nix packages", "https://search.nixos.org/packages?query=%s", "https://search.nixos.org", "Linux", [ + "nixpkgs", + ]), + b( + "debian", + "Debian packages", + "https://packages.debian.org/search?keywords=%s", + "https://packages.debian.org", + "Linux", + [ + "deb", + ], + ), + b( + "ubuntu", + "Ubuntu packages", + "https://packages.ubuntu.com/search?keywords=%s", + "https://packages.ubuntu.com", + "Linux", + ), + // Media and social + b("yt", "YouTube", "https://www.youtube.com/results?search_query=%s", "https://www.youtube.com", "Media", [ + "youtube", + ]), + b("r", "Reddit", "https://www.reddit.com/search/?q=%s", "https://www.reddit.com", "Media", ["reddit"]), + b("x", "X (Twitter)", "https://x.com/search?q=%s", "https://x.com", "Media", ["tw", "twitter"]), + b("li", "LinkedIn", "https://www.linkedin.com/search/results/all/?keywords=%s", "https://www.linkedin.com", "Media"), + b("spotify", "Spotify", "https://open.spotify.com/search/%s", "https://open.spotify.com", "Media", ["spot"]), + // Shopping + b("amazon", "Amazon", "https://www.amazon.com/s?k=%s", "https://www.amazon.com", "Shopping", ["a"]), + b("afr", "Amazon.fr", "https://www.amazon.fr/s?k=%s", "https://www.amazon.fr", "Shopping", ["amazonfr"]), + b("ebay", "eBay", "https://www.ebay.com/sch/i.html?_nkw=%s", "https://www.ebay.com", "Shopping"), + b("lbc", "leboncoin", "https://www.leboncoin.fr/recherche?text=%s", "https://www.leboncoin.fr", "Shopping"), +]; + +/** The bang a trigger names, "!tw" finding X through its alias. */ +export function findBang(trigger: string, bangs = BANGS): Bang | undefined { + const t = trigger.toLowerCase(); + return bangs.find((x) => x.trigger === t) ?? bangs.find((x) => x.aliases?.includes(t)); +} + +/** Bangs whose trigger or name starts with a prefix, for "!y" → YouTube. */ +export function completeBang(prefix: string, bangs = BANGS): Bang[] { + const p = prefix.toLowerCase(); + const score = (x: Bang) => + x.trigger === p ? 0 : x.trigger.startsWith(p) ? 1 : x.aliases?.some((a) => a.startsWith(p)) ? 2 : 3; + return bangs + .filter((x) => !p || score(x) < 3 || x.name.toLowerCase().split(/[\s(|.]+/).some((w) => w.startsWith(p))) + .sort((x, y) => score(x) - score(y) || x.trigger.length - y.trigger.length); +} + +export interface Parsed { + /** The words searched, without the bang. */ + terms: string; + bang?: Bang; + /** A "!xyz" typed that is no known bang (or still being typed). */ + partialBang?: string; +} + +/** "!yt lofi", "lofi !yt", "lofi yt!" all search YouTube for "lofi". */ +export function parseQuery(query: string, bangs = BANGS): Parsed { + const words = query.trim().split(/\s+/).filter(Boolean); + for (let i = 0; i < words.length; i++) { + const m = /^!(\S+)$/.exec(words[i]) ?? /^(\S+)!$/.exec(words[i]); + if (!m) continue; + const terms = [...words.slice(0, i), ...words.slice(i + 1)].join(" "); + const bang = findBang(m[1], bangs); + if (bang) return { terms, bang }; + if (words[i].startsWith("!")) return { terms, partialBang: m[1] }; + } + if (words.length === 1 && words[0] === "!") return { terms: "", partialBang: "" }; + return { terms: words.join(" ") }; +} + +/** `%s` replaced by the query, encoded the way the site expects (spaces as `+` in query strings). */ +export function fill(template: string, terms: string): string { + const inQuery = template.indexOf("?") !== -1 && template.indexOf("?") < template.indexOf("%s"); + const encoded = inQuery ? encodeURIComponent(terms).replace(/%20/g, "+") : encodeURIComponent(terms); + return template.replace("%s", encoded); +} + +export function searchUrl(parsed: Parsed, engine: Engine): string { + if (parsed.bang) return parsed.terms ? fill(parsed.bang.url, parsed.terms) : parsed.bang.home; + return fill(engine.url, parsed.terms); +} + +/** A query that is an address: "example.com", "https://deno.com/x", "localhost:8000". */ +export function asUrl(query: string): string | undefined { + const q = query.trim(); + if (!q || /\s/.test(q)) return undefined; + if (/^https?:\/\/\S+$/i.test(q)) return q; + if (/^localhost(:\d+)?(\/\S*)?$/i.test(q)) return `http://${q}`; + if (/^([a-z0-9-]+\.)+[a-z]{2,24}(:\d+)?(\/\S*)?$/i.test(q) && !/^\d+(\.\d+)+$/.test(q)) return `https://${q}`; + return undefined; +} + +// --------------------------------------------------------------------------- +// Suggestions + +export type SuggestSource = "duckduckgo" | "google"; + +export function suggestUrl(source: SuggestSource, terms: string): string { + const q = encodeURIComponent(terms); + return source === "google" + ? `https://suggestqueries.google.com/complete/search?client=firefox&q=${q}` + : `https://duckduckgo.com/ac/?q=${q}&kl=wt-wt`; +} + +/** Reads DuckDuckGo's `[{ phrase }]`, or the OpenSearch `["q", ["a", "b"]]` of Google and DuckDuckGo's `type=list`. */ +export function parseSuggestions(body: unknown, terms: string, max = 8): string[] { + let out: string[] = []; + if (Array.isArray(body)) { + if (body.length >= 2 && typeof body[0] === "string" && Array.isArray(body[1])) { + out = body[1].filter((s): s is string => typeof s === "string"); + } else { + out = body + .map((x) => (x && typeof x === "object" && "phrase" in x ? String((x as { phrase: unknown }).phrase) : "")) + .filter(Boolean); + } + } + const seen = new Set([terms.trim().toLowerCase()]); + return out.filter((s) => { + const k = s.trim().toLowerCase(); + if (!k || seen.has(k)) return false; + seen.add(k); + return true; + }).slice(0, max); +} + +// --------------------------------------------------------------------------- +// History + +// A type, not an interface: storage only takes JSON-compatible types. +export type HistoryEntry = { + /** The query as typed, bang included. */ + query: string; + at: number; + count: number; +}; + +export const HISTORY_MAX = 60; + +/** Moves the query to the top, counting how often it was searched. */ +export function remember(history: HistoryEntry[], query: string, now = Date.now()): HistoryEntry[] { + const q = query.trim(); + if (!q) return history; + const old = history.find((h) => h.query.toLowerCase() === q.toLowerCase()); + const rest = history.filter((h) => h !== old); + return [{ query: q, at: now, count: (old?.count ?? 0) + 1 }, ...rest].slice(0, HISTORY_MAX); +} + +export function forget(history: HistoryEntry[], query: string): HistoryEntry[] { + return history.filter((h) => h.query !== query); +} + +/** Entries matching every word typed, most recent first. */ +export function matchHistory(history: HistoryEntry[], query: string): HistoryEntry[] { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + return history.filter((h) => words.every((w) => h.query.toLowerCase().includes(w))); +} + +/** "now", "5m", "3h", "2d", "4w". */ +export function ago(ms: number, now = Date.now()): string { + const m = Math.floor((now - ms) / 60000); + if (m < 1) return "now"; + if (m < 60) return `${m}m`; + if (m < 1440) return `${Math.floor(m / 60)}h`; + if (m < 10080) return `${Math.floor(m / 1440)}d`; + return `${Math.floor(m / 10080)}w`; +} + +export const hostOf = (url: string) => { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +}; diff --git a/extensions/web-search/lib_test.ts b/extensions/web-search/lib_test.ts new file mode 100644 index 0000000..7025f15 --- /dev/null +++ b/extensions/web-search/lib_test.ts @@ -0,0 +1,128 @@ +import { assert, assertEquals } from "@std/assert"; +import { + ago, + asUrl, + BANGS, + completeBang, + engineOf, + fill, + findBang, + forget, + HISTORY_MAX, + type HistoryEntry, + matchHistory, + parseQuery, + parseSuggestions, + remember, + searchUrl, + suggestUrl, +} from "./lib.ts"; + +Deno.test("bang table: unique triggers and aliases, every URL has %s and is https", () => { + assert(BANGS.length >= 40); + const seen = new Set(); + for (const b of BANGS) { + for (const t of [b.trigger, ...(b.aliases ?? [])]) { + assert(!seen.has(t), `!${t} is defined twice`); + seen.add(t); + } + assert(b.url.includes("%s"), b.trigger); + assert(b.url.startsWith("https://") && b.home.startsWith("https://"), b.trigger); + } + for (const t of ["g", "ddg", "w", "yt", "gh", "so", "mdn", "aw", "npm", "crates", "maps", "img", "r", "amazon"]) { + assert(findBang(t), `!${t} missing`); + } +}); + +Deno.test("parsing bangs anywhere in the query", () => { + assertEquals(parseQuery("!yt lofi beats").bang?.name, "YouTube"); + assertEquals(parseQuery("!yt lofi beats").terms, "lofi beats"); + assertEquals(parseQuery("lofi beats !yt").bang?.trigger, "yt"); + assertEquals(parseQuery("rust yt!").bang?.trigger, "yt"); + assertEquals(parseQuery("!tw deno").bang?.name, "X (Twitter)"); + assertEquals(parseQuery("!GH deno").bang?.trigger, "gh"); + assertEquals(parseQuery("!wfr Paris").bang?.url.includes("fr.wikipedia.org"), true); + assertEquals(parseQuery("!aw systemd").bang?.name, "ArchWiki"); + assertEquals(parseQuery("hello world"), { terms: "hello world" }); + assertEquals(parseQuery("!zzz hello"), { terms: "hello", partialBang: "zzz" }); + assertEquals(parseQuery("!"), { terms: "", partialBang: "" }); + assertEquals(parseQuery("wow!").bang, undefined); // not a bang: plain text + assertEquals(parseQuery("wow!").terms, "wow!"); +}); + +Deno.test("completing bangs", () => { + assertEquals(completeBang("y")[0].trigger, "yt"); + assertEquals(completeBang("w")[0].trigger, "w"); + assertEquals(completeBang("crat")[0].trigger, "crates"); + assertEquals(completeBang("").length, BANGS.length); + assert(completeBang("wiki").some((b) => b.trigger === "wfr")); // by name + assertEquals(completeBang("qqqq"), []); +}); + +Deno.test("URLs and encoding", () => { + const ddg = engineOf("DuckDuckGo"); + assertEquals(searchUrl(parseQuery("c++ & rust"), ddg), "https://duckduckgo.com/?q=c%2B%2B+%26+rust"); + assertEquals( + searchUrl(parseQuery("!w Ada Lovelace"), ddg), + "https://en.wikipedia.org/wiki/Special:Search?search=Ada+Lovelace", + ); + assertEquals(searchUrl(parseQuery("!maps Eiffel Tower"), ddg), "https://www.google.com/maps/search/Eiffel%20Tower"); + assertEquals(searchUrl(parseQuery("!yt"), ddg), "https://www.youtube.com"); + assertEquals(fill("https://x.com/search?q=%s", "été"), "https://x.com/search?q=%C3%A9t%C3%A9"); + assertEquals(searchUrl(parseQuery("deno"), engineOf("kagi")), "https://kagi.com/search?q=deno"); +}); + +Deno.test("engine preference", () => { + assertEquals(engineOf("Google").id, "google"); + assertEquals(engineOf("brave").id, "brave"); + assertEquals(engineOf("Brave Search").id, "brave"); + assertEquals(engineOf("start").id, "startpage"); + assertEquals(engineOf("").id, "duckduckgo"); + assertEquals(engineOf(undefined).id, "duckduckgo"); + assertEquals(engineOf("nope").id, "duckduckgo"); +}); + +Deno.test("addresses are opened, not searched", () => { + assertEquals(asUrl("deno.com"), "https://deno.com"); + assertEquals(asUrl("https://deno.com/x?y=1"), "https://deno.com/x?y=1"); + assertEquals(asUrl("localhost:8000/app"), "http://localhost:8000/app"); + assertEquals(asUrl("docs.rs/serde"), "https://docs.rs/serde"); + assertEquals(asUrl("hello world"), undefined); + assertEquals(asUrl("3.14"), undefined); + assertEquals(asUrl("node.js tutorial"), undefined); +}); + +Deno.test("suggestions from DuckDuckGo and Google", () => { + assertEquals( + parseSuggestions([{ phrase: "deno" }, { phrase: "deno deploy" }, { phrase: "deno fresh" }], "deno"), + ["deno deploy", "deno fresh"], + ); + assertEquals(parseSuggestions(["rust", ["rust", "rust lang", "Rust Lang", "rustup"]], "rust"), [ + "rust lang", + "rustup", + ]); + assertEquals(parseSuggestions({ error: 1 }, "x"), []); + assertEquals(parseSuggestions([{ phrase: "a" }, { phrase: "b" }, { phrase: "c" }], "", 2), ["a", "b"]); + assert(suggestUrl("duckduckgo", "a b").startsWith("https://duckduckgo.com/ac/?q=a%20b")); + assert(suggestUrl("google", "a").startsWith("https://suggestqueries.google.com/complete/search?client=firefox")); +}); + +Deno.test("history: recent first, counted, case-insensitive, capped", () => { + let h: HistoryEntry[] = []; + h = remember(h, "deno", 1); + h = remember(h, "rust", 2); + h = remember(h, "Deno", 3); + assertEquals(h.map((e) => [e.query, e.count, e.at]), [["Deno", 2, 3], ["rust", 1, 2]]); + assertEquals(remember(h, " ", 4), h); + assertEquals(forget(h, "rust").map((e) => e.query), ["Deno"]); + assertEquals(matchHistory([...h, { query: "!yt rust talks", at: 0, count: 1 }], "rust").map((e) => e.query), [ + "rust", + "!yt rust talks", + ]); + for (let i = 0; i < 100; i++) h = remember(h, `q${i}`, i); + assertEquals(h.length, HISTORY_MAX); + assertEquals(ago(0, 30_000), "now"); + assertEquals(ago(0, 5 * 60_000), "5m"); + assertEquals(ago(0, 3 * 3_600_000), "3h"); + assertEquals(ago(0, 2 * 86_400_000), "2d"); +}); diff --git a/extensions/world-clock/add.ts b/extensions/world-clock/add.ts new file mode 100644 index 0000000..a7673f5 --- /dev/null +++ b/extensions/world-clock/add.ts @@ -0,0 +1,53 @@ +// The "Add City" list, shared by its command and the clocks' action. + +import { action, Icons, type Item, list, type ListSpec, refresh, replace, toast, type View } from "@backslash/api"; +import { CITIES, cityId, formatDiff, formatTime, formatUtcOffset, localZone, zoneOffset } from "./lib.ts"; +import { addCity, clockIcon, region, savedIds } from "./clocks.ts"; + +const REGIONS = ["Europe", "Americas", "Asia", "Africa", "Oceania", "Islands", "Other"]; + +/** `back` builds the view to return to after adding (the clocks); without it the list stays open. */ +export function addCityList(options: { back?: () => View } = {}): ListSpec { + return list({ + title: "Add City", + placeholder: "Search 230 cities by name, country, airport or zone…", + empty: { title: "No such city", description: "Try a country or a capital nearby", icon: Icons.globe }, + status: (items) => `${items.length} cit${items.length === 1 ? "y" : "ies"}`, + async items({ preferences }) { + const hour12 = preferences.hour12 === true; + const ids = await savedIds(); + const now = Date.now(); + const local = localZone(); + const sorted = [...CITIES].sort((a, b) => + REGIONS.indexOf(region(a.zone)) - REGIONS.indexOf(region(b.zone)) || a.name.localeCompare(b.name) + ); + return sorted.map((c): Item => { + const saved = ids.includes(cityId(c)); + const diff = zoneOffset(c.zone, now) - zoneOffset(local, now); + return { + id: cityId(c), + title: c.name, + subtitle: c.country, + icon: clockIcon(c.zone, now), + section: region(c.zone), + keywords: [c.country, c.zone, ...c.aliases], + accessories: [ + ...(saved ? [{ icon: { source: Icons.check, tint: "green" as const }, tooltip: "In your clocks" }] : []), + { text: formatDiff(diff), tooltip: formatUtcOffset(zoneOffset(c.zone, now)) }, + { text: formatTime(c.zone, now, hour12) }, + ], + actions: [ + action(saved ? "Already in Your Clocks" : "Add to Clocks", async () => { + if (!(await addCity(c))) { + await toast(`${c.name} is already in your clocks`); + return; + } + await toast(`${c.name} added`, { style: "success" }); + return options.back ? replace(options.back()) : refresh(); + }, { icon: Icons.plus }), + ], + }; + }); + }, + }); +} diff --git a/extensions/world-clock/backslash.toml b/extensions/world-clock/backslash.toml new file mode 100644 index 0000000..22018f4 --- /dev/null +++ b/extensions/world-clock/backslash.toml @@ -0,0 +1,44 @@ +[extension] +id = "world-clock" +title = "World Clock" +description = "The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris”" +version = "1.0.0" +author = "Backslash" +icon = "bs-clock" +license = "MIT" + +# Everything is computed locally with the system's time zone data: no permission. + +[[commands]] +name = "clocks" +title = "World Clock" +description = "Your cities' time; type “15:00 paris to tokyo” to convert" +icon = "bs-clock" +keywords = [ + "time", + "clock", + "timezone", + "time zone", + "convert", + "utc", + "heure", + "fuseau", + "fuseau horaire", + "horloge", + "décalage", + "convertir", +] + +[[commands]] +name = "add-city" +title = "Add World Clock City" +description = "Pick a city among 230 to keep its time at hand" +icon = "bs-globe" +keywords = ["city", "timezone", "add", "ville", "ajouter", "fuseau", "heure"] + +[[preferences]] +name = "hour12" +title = "12-hour clock" +description = "Show 3:00 PM instead of 15:00" +type = "boolean" +default = false diff --git a/extensions/world-clock/cities.ts b/extensions/world-clock/cities.ts new file mode 100644 index 0000000..d54cf1b --- /dev/null +++ b/extensions/world-clock/cities.ts @@ -0,0 +1,253 @@ +// Major cities and their IANA time zones. `aliases` are extra names people +// type: abbreviations (NYC, SF), local names (München), airport codes. + +export interface City { + name: string; + country: string; + zone: string; + aliases: string[]; +} + +// name | country | zone | aliases (comma separated) +const RAW = ` +New York|United States|America/New_York|ny,nyc,new york city,manhattan,jfk,east coast,est,edt +Los Angeles|United States|America/Los_Angeles|la,lax,hollywood,pst,pdt,pacific +San Francisco|United States|America/Los_Angeles|sf,sfo,bay area,silicon valley +Seattle|United States|America/Los_Angeles|sea +San Diego|United States|America/Los_Angeles| +Las Vegas|United States|America/Los_Angeles|vegas,las +Portland|United States|America/Los_Angeles|pdx +Chicago|United States|America/Chicago|chi,ord,cst,cdt,central +Houston|United States|America/Chicago|hou +Dallas|United States|America/Chicago|dfw +Austin|United States|America/Chicago|aus +New Orleans|United States|America/Chicago|nola +Minneapolis|United States|America/Chicago|msp +Denver|United States|America/Denver|den,mst,mdt,mountain +Salt Lake City|United States|America/Denver|slc +Phoenix|United States|America/Phoenix|phx,arizona +Boston|United States|America/New_York|bos +Washington|United States|America/New_York|dc,washington dc,was +Miami|United States|America/New_York|mia +Atlanta|United States|America/New_York|atl +Philadelphia|United States|America/New_York|philly,phl +Detroit|United States|America/Detroit|dtw +Pittsburgh|United States|America/New_York|pit +Orlando|United States|America/New_York|mco +Anchorage|United States|America/Anchorage|alaska,akst +Honolulu|United States|Pacific/Honolulu|hawaii,hnl,hst +Toronto|Canada|America/Toronto|yyz +Montreal|Canada|America/Toronto|montréal,yul +Ottawa|Canada|America/Toronto| +Quebec City|Canada|America/Toronto|québec +Vancouver|Canada|America/Vancouver|yvr +Calgary|Canada|America/Edmonton|yyc +Edmonton|Canada|America/Edmonton| +Winnipeg|Canada|America/Winnipeg| +Halifax|Canada|America/Halifax|atlantic +St. John's|Canada|America/St_Johns|newfoundland +Mexico City|Mexico|America/Mexico_City|cdmx,mexico +Guadalajara|Mexico|America/Mexico_City| +Monterrey|Mexico|America/Monterrey| +Cancún|Mexico|America/Cancun|cancun +Tijuana|Mexico|America/Tijuana| +Guatemala City|Guatemala|America/Guatemala|guatemala +San José|Costa Rica|America/Costa_Rica|san jose,costa rica +Panama City|Panama|America/Panama|panama +Havana|Cuba|America/Havana|la habana,cuba +Kingston|Jamaica|America/Jamaica|jamaica +Santo Domingo|Dominican Republic|America/Santo_Domingo| +San Juan|Puerto Rico|America/Puerto_Rico|puerto rico +Bogotá|Colombia|America/Bogota|bogota,colombia +Medellín|Colombia|America/Bogota|medellin +Caracas|Venezuela|America/Caracas|venezuela +Quito|Ecuador|America/Guayaquil|ecuador +Lima|Peru|America/Lima|peru +La Paz|Bolivia|America/La_Paz|bolivia +Santiago|Chile|America/Santiago|chile +Buenos Aires|Argentina|America/Argentina/Buenos_Aires|argentina,bsas +Montevideo|Uruguay|America/Montevideo|uruguay +Asunción|Paraguay|America/Asuncion|asuncion,paraguay +São Paulo|Brazil|America/Sao_Paulo|sao paulo,sp,brazil,brasil +Rio de Janeiro|Brazil|America/Sao_Paulo|rio +Brasília|Brazil|America/Sao_Paulo|brasilia +Manaus|Brazil|America/Manaus| +Recife|Brazil|America/Recife| +London|United Kingdom|Europe/London|ldn,uk,england,lhr,gmt,bst,londres +Manchester|United Kingdom|Europe/London| +Edinburgh|United Kingdom|Europe/London|scotland +Dublin|Ireland|Europe/Dublin|ireland,irlande +Lisbon|Portugal|Europe/Lisbon|lisboa,lisbonne,portugal +Porto|Portugal|Europe/Lisbon| +Madrid|Spain|Europe/Madrid|spain,espagne,españa +Barcelona|Spain|Europe/Madrid|barcelone +Valencia|Spain|Europe/Madrid| +Seville|Spain|Europe/Madrid|sevilla,séville +Canary Islands|Spain|Atlantic/Canary|las palmas,tenerife,canaries +Paris|France|Europe/Paris|france,cdg,cet,cest +Lyon|France|Europe/Paris| +Marseille|France|Europe/Paris| +Toulouse|France|Europe/Paris| +Nice|France|Europe/Paris| +Bordeaux|France|Europe/Paris| +Nantes|France|Europe/Paris| +Lille|France|Europe/Paris| +Strasbourg|France|Europe/Paris| +Brussels|Belgium|Europe/Brussels|bruxelles,belgium,belgique +Amsterdam|Netherlands|Europe/Amsterdam|netherlands,holland,pays-bas,ams +Rotterdam|Netherlands|Europe/Amsterdam| +Luxembourg|Luxembourg|Europe/Luxembourg| +Berlin|Germany|Europe/Berlin|germany,allemagne,deutschland +Munich|Germany|Europe/Berlin|münchen,munchen +Frankfurt|Germany|Europe/Berlin|fra +Hamburg|Germany|Europe/Berlin| +Cologne|Germany|Europe/Berlin|köln,koln +Zurich|Switzerland|Europe/Zurich|zürich,switzerland,suisse,zrh +Geneva|Switzerland|Europe/Zurich|genève,geneve +Vienna|Austria|Europe/Vienna|wien,vienne,austria,autriche +Rome|Italy|Europe/Rome|roma,italy,italie +Milan|Italy|Europe/Rome|milano +Naples|Italy|Europe/Rome|napoli +Venice|Italy|Europe/Rome|venezia,venise +Florence|Italy|Europe/Rome|firenze +Monaco|Monaco|Europe/Monaco|monte carlo +Copenhagen|Denmark|Europe/Copenhagen|københavn,copenhague,denmark +Oslo|Norway|Europe/Oslo|norway,norvège +Stockholm|Sweden|Europe/Stockholm|sweden,suède +Helsinki|Finland|Europe/Helsinki|finland,finlande +Reykjavík|Iceland|Atlantic/Reykjavik|reykjavik,iceland,islande +Warsaw|Poland|Europe/Warsaw|warszawa,varsovie,poland,pologne +Kraków|Poland|Europe/Warsaw|krakow,cracovie +Prague|Czechia|Europe/Prague|praha,czech republic +Budapest|Hungary|Europe/Budapest|hungary,hongrie +Bratislava|Slovakia|Europe/Bratislava| +Ljubljana|Slovenia|Europe/Ljubljana| +Zagreb|Croatia|Europe/Zagreb|croatia +Belgrade|Serbia|Europe/Belgrade|beograd,serbia +Sarajevo|Bosnia and Herzegovina|Europe/Sarajevo| +Bucharest|Romania|Europe/Bucharest|bucurești,bucarest,romania +Sofia|Bulgaria|Europe/Sofia|bulgaria +Athens|Greece|Europe/Athens|athína,athènes,greece,grèce +Istanbul|Turkey|Europe/Istanbul|constantinople,türkiye,turkey,ist +Ankara|Turkey|Europe/Istanbul| +Kyiv|Ukraine|Europe/Kyiv|kiev,ukraine +Minsk|Belarus|Europe/Minsk| +Vilnius|Lithuania|Europe/Vilnius|lithuania +Riga|Latvia|Europe/Riga|latvia +Tallinn|Estonia|Europe/Tallinn|estonia +Chișinău|Moldova|Europe/Chisinau|chisinau +Moscow|Russia|Europe/Moscow|moskva,moscou,msk +Saint Petersburg|Russia|Europe/Moscow|st petersburg,leningrad +Yekaterinburg|Russia|Asia/Yekaterinburg| +Novosibirsk|Russia|Asia/Novosibirsk| +Vladivostok|Russia|Asia/Vladivostok| +Tbilisi|Georgia|Asia/Tbilisi|georgia +Yerevan|Armenia|Asia/Yerevan|armenia +Baku|Azerbaijan|Asia/Baku|azerbaijan +Cairo|Egypt|Africa/Cairo|le caire,egypt,égypte +Alexandria|Egypt|Africa/Cairo| +Casablanca|Morocco|Africa/Casablanca|morocco,maroc +Marrakesh|Morocco|Africa/Casablanca|marrakech +Rabat|Morocco|Africa/Casablanca| +Algiers|Algeria|Africa/Algiers|alger,algeria,algérie +Tunis|Tunisia|Africa/Tunis|tunisia,tunisie +Tripoli|Libya|Africa/Tripoli|libya +Dakar|Senegal|Africa/Dakar|senegal,sénégal +Abidjan|Côte d'Ivoire|Africa/Abidjan|ivory coast +Accra|Ghana|Africa/Accra|ghana +Lagos|Nigeria|Africa/Lagos|nigeria +Abuja|Nigeria|Africa/Lagos| +Kinshasa|DR Congo|Africa/Kinshasa| +Luanda|Angola|Africa/Luanda|angola +Addis Ababa|Ethiopia|Africa/Addis_Ababa|ethiopia +Nairobi|Kenya|Africa/Nairobi|kenya +Dar es Salaam|Tanzania|Africa/Dar_es_Salaam|tanzania +Kampala|Uganda|Africa/Kampala|uganda +Kigali|Rwanda|Africa/Kigali|rwanda +Johannesburg|South Africa|Africa/Johannesburg|joburg,jnb,south africa +Cape Town|South Africa|Africa/Johannesburg|le cap +Antananarivo|Madagascar|Indian/Antananarivo|madagascar +Port Louis|Mauritius|Indian/Mauritius|mauritius,maurice +Saint-Denis|Réunion|Indian/Reunion|reunion,la réunion +Jerusalem|Israel|Asia/Jerusalem|israel +Tel Aviv|Israel|Asia/Jerusalem|tlv +Beirut|Lebanon|Asia/Beirut|beyrouth,lebanon +Amman|Jordan|Asia/Amman|jordan +Damascus|Syria|Asia/Damascus|damas +Baghdad|Iraq|Asia/Baghdad|iraq +Riyadh|Saudi Arabia|Asia/Riyadh|saudi arabia,ksa +Jeddah|Saudi Arabia|Asia/Riyadh| +Kuwait City|Kuwait|Asia/Kuwait|kuwait +Doha|Qatar|Asia/Qatar|qatar +Manama|Bahrain|Asia/Bahrain|bahrain +Dubai|United Arab Emirates|Asia/Dubai|uae,dxb +Abu Dhabi|United Arab Emirates|Asia/Dubai| +Muscat|Oman|Asia/Muscat|oman +Tehran|Iran|Asia/Tehran|téhéran,iran +Kabul|Afghanistan|Asia/Kabul|afghanistan +Karachi|Pakistan|Asia/Karachi|pakistan +Lahore|Pakistan|Asia/Karachi| +Islamabad|Pakistan|Asia/Karachi| +Tashkent|Uzbekistan|Asia/Tashkent|uzbekistan +Almaty|Kazakhstan|Asia/Almaty|kazakhstan +Astana|Kazakhstan|Asia/Almaty| +Mumbai|India|Asia/Kolkata|bombay,india,inde,ist,bom +Delhi|India|Asia/Kolkata|new delhi,del +Bangalore|India|Asia/Kolkata|bengaluru,blr +Chennai|India|Asia/Kolkata|madras +Hyderabad|India|Asia/Kolkata| +Kolkata|India|Asia/Kolkata|calcutta +Pune|India|Asia/Kolkata| +Colombo|Sri Lanka|Asia/Colombo|sri lanka +Kathmandu|Nepal|Asia/Kathmandu|nepal,népal +Dhaka|Bangladesh|Asia/Dhaka|bangladesh +Yangon|Myanmar|Asia/Yangon|rangoon,myanmar +Bangkok|Thailand|Asia/Bangkok|thailand,thaïlande,bkk +Chiang Mai|Thailand|Asia/Bangkok| +Phuket|Thailand|Asia/Bangkok| +Hanoi|Vietnam|Asia/Ho_Chi_Minh|hà nội +Ho Chi Minh City|Vietnam|Asia/Ho_Chi_Minh|saigon,hcmc,vietnam +Phnom Penh|Cambodia|Asia/Phnom_Penh|cambodia +Kuala Lumpur|Malaysia|Asia/Kuala_Lumpur|kl,malaysia +Singapore|Singapore|Asia/Singapore|sg,sin,singapour +Jakarta|Indonesia|Asia/Jakarta|indonesia +Bali|Indonesia|Asia/Makassar|denpasar +Manila|Philippines|Asia/Manila|philippines +Hong Kong|China|Asia/Hong_Kong|hk,hkg +Macau|China|Asia/Macau|macao +Shanghai|China|Asia/Shanghai|china,chine,pvg +Beijing|China|Asia/Shanghai|peking,pékin,pek +Shenzhen|China|Asia/Shanghai| +Guangzhou|China|Asia/Shanghai|canton +Chengdu|China|Asia/Shanghai| +Taipei|Taiwan|Asia/Taipei|taiwan +Seoul|South Korea|Asia/Seoul|korea,corée,icn,kst +Busan|South Korea|Asia/Seoul| +Pyongyang|North Korea|Asia/Pyongyang| +Tokyo|Japan|Asia/Tokyo|japan,japon,jst,tyo,hnd,nrt +Osaka|Japan|Asia/Tokyo| +Kyoto|Japan|Asia/Tokyo| +Sapporo|Japan|Asia/Tokyo| +Ulaanbaatar|Mongolia|Asia/Ulaanbaatar|mongolia +Sydney|Australia|Australia/Sydney|australia,australie,syd,aest +Melbourne|Australia|Australia/Melbourne|mel +Brisbane|Australia|Australia/Brisbane|bne +Perth|Australia|Australia/Perth|per,awst +Adelaide|Australia|Australia/Adelaide|acst +Darwin|Australia|Australia/Darwin| +Hobart|Australia|Australia/Hobart|tasmania +Canberra|Australia|Australia/Sydney| +Auckland|New Zealand|Pacific/Auckland|new zealand,nz,akl,nouvelle-zélande +Wellington|New Zealand|Pacific/Auckland| +Fiji|Fiji|Pacific/Fiji|suva +Nouméa|New Caledonia|Pacific/Noumea|noumea,nouvelle-calédonie +Papeete|French Polynesia|Pacific/Tahiti|tahiti +Guam|Guam|Pacific/Guam| +Apia|Samoa|Pacific/Apia|samoa +UTC|Coordinated Universal Time|UTC|gmt,zulu,utc,z +`; + +export const CITIES: City[] = RAW.trim().split("\n").map((line) => { + const [name, country, zone, aliases] = line.split("|"); + return { name, country, zone, aliases: aliases ? aliases.split(",").map((a) => a.trim()).filter(Boolean) : [] }; +}); diff --git a/extensions/world-clock/clocks.ts b/extensions/world-clock/clocks.ts new file mode 100644 index 0000000..e0694e6 --- /dev/null +++ b/extensions/world-clock/clocks.ts @@ -0,0 +1,200 @@ +// Storage of the user's clocks and their rendering as launcher items. + +import { type Action, action, copy, Icons, type Item, meta, refresh, storage, toast } from "@backslash/api"; +import { + CITIES, + type City, + cityId, + dayDiff, + dayLabel, + DEFAULT_PLACES, + formatDate, + formatDiff, + formatTime, + formatUtcOffset, + isDaytime, + isWorkingHours, + LOCAL_ID, + localZone, + move, + nextTransition, + type Place, + placesFromIds, + shortDate, + wallClock, + zoneAbbreviation, + zoneOffset, +} from "./lib.ts"; + +const KEY = "places"; + +export async function savedIds(): Promise { + const ids = await storage.get(KEY).catch(() => undefined); + return Array.isArray(ids) ? ids : [...DEFAULT_PLACES]; +} + +export async function saveIds(ids: string[]): Promise { + await storage.set(KEY, ids); +} + +export async function savedPlaces(): Promise { + return placesFromIds(await savedIds()); +} + +export async function addCity(city: City): Promise { + const ids = await savedIds(); + const id = cityId(city); + if (ids.includes(id)) return false; + await saveIds([...ids, id]); + return true; +} + +/** The name of the local clock: the city of its zone ("Paris"), else the zone. */ +export function localName(zone = localZone()): string { + const city = CITIES.find((c) => c.zone === zone); + return city?.name ?? (zone.split("/").pop() ?? zone).replace(/_/g, " "); +} + +export const displayName = (p: Place) => (p.local ? localName(p.zone) : p.name); + +/** Region of a zone, for sections: "Europe", "Americas"… */ +export function region(zone: string): string { + const head = zone.split("/")[0]; + switch (head) { + case "America": + return "Americas"; + case "Europe": + return "Europe"; + case "Asia": + return "Asia"; + case "Africa": + return "Africa"; + case "Australia": + case "Pacific": + return "Oceania"; + case "Atlantic": + case "Indian": + return "Islands"; + default: + return "Other"; + } +} + +export function clockIcon(zone: string, at: number) { + const day = isDaytime(wallClock(zone, at).hour); + return day ? { source: Icons.sun, tint: "yellow" as const } : { source: Icons.moon, tint: "blue" as const }; +} + +export interface ClockOptions { + at: number; + hour12: boolean; + section?: string; + ids?: string[]; + /** The clock is part of a conversion: no reordering actions. */ + converted?: boolean; + extraActions?: Action[]; +} + +export function clockItem(place: Place, o: ClockOptions): Item { + const local = localZone(); + const at = o.at; + const w = wallClock(place.zone, at); + const time = formatTime(place.zone, at, o.hour12); + const diffMin = zoneOffset(place.zone, at) - zoneOffset(local, at); + const days = dayDiff(place.zone, local, at); + const when = dayLabel(days); + const abbr = zoneAbbreviation(place.zone, at); + const name = displayName(place); + const dst = nextTransition(place.zone, at); + const working = isWorkingHours(w); + const ids = o.ids ?? []; + const index = ids.indexOf(place.id); + + const actions: Action[] = [ + ...(o.extraActions ?? []), + copy(`${time} in ${name}`, "Copy Time", { shortcut: "c" }), + copy(`${time} ${name} (${formatUtcOffset(zoneOffset(place.zone, at))})`, "Copy Time with Offset", { + section: "Copy", + }), + copy(new Date(at).toISOString(), "Copy as ISO 8601 (UTC)", { section: "Copy" }), + copy(place.zone, "Copy Time Zone", { section: "Copy" }), + ]; + if (!o.converted && index !== -1) { + const reorder = (where: "top" | "up" | "down" | "bottom", title: string, icon: string, shortcut?: string) => + action(title, async () => { + await saveIds(move(ids, place.id, where)); + return refresh(); + }, { icon, section: "Order", shortcut }); + if (index > 0) { + actions.push(reorder("top", "Move to Top", Icons.arrowUp, "Up")); + actions.push(reorder("up", "Move Up", Icons.arrowUp, "Up")); + } + if (index < ids.length - 1) actions.push(reorder("down", "Move Down", Icons.arrowDown, "Down")); + actions.push(action("Remove Clock", async () => { + await saveIds(ids.filter((id) => id !== place.id)); + await toast(`${name} removed`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${name} from your clocks?`, + section: "Danger", + shortcut: "Delete", + })); + } + + return { + id: `${o.section ?? ""}:${place.id}`, + title: name, + subtitle: [place.local ? "Local time" : place.country, abbr].filter(Boolean).join(" · "), + icon: clockIcon(place.zone, at), + section: o.section, + keywords: [place.zone, place.country ?? "", abbr, ...(place.local ? ["local", "here"] : [])].filter(Boolean), + accessories: [ + ...(when ? [{ tag: when, color: days > 0 ? "purple" as const : "orange" as const }] : []), + ...(place.local + ? [{ tag: "Local", color: "green" as const }] + : [{ text: formatDiff(diffMin), tooltip: "vs. local time" }]), + { text: time, tooltip: formatDate(place.zone, at) }, + ], + detail: { + stats: [ + { label: "Time", value: time }, + { + label: "vs. local", + value: place.local ? "—" : formatDiff(diffMin), + color: diffMin === 0 ? undefined : "blue", + }, + { label: "UTC offset", value: formatUtcOffset(zoneOffset(place.zone, at)) }, + ], + metadata: [ + meta.text("Date", formatDate(place.zone, at), { icon: Icons.calendar }), + ...(when ? [meta.text("Day", when, { icon: Icons.calendar, color: days > 0 ? "purple" : "orange" })] : []), + meta.text("Time zone", place.zone.replace(/_/g, " "), { icon: Icons.globe }), + meta.text("Abbreviation", abbr), + ...(place.country ? [meta.text("Country", place.country)] : []), + meta.separator(), + meta.text( + "Daylight", + isDaytime(w.hour) ? "Day" : "Night", + isDaytime(w.hour) ? { icon: Icons.sun, color: "yellow" } : { icon: Icons.moon, color: "blue" }, + ), + meta.text("Office hours", working ? "Yes (9:00–18:00, Mon–Fri)" : "No", { + icon: Icons.clock, + color: working ? "green" : "gray", + }), + dst + ? meta.text( + "Next clock change", + `${shortDate(place.zone, dst.at)} (${formatDiff(dst.after - dst.before)})`, + { icon: Icons.refresh }, + ) + : meta.text("Next clock change", "None this year", { icon: Icons.refresh }), + ], + }, + actions, + }; +} + +export const isSaved = (ids: string[], c: City) => ids.includes(cityId(c)); +export { LOCAL_ID }; diff --git a/extensions/world-clock/commands/add-city.ts b/extensions/world-clock/commands/add-city.ts new file mode 100644 index 0000000..00d50d0 --- /dev/null +++ b/extensions/world-clock/commands/add-city.ts @@ -0,0 +1,3 @@ +import { addCityList } from "../add.ts"; + +export default addCityList(); diff --git a/extensions/world-clock/commands/clocks.ts b/extensions/world-clock/commands/clocks.ts new file mode 100644 index 0000000..5b9606d --- /dev/null +++ b/extensions/world-clock/commands/clocks.ts @@ -0,0 +1,140 @@ +import { action, copy, Icons, type Item, list, type ListSpec, refresh, replace, toast } from "@backslash/api"; +import { addCityList } from "../add.ts"; +import { addCity, clockItem, displayName, isSaved, savedIds } from "../clocks.ts"; +import { + dayDiff, + dayLabel, + fold, + formatDiff, + formatTime, + formatUtcOffset, + localZone, + parseConversion, + placesFromIds, + searchCities, + zoneOffset, +} from "../lib.ts"; + +let shown = 0; + +function clocksView(): ListSpec { + const addAction = action("Add City…", () => replace(addCityList({ back: clocksView })), { + icon: Icons.plus, + shortcut: "n", + section: "Clocks", + }); + return list({ + placeholder: "Filter clocks, add a city, or convert: “3pm NY in Paris”, “15:00 paris to tokyo”…", + empty: { + title: "No clock matches", + description: "Type a city to add it, or a time to convert: “9am London to Tokyo”", + icon: Icons.clock, + }, + actions: [addAction], + status: () => `${shown} clock${shown === 1 ? "" : "s"} · local ${localZone().replace(/_/g, " ")}`, + async search(query, { preferences }) { + const hour12 = preferences.hour12 === true; + const now = Date.now(); + const ids = await savedIds(); + const places = placesFromIds(ids); + shown = places.length; + const items: Item[] = []; + + const conv = parseConversion(query, now); + if (conv) { + const fromName = displayName(conv.from); + const fromTime = formatTime(conv.from.zone, conv.at, hour12); + const targets = conv.to ? [conv.to] : places.filter((p) => p.zone !== conv.from.zone || p.id !== conv.from.id); + for (const to of targets) { + const toName = displayName(to); + const toTime = formatTime(to.zone, conv.at, hour12); + const days = dayDiff(to.zone, conv.from.zone, conv.at); + const when = dayLabel(days); + const diff = zoneOffset(to.zone, conv.at) - zoneOffset(conv.from.zone, conv.at); + const sentence = `${fromTime} in ${fromName} is ${toTime}${ + when ? ` ${when.toLowerCase()}` : "" + } in ${toName}`; + items.push({ + id: `conv:${to.id}`, + title: `${toTime} in ${toName}`, + subtitle: `${fromTime} in ${fromName}`, + icon: { source: Icons.arrowRight, tint: "blue" }, + section: conv.time === "now" ? "Right Now" : "Conversion", + accessories: [ + ...(when ? [{ tag: when, color: days > 0 ? "purple" as const : "orange" as const }] : []), + { text: formatDiff(diff), tooltip: `${toName} vs. ${fromName}` }, + ], + detail: { + stats: [ + { label: fromName, value: fromTime }, + { label: toName, value: toTime, color: "blue" }, + { label: "Difference", value: formatDiff(diff) }, + ], + markdown: `**${sentence}.**`, + metadata: [], + }, + actions: [ + copy(toTime, "Copy Converted Time"), + copy(sentence, "Copy Sentence", { shortcut: "c" }), + copy(new Date(conv.at).toISOString(), "Copy as ISO 8601 (UTC)", { section: "Copy" }), + ], + }); + } + const section = conv.time === "now" ? "Your Clocks" : `At ${fromTime} in ${fromName}`; + for (const p of places) items.push(clockItem(p, { at: conv.at, hour12, section, ids, converted: true })); + return items; + } + + // Not a conversion: filter the clocks, then offer to add matching cities. + const q = fold(query); + const matching = q + ? places.filter((p) => + [displayName(p), p.country ?? "", p.zone.replace(/_/g, " "), p.local ? "local" : ""].some((s) => + fold(s).includes(q) + ) + ) + : places; + for (const p of matching) items.push(clockItem(p, { at: now, hour12, section: "Your Clocks", ids })); + + if (q.length >= 2) { + const local = localZone(); + for (const c of searchCities(q).filter((c) => !isSaved(ids, c)).slice(0, 6)) { + const diff = zoneOffset(c.zone, now) - zoneOffset(local, now); + const when = dayLabel(dayDiff(c.zone, local, now)); + items.push({ + id: `add:${c.zone}#${c.name}`, + title: c.name, + subtitle: c.country, + icon: { source: Icons.plus, tint: "green" }, + section: "Add City", + accessories: [ + ...(when ? [{ tag: when }] : []), + { text: formatDiff(diff), tooltip: formatUtcOffset(zoneOffset(c.zone, now)) }, + { text: formatTime(c.zone, now, hour12) }, + ], + actions: [ + action("Add to Clocks", async () => { + await addCity(c); + await toast(`${c.name} added`, { style: "success" }); + return refresh(); + }, { icon: Icons.plus }), + ], + }); + } + } + if (!q) { + items.push({ + id: "add-city", + title: "Add City…", + subtitle: "Search 230 cities", + icon: { source: Icons.plus, tint: "gray" }, + section: "Clocks", + actions: [addAction], + }); + } + return items; + }, + }); +} + +export default clocksView(); diff --git a/extensions/world-clock/lib.ts b/extensions/world-clock/lib.ts new file mode 100644 index 0000000..b994cd9 --- /dev/null +++ b/extensions/world-clock/lib.ts @@ -0,0 +1,361 @@ +// World clock logic: time zone arithmetic with Intl (no network, no tz +// database of our own), city lookup and "3pm NY in Paris" conversions. + +import { CITIES, type City } from "./cities.ts"; + +export type { City }; +export { CITIES }; + +/** A clock the user keeps: a city, or "local" for this computer's zone. */ +export interface Place { + id: string; + name: string; + zone: string; + country?: string; + local?: boolean; +} + +export const LOCAL_ID = "local"; + +export const localZone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +export function localPlace(zone = localZone()): Place { + return { id: LOCAL_ID, name: "Local", zone, local: true }; +} + +export const cityId = (c: City) => `${c.zone}#${c.name}`; + +export function placeOf(c: City): Place { + return { id: cityId(c), name: c.name, zone: c.zone, country: c.country }; +} + +export const DEFAULT_PLACES = [LOCAL_ID, "America/New_York#New York", "Europe/London#London", "Asia/Tokyo#Tokyo"]; + +/** Resolves stored ids (unknown ones are dropped). */ +export function placesFromIds(ids: string[], zone = localZone()): Place[] { + const out: Place[] = []; + for (const id of ids) { + if (id === LOCAL_ID) out.push(localPlace(zone)); + else { + const city = CITIES.find((c) => cityId(c) === id); + if (city) out.push(placeOf(city)); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Time zone arithmetic + +export interface Wall { + year: number; + month: number; // 1-12 + day: number; + hour: number; + minute: number; + second: number; + weekday: number; // 0 = Sunday +} + +const formatters = new Map(); + +function formatter(zone: string): Intl.DateTimeFormat { + let f = formatters.get(zone); + if (!f) { + f = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + formatters.set(zone, f); + } + return f; +} + +export function isValidZone(zone: string): boolean { + try { + formatter(zone); + return true; + } catch { + return false; + } +} + +/** The wall clock in `zone` at instant `at` (ms). */ +export function wallClock(zone: string, at: number): Wall { + const parts: Record = {}; + for (const p of formatter(zone).formatToParts(new Date(at))) { + if (p.type !== "literal") parts[p.type] = Number(p.value); + } + const hour = parts.hour === 24 ? 0 : parts.hour; + const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay(); + return { + year: parts.year, + month: parts.month, + day: parts.day, + hour, + minute: parts.minute, + second: parts.second, + weekday, + }; +} + +/** Minutes east of UTC in `zone` at instant `at`: Paris in summer is 120. */ +export function zoneOffset(zone: string, at: number): number { + const w = wallClock(zone, at); + const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second); + return Math.round((asUtc - Math.floor(at / 1000) * 1000) / 60000); +} + +/** + * The instant at which the wall clock in `zone` shows this date and time. + * In a DST gap the time is moved forward, in an overlap the first one wins. + */ +export function zonedToInstant(zone: string, year: number, month: number, day: number, hour = 0, minute = 0): number { + const civil = Date.UTC(year, month - 1, day, hour, minute); + const before = zoneOffset(zone, civil - 86_400_000), after = zoneOffset(zone, civil + 86_400_000); + const valid = [before, after] + .map((off) => civil - off * 60000) + .filter((t) => civil - zoneOffset(zone, t) * 60000 === t); + if (valid.length) return Math.min(...valid); + // In a gap: the offset from before the jump moves the time forward (2:30 → 3:30). + return civil - before * 60000; +} + +/** "UTC+2", "UTC−5", "UTC+5:30". */ +export function formatUtcOffset(minutes: number): string { + if (minutes === 0) return "UTC"; + const sign = minutes > 0 ? "+" : "−"; + const abs = Math.abs(minutes); + const h = Math.floor(abs / 60), m = abs % 60; + return `UTC${sign}${h}${m ? `:${String(m).padStart(2, "0")}` : ""}`; +} + +/** Difference with the local clock: "+9h", "−5h", "+5h30", "Same time". */ +export function formatDiff(minutes: number): string { + if (minutes === 0) return "Same time"; + const sign = minutes > 0 ? "+" : "−"; + const abs = Math.abs(minutes); + const h = Math.floor(abs / 60), m = abs % 60; + return `${sign}${h}h${m ? String(m).padStart(2, "0") : ""}`; +} + +/** Calendar days between the wall dates of two zones at `at`: Tokyo vs Paris late evening is 1. */ +export function dayDiff(zone: string, reference: string, at: number): number { + const a = wallClock(zone, at), b = wallClock(reference, at); + return Math.round((Date.UTC(a.year, a.month - 1, a.day) - Date.UTC(b.year, b.month - 1, b.day)) / 86_400_000); +} + +export function dayLabel(diff: number): string | undefined { + if (diff === 0) return undefined; + if (diff === 1) return "Tomorrow"; + if (diff === -1) return "Yesterday"; + return diff > 0 ? `+${diff} days` : `${diff} days`; +} + +/** Daytime is 6:00 to 18:59, for the sun and moon icons. */ +export const isDaytime = (hour: number) => hour >= 6 && hour < 19; + +/** Office hours, for "a good time to call". */ +export const isWorkingHours = (w: Wall) => w.weekday >= 1 && w.weekday <= 5 && w.hour >= 9 && w.hour < 18; + +export function formatTime(zone: string, at: number, hour12 = false): string { + return new Date(at).toLocaleTimeString(hour12 ? "en-US" : "en-GB", { + timeZone: zone, + hour: hour12 ? "numeric" : "2-digit", + minute: "2-digit", + hour12, + }); +} + +export function formatDate(zone: string, at: number): string { + return new Date(at).toLocaleDateString("en-GB", { + timeZone: zone, + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }); +} + +export function shortDate(zone: string, at: number): string { + return new Date(at).toLocaleDateString("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short" }); +} + +/** The zone's abbreviation where Intl knows one ("CET", "EST"), else its offset. */ +export function zoneAbbreviation(zone: string, at: number): string { + const part = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "short" }) + .formatToParts(new Date(at)).find((p) => p.type === "timeZoneName")?.value ?? ""; + return part.replace(/^GMT(?=[+-])/, "UTC"); +} + +/** The next change of UTC offset (DST) within a year, if any. */ +export function nextTransition(zone: string, from: number): { at: number; before: number; after: number } | undefined { + const start = zoneOffset(zone, from); + const HOUR = 3_600_000; + let lo = from; + for (let t = from + 24 * HOUR; t <= from + 366 * 24 * HOUR; t += 24 * HOUR) { + const off = zoneOffset(zone, t); + if (off !== start) { + let hi = t; + while (hi - lo > 60_000) { + const mid = Math.floor((lo + hi) / 2 / 60_000) * 60_000; + if (zoneOffset(zone, mid) === start) lo = mid; + else hi = mid; + } + return { at: hi, before: start, after: off }; + } + lo = t; + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Cities + +/** Lowercase without accents: "São Paulo" → "sao paulo". */ +export const fold = (s: string) => + s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[.'’]/g, "").replace(/\s+/g, " ").trim(); + +const HERE = new Set(["local", "here", "me", "ici", "home", "my time"]); + +/** Cities matching a query, best first: exact name, alias, prefix, then substring. */ +export function searchCities(query: string, cities = CITIES): City[] { + const q = fold(query); + if (!q) return [...cities]; + const scored: [number, City][] = []; + for (const c of cities) { + const name = fold(c.name); + const aliases = c.aliases.map(fold); + const zone = fold(c.zone.replace(/_/g, " ")); + let score = 0; + if (name === q) score = 100; + else if (aliases.includes(q)) score = 90; + else if (name.startsWith(q)) score = 80; + else if (fold(c.country) === q) score = 70; + else if (aliases.some((a) => a.startsWith(q))) score = 60; + else if (name.split(/[\s-]/).some((w) => w.startsWith(q))) score = 50; + else if (fold(c.country).startsWith(q)) score = 40; + else if (zone.includes(q)) score = 30; + else if (name.includes(q)) score = 20; + if (score) scored.push([score, c]); + } + return scored.sort((a, b) => b[0] - a[0]).map(([, c]) => c); +} + +/** The place a piece of text names: a city, an alias, a zone id, or "local". */ +export function resolvePlace(text: string, zone = localZone()): Place | undefined { + const q = fold(text); + if (!q) return undefined; + if (HERE.has(q)) return localPlace(zone); + const exactZone = CITIES.find((c) => fold(c.zone) === q); + if (exactZone) return placeOf(exactZone); + if (text.includes("/") && isValidZone(text.trim())) { + const z = text.trim(); + return { id: z, name: z.split("/").pop()!.replace(/_/g, " "), zone: z }; + } + const [best] = searchCities(q); + if (!best) return undefined; + // Fuzzy matches (substring) only count for words long enough to mean something. + const name = fold(best.name); + const strong = name === q || best.aliases.map(fold).includes(q) || name.startsWith(q) || + fold(best.country) === q; + return strong || q.length >= 4 ? placeOf(best) : undefined; +} + +// --------------------------------------------------------------------------- +// "15:00 paris to tokyo" + +export interface TimeOfDay { + hour: number; + minute: number; +} + +const TIME_RE = + /(?:^|\s)(now|maintenant|noon|midday|midi|midnight|minuit|(\d{1,2})(?:[:.h](\d{2}))?\s*(am|pm|a\.m\.|p\.m\.|h)?)(?=\s|$)/i; + +/** A time in free text: "15:00", "3pm", "3:30 pm", "15h30", "15h", "noon". `undefined` hour means now. */ +export function findTime(text: string): { time: TimeOfDay | "now"; rest: string } | undefined { + const m = TIME_RE.exec(text); + if (!m) return undefined; + const word = m[1].toLowerCase(); + const rest = (text.slice(0, m.index) + " " + text.slice(m.index + m[0].length)).replace(/\s+/g, " ").trim(); + if (word === "now" || word === "maintenant") return { time: "now", rest }; + if (word === "noon" || word === "midday" || word === "midi") return { time: { hour: 12, minute: 0 }, rest }; + if (word === "midnight" || word === "minuit") return { time: { hour: 0, minute: 0 }, rest }; + const suffix = (m[4] ?? "").toLowerCase().replace(/\./g, ""); + // A bare number is not a time ("3 tokyo" means nothing); "15:00", "3pm" and "15h" are. + if (!m[3] && !suffix && !/[:h]/i.test(m[1])) return undefined; + let hour = Number(m[2]); + const minute = m[3] ? Number(m[3]) : 0; + if (minute > 59) return undefined; + if (suffix === "am" || suffix === "pm") { + if (hour < 1 || hour > 12) return undefined; + if (suffix === "am" && hour === 12) hour = 0; + if (suffix === "pm" && hour !== 12) hour += 12; + } else if (hour > 23) return undefined; + return { time: { hour, minute }, rest }; +} + +export interface Conversion { + /** The instant meant. */ + at: number; + from: Place; + /** Undefined: convert to every saved clock. */ + to?: Place; + /** The time as typed, `now` when none was. */ + time: TimeOfDay | "now"; +} + +const SEPARATOR = /\s+(?:to|in|into|->|→|=>|en|à|vers)\s+|\s*(?:->|→|=>)\s*/i; + +/** + * Parses "15:00 paris to tokyo", "3pm NY in Paris", "noon in london", + * "9am tokyo" (to every clock), "paris to tokyo" (now). Undefined when the + * query is not a conversion. + */ +export function parseConversion(query: string, now: number, zone = localZone()): Conversion | undefined { + const q = query.trim(); + if (!q) return undefined; + const parts = q.split(SEPARATOR); + const left = parts[0] ?? ""; + const right = parts.length > 1 ? parts.slice(1).join(" ") : undefined; + const found = findTime(left); + let to: Place | undefined; + if (right !== undefined) { + to = resolvePlace(right, zone); + if (!to) return undefined; + } + let from: Place | undefined; + let time: TimeOfDay | "now"; + if (found) { + time = found.time; + from = found.rest ? resolvePlace(found.rest, zone) : localPlace(zone); + if (!from) return undefined; + } else { + // "paris to tokyo" converts the current time; a city alone is a search, not a conversion. + if (right === undefined) return undefined; + from = resolvePlace(left, zone); + if (!from) return undefined; + time = "now"; + } + if (time === "now") return { at: now, from, to, time }; + const today = wallClock(from.zone, now); + const at = zonedToInstant(from.zone, today.year, today.month, today.day, time.hour, time.minute); + return { at, from, to, time }; +} + +/** Moves the clock `id` to the top, or up/down by one. */ +export function move(ids: string[], id: string, where: "top" | "up" | "down" | "bottom"): string[] { + const i = ids.indexOf(id); + if (i === -1) return ids; + const out = ids.filter((x) => x !== id); + const at = where === "top" ? 0 : where === "bottom" ? out.length : where === "up" ? Math.max(0, i - 1) : i + 1; + out.splice(Math.min(at, out.length), 0, id); + return out; +} diff --git a/extensions/world-clock/lib_test.ts b/extensions/world-clock/lib_test.ts new file mode 100644 index 0000000..83cac7a --- /dev/null +++ b/extensions/world-clock/lib_test.ts @@ -0,0 +1,179 @@ +import { assert, assertEquals } from "@std/assert"; +import { + CITIES, + dayDiff, + dayLabel, + findTime, + formatDiff, + formatTime, + formatUtcOffset, + isDaytime, + move, + nextTransition, + parseConversion, + placesFromIds, + resolvePlace, + searchCities, + wallClock, + zonedToInstant, + zoneOffset, +} from "./lib.ts"; +import { clockItem, localName, region } from "./clocks.ts"; + +const SUMMER = Date.UTC(2026, 6, 15, 12, 0); // 15 July 2026, 12:00 UTC +const WINTER = Date.UTC(2026, 0, 15, 12, 0); + +Deno.test("offsets follow daylight saving time", () => { + assertEquals(zoneOffset("Europe/Paris", SUMMER), 120); + assertEquals(zoneOffset("Europe/Paris", WINTER), 60); + assertEquals(zoneOffset("America/New_York", SUMMER), -240); + assertEquals(zoneOffset("America/New_York", WINTER), -300); + assertEquals(zoneOffset("Asia/Kolkata", SUMMER), 330); + assertEquals(zoneOffset("Asia/Kathmandu", SUMMER), 345); + assertEquals(zoneOffset("Australia/Sydney", WINTER), 660); + assertEquals(zoneOffset("UTC", SUMMER), 0); +}); + +Deno.test("wall clock and its inverse", () => { + const w = wallClock("Asia/Tokyo", SUMMER); + assertEquals([w.year, w.month, w.day, w.hour, w.minute, w.weekday], [2026, 7, 15, 21, 0, 3]); + assertEquals(zonedToInstant("Asia/Tokyo", 2026, 7, 15, 21, 0), SUMMER); + assertEquals(zonedToInstant("Europe/Paris", 2026, 1, 15, 13, 0), WINTER); + // 29 March 2026: Paris jumps from 2:00 to 3:00; 2:30 does not exist and moves forward. + const gap = zonedToInstant("Europe/Paris", 2026, 3, 29, 2, 30); + assertEquals(wallClock("Europe/Paris", gap).hour, 3); + // 25 October 2026: 2:30 happens twice; the first one (still summer time) wins. + assertEquals(zonedToInstant("Europe/Paris", 2026, 10, 25, 2, 30), Date.UTC(2026, 9, 25, 0, 30)); +}); + +Deno.test("formatting offsets, differences and days", () => { + assertEquals(formatUtcOffset(120), "UTC+2"); + assertEquals(formatUtcOffset(-300), "UTC−5"); + assertEquals(formatUtcOffset(330), "UTC+5:30"); + assertEquals(formatUtcOffset(0), "UTC"); + assertEquals(formatDiff(420), "+7h"); + assertEquals(formatDiff(-360), "−6h"); + assertEquals(formatDiff(210), "+3h30"); + assertEquals(formatDiff(0), "Same time"); + // 23:00 in Paris is the next morning in Tokyo, the previous afternoon in Los Angeles. + const late = Date.UTC(2026, 6, 15, 21, 0); + assertEquals(dayDiff("Asia/Tokyo", "Europe/Paris", late), 1); + assertEquals(dayDiff("America/Los_Angeles", "Europe/Paris", Date.UTC(2026, 6, 15, 3, 0)), -1); + assertEquals([dayLabel(0), dayLabel(1), dayLabel(-1)], [undefined, "Tomorrow", "Yesterday"]); + assertEquals(formatTime("Europe/Paris", SUMMER), "14:00"); + assertEquals(formatTime("Europe/Paris", SUMMER, true), "2:00 PM"); + assert(isDaytime(12) && !isDaytime(23) && !isDaytime(5)); +}); + +Deno.test("next clock change", () => { + const t = nextTransition("Europe/Paris", SUMMER)!; + assertEquals(new Date(t.at).toISOString(), "2026-10-25T01:00:00.000Z"); + assertEquals([t.before, t.after], [120, 60]); + assertEquals(nextTransition("Asia/Tokyo", SUMMER), undefined); +}); + +Deno.test("city table: about 200 cities, all zones valid, unique ids", () => { + assert(CITIES.length >= 200, `${CITIES.length} cities`); + const ids = new Set(); + for (const c of CITIES) { + new Intl.DateTimeFormat("en", { timeZone: c.zone }); // throws on an unknown zone + const id = `${c.zone}#${c.name}`; + assert(!ids.has(id), `duplicate ${id}`); + ids.add(id); + } +}); + +Deno.test("searching and resolving cities", () => { + assertEquals(searchCities("paris")[0].name, "Paris"); + assertEquals(searchCities("nyc")[0].name, "New York"); + assertEquals(searchCities("sao")[0].name, "São Paulo"); + assertEquals(searchCities("japan")[0].zone, "Asia/Tokyo"); + assertEquals(resolvePlace("NY")?.name, "New York"); + assertEquals(resolvePlace("münchen")?.name, "Munich"); + assertEquals(resolvePlace("sf")?.zone, "America/Los_Angeles"); + assertEquals(resolvePlace("here", "Europe/Paris")?.local, true); + assertEquals(resolvePlace("Europe/Paris")?.zone, "Europe/Paris"); + assertEquals(resolvePlace("America/Argentina/Cordoba")?.name, "Cordoba"); + assertEquals(resolvePlace("zz"), undefined); + assertEquals(resolvePlace("xyzzyville"), undefined); +}); + +Deno.test("finding a time in text", () => { + assertEquals(findTime("15:00 paris"), { time: { hour: 15, minute: 0 }, rest: "paris" }); + assertEquals(findTime("3pm NY"), { time: { hour: 15, minute: 0 }, rest: "NY" }); + assertEquals(findTime("3:30 pm"), { time: { hour: 15, minute: 30 }, rest: "" }); + assertEquals(findTime("12am")?.time, { hour: 0, minute: 0 }); + assertEquals(findTime("12pm")?.time, { hour: 12, minute: 0 }); + assertEquals(findTime("15h30 lyon")?.time, { hour: 15, minute: 30 }); + assertEquals(findTime("9h")?.time, { hour: 9, minute: 0 }); + assertEquals(findTime("noon london")?.time, { hour: 12, minute: 0 }); + assertEquals(findTime("london midnight")?.rest, "london"); + assertEquals(findTime("now")?.time, "now"); + assertEquals(findTime("3 tokyo"), undefined); + assertEquals(findTime("25:00"), undefined); + assertEquals(findTime("13pm"), undefined); + assertEquals(findTime("tokyo"), undefined); +}); + +Deno.test("conversions", () => { + const now = SUMMER; // 14:00 in Paris + const a = parseConversion("15:00 paris to tokyo", now, "Europe/Paris")!; + assertEquals([a.from.name, a.to?.name], ["Paris", "Tokyo"]); + assertEquals(a.at, Date.UTC(2026, 6, 15, 13, 0)); + assertEquals(formatTime("Asia/Tokyo", a.at), "22:00"); + + const b = parseConversion("3pm NY in Paris", now, "Europe/Paris")!; + assertEquals(formatTime("Europe/Paris", b.at), "21:00"); + + const c = parseConversion("9am to tokyo", now, "Europe/Paris")!; + assertEquals(c.from.local, true); + assertEquals(formatTime("Asia/Tokyo", c.at), "16:00"); + + const d = parseConversion("noon london", now, "Europe/Paris")!; + assertEquals([d.from.name, d.to], ["London", undefined]); + + const e = parseConversion("paris to tokyo", now, "Europe/Paris")!; + assertEquals([e.time, e.at], ["now", now]); + + const f = parseConversion("new york 9:30am → los angeles", now, "Europe/Paris")!; + assertEquals(formatTime("America/Los_Angeles", f.at), "06:30"); + + const g = parseConversion("15h en tokyo", now, "Europe/Paris")!; + assertEquals(g.to?.name, "Tokyo"); + + assertEquals(parseConversion("tokyo", now, "Europe/Paris"), undefined); + assertEquals(parseConversion("paris", now, "Europe/Paris"), undefined); + assertEquals(parseConversion("3pm to nowhereland", now, "Europe/Paris"), undefined); + assertEquals(parseConversion("", now, "Europe/Paris"), undefined); +}); + +Deno.test("stored clocks and reordering", () => { + const places = placesFromIds(["local", "Asia/Tokyo#Tokyo", "Nope/Nope#Nowhere"], "Europe/Paris"); + assertEquals(places.map((p) => p.name), ["Local", "Tokyo"]); + assertEquals(localName("Europe/Paris"), "Paris"); + assertEquals(localName("America/Argentina/Cordoba"), "Cordoba"); + const ids = ["a", "b", "c", "d"]; + assertEquals(move(ids, "c", "top"), ["c", "a", "b", "d"]); + assertEquals(move(ids, "c", "up"), ["a", "c", "b", "d"]); + assertEquals(move(ids, "a", "down"), ["b", "a", "c", "d"]); + assertEquals(move(ids, "a", "bottom"), ["b", "c", "d", "a"]); + assertEquals(move(ids, "x", "top"), ids); + assertEquals([region("Europe/Paris"), region("America/Lima"), region("Pacific/Auckland")], [ + "Europe", + "Americas", + "Oceania", + ]); +}); + +Deno.test("a clock item", () => { + const [tokyo] = placesFromIds(["Asia/Tokyo#Tokyo"]); + const item = clockItem(tokyo, { at: SUMMER, hour12: false, section: "Your Clocks", ids: ["local", tokyo.id] }); + assertEquals(item.title, "Tokyo"); + assertEquals(item.icon, { source: "bs-moon", tint: "blue" }); // 21:00 + assertEquals(item.accessories?.at(-1)?.text, "21:00"); + const titles = item.actions?.map((a) => a.title) ?? []; + assert(titles.includes("Move to Top")); + assert(titles.includes("Remove Clock")); + assert(!titles.includes("Move Down")); + assertEquals(item.detail?.stats?.[0], { label: "Time", value: "21:00" }); +}); From b219ea25ec2e1d43728f149c0bd91959f16b030f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:05:52 +0000 Subject: [PATCH 07/10] Passwords and generated passwords stay out of clipboard histories Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- .../dev-utils/commands/copy-password.ts | 2 +- extensions/dev-utils/commands/dev-utils.ts | 21 ++++++++++++------- extensions/passwords/ui.ts | 6 ++++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/extensions/dev-utils/commands/copy-password.ts b/extensions/dev-utils/commands/copy-password.ts index 37301ad..47e6083 100644 --- a/extensions/dev-utils/commands/copy-password.ts +++ b/extensions/dev-utils/commands/copy-password.ts @@ -3,6 +3,6 @@ import { password } from "../lib.ts"; export default run<{ length?: number }>(async ({ args }) => { const length = Math.min(256, Math.max(8, Number(args.length) || 20)); - await clipboard.write(password(length, true)); + await clipboard.write(password(length, true), { concealed: true }); return `Copied a ${length}-character password`; }); diff --git a/extensions/dev-utils/commands/dev-utils.ts b/extensions/dev-utils/commands/dev-utils.ts index e536185..7856f04 100644 --- a/extensions/dev-utils/commands/dev-utils.ts +++ b/extensions/dev-utils/commands/dev-utils.ts @@ -38,9 +38,9 @@ const fence = (text: string, lang = "") => { return `${ticks}${lang}\n${shown}\n${ticks}`; }; -const valueActions = (value: string): Action[] => [ - copy(value, "Copy to Clipboard"), - paste(value, "Paste in Active App"), +const valueActions = (value: string, concealed = false): Action[] => [ + copy(value, "Copy to Clipboard", { concealed }), + paste(value, "Paste in Active App", { concealed }), ]; function resultItem(r: Result, depth: number): Item { @@ -124,16 +124,19 @@ function passwordItem(length: number, symbols: boolean): Item { ], }, actions: [ - ...valueActions(value), + // Kept out of clipboard histories. + ...valueActions(value, true), action("Generate Another", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), submenu( "Other Length", - LENGTHS.filter((l) => l !== length).map((l) => copy(password(l, symbols), `${l} characters`)), + LENGTHS.filter((l) => l !== length).map((l) => + copy(password(l, symbols), `${l} characters`, { concealed: true }) + ), { icon: Icons.hash, section: "Generate" }, ), submenu( symbols ? "Without Symbols" : "With Symbols", - LENGTHS.map((l) => copy(password(l, !symbols), `${l} characters`)), + LENGTHS.map((l) => copy(password(l, !symbols), `${l} characters`, { concealed: true })), { icon: Icons.key, section: "Generate" }, ), ], @@ -165,7 +168,8 @@ function uuidItem(version: 4 | 7): Item { ], }, actions: [ - ...valueActions(value), + // Kept out of clipboard histories. + ...valueActions(value, true), action("Generate Another", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), submenu("Copy As", variants.map(([title, v]) => copy(v, title)), { icon: Icons.copy, section: "Copy" }), submenu( @@ -202,7 +206,8 @@ function loremItems(paragraphs?: number): Item[] { metadata: [meta.text("Words", value.split(/\s+/).length), meta.text("Characters", value.length)], }, actions: [ - ...valueActions(value), + // Kept out of clipboard histories. + ...valueActions(value, true), action("Generate Another", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), ], })); diff --git a/extensions/passwords/ui.ts b/extensions/passwords/ui.ts index f0ccc1a..6506cfe 100644 --- a/extensions/passwords/ui.ts +++ b/extensions/passwords/ui.ts @@ -46,11 +46,13 @@ function secretAction(entry: Entry, what: Secret, prefs: Record const title = `${how === "copy" ? "Copy" : "Paste"} ${what === "totp" ? "One-Time Code" : LABEL[what]}`; return action(title, async () => { const value = await reveal(entry, what, prefs); + // Clipboard histories (Backslash's included) skip concealed text. + const concealed = what !== "username"; if (how === "paste") { - await clipboard.paste(value); + await clipboard.paste(value, { concealed }); return close(); } - await clipboard.write(value); + await clipboard.write(value, { concealed }); return close(`${LABEL[what]} copied`); }, { icon: what === "totp" From 1013c00ada454c2eb0f6b03bcdb35dfec887562f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:10:03 +0000 Subject: [PATCH 08/10] Descriptions without a trailing period, like the other extensions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/calendar/backslash.toml | 2 +- extensions/dev-utils/backslash.toml | 2 +- extensions/distrobox/backslash.toml | 2 +- extensions/docs/backslash.toml | 2 +- extensions/kubernetes/backslash.toml | 2 +- extensions/passwords/backslash.toml | 4 ++-- extensions/quick-notes/backslash.toml | 2 +- extensions/ssh/backslash.toml | 2 +- extensions/terminal-sessions/backslash.toml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/extensions/calendar/backslash.toml b/extensions/calendar/backslash.toml index f34fe47..86f3424 100644 --- a/extensions/calendar/backslash.toml +++ b/extensions/calendar/backslash.toml @@ -51,7 +51,7 @@ keywords = [ [[preferences]] name = "urls" title = "Calendar addresses" -description = "Private iCal (.ics) addresses, one per line. Google: Settings → your calendar → Integrate calendar → Secret address in iCal format." +description = "Private iCal (.ics) addresses, one per line. Google: Settings → your calendar → Integrate calendar → Secret address in iCal format" type = "secret" [[preferences]] diff --git a/extensions/dev-utils/backslash.toml b/extensions/dev-utils/backslash.toml index ea68c96..f5aa546 100644 --- a/extensions/dev-utils/backslash.toml +++ b/extensions/dev-utils/backslash.toml @@ -1,7 +1,7 @@ [extension] id = "dev-utils" title = "Dev Utils" -description = "Encode, decode, hash, convert and generate developer values right from the search bar." +description = "Encode, decode, hash, convert and generate developer values right from the search bar" version = "1.0.0" author = "Backslash" icon = "bs-braces" diff --git a/extensions/distrobox/backslash.toml b/extensions/distrobox/backslash.toml index 21329a0..0ff4993 100644 --- a/extensions/distrobox/backslash.toml +++ b/extensions/distrobox/backslash.toml @@ -1,7 +1,7 @@ [extension] id = "distrobox" title = "Containers & VMs" -description = "Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines." +description = "Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines" version = "1.0.0" author = "Backslash" icon = "bs-layers" diff --git a/extensions/docs/backslash.toml b/extensions/docs/backslash.toml index bc759a6..6969906 100644 --- a/extensions/docs/backslash.toml +++ b/extensions/docs/backslash.toml @@ -1,7 +1,7 @@ [extension] id = "docs" title = "Docs" -description = "Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages." +description = "Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages" version = "1.0.0" author = "Backslash" icon = "bs-book" diff --git a/extensions/kubernetes/backslash.toml b/extensions/kubernetes/backslash.toml index 04a4cf6..e7081e5 100644 --- a/extensions/kubernetes/backslash.toml +++ b/extensions/kubernetes/backslash.toml @@ -1,7 +1,7 @@ [extension] id = "kubernetes" title = "Kubernetes" -description = "Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces." +description = "Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces" version = "1.0.0" author = "Backslash" icon = "bs-cloud" diff --git a/extensions/passwords/backslash.toml b/extensions/passwords/backslash.toml index 0a43846..1eecfb5 100644 --- a/extensions/passwords/backslash.toml +++ b/extensions/passwords/backslash.toml @@ -41,13 +41,13 @@ keywords = [ [[preferences]] name = "bwSession" title = "Bitwarden session" -description = "Optional: the output of `bw unlock --raw`, to stay unlocked. Without it you type your master password once per use." +description = "Optional: the output of `bw unlock --raw`, to stay unlocked. Without it you type your master password once per use" type = "secret" [[preferences]] name = "keepassDb" title = "KeePassXC database" -description = "Path of your .kdbx file, e.g. ~/Passwords.kdbx. Its password is asked each time, never saved." +description = "Path of your .kdbx file, e.g. ~/Passwords.kdbx. Its password is asked each time, never saved" [[preferences]] name = "keepassKeyFile" diff --git a/extensions/quick-notes/backslash.toml b/extensions/quick-notes/backslash.toml index f8997ab..6eb4d8e 100644 --- a/extensions/quick-notes/backslash.toml +++ b/extensions/quick-notes/backslash.toml @@ -42,5 +42,5 @@ required = true [[preferences]] name = "folder" title = "Notes folder" -description = "Inside ~/Documents. Created when you save your first note." +description = "Inside ~/Documents. Created when you save your first note" default = "~/Documents/Notes" diff --git a/extensions/ssh/backslash.toml b/extensions/ssh/backslash.toml index f058b1f..1cd1c63 100644 --- a/extensions/ssh/backslash.toml +++ b/extensions/ssh/backslash.toml @@ -1,7 +1,7 @@ [extension] id = "ssh" title = "SSH Hosts" -description = "Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager." +description = "Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager" version = "1.0.0" author = "Backslash" icon = "bs-server" diff --git a/extensions/terminal-sessions/backslash.toml b/extensions/terminal-sessions/backslash.toml index b15af00..9f7ab3d 100644 --- a/extensions/terminal-sessions/backslash.toml +++ b/extensions/terminal-sessions/backslash.toml @@ -1,7 +1,7 @@ [extension] id = "terminal-sessions" title = "Terminal Sessions" -description = "Attach to, create, rename and kill your tmux and zellij sessions." +description = "Attach to, create, rename and kill your tmux and zellij sessions" version = "1.0.0" author = "Backslash" icon = "bs-terminal" From e34f16627f9ac1ca033e2ea6464db87baf6485bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:10:56 +0000 Subject: [PATCH 09/10] Registry entries and README for the 34 new extensions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- README.md | 68 +++++++++++++++++++++++++-------- registry/caffeine.toml | 14 +++++++ registry/calendar.toml | 14 +++++++ registry/color-picker.toml | 14 +++++++ registry/deployments.toml | 14 +++++++ registry/desktop-settings.toml | 14 +++++++ registry/dev-utils.toml | 14 +++++++ registry/dictionary.toml | 14 +++++++ registry/display.toml | 14 +++++++ registry/distrobox.toml | 14 +++++++ registry/docs.toml | 14 +++++++ registry/drives.toml | 14 +++++++ registry/games.toml | 14 +++++++ registry/gitlab.toml | 14 +++++++ registry/hacker-news.toml | 14 +++++++ registry/home-assistant.toml | 14 +++++++ registry/jira.toml | 14 +++++++ registry/kubernetes.toml | 14 +++++++ registry/notifications.toml | 14 +++++++ registry/notion.toml | 14 +++++++ registry/obsidian.toml | 14 +++++++ registry/ocr.toml | 14 +++++++ registry/passwords.toml | 14 +++++++ registry/power.toml | 14 +++++++ registry/quick-notes.toml | 14 +++++++ registry/screenshot.toml | 14 +++++++ registry/sentry.toml | 14 +++++++ registry/spotify.toml | 14 +++++++ registry/ssh.toml | 14 +++++++ registry/system-monitor.toml | 14 +++++++ registry/terminal-sessions.toml | 14 +++++++ registry/updates.toml | 14 +++++++ registry/weather.toml | 14 +++++++ registry/web-search.toml | 14 +++++++ registry/world-clock.toml | 14 +++++++ 35 files changed, 528 insertions(+), 16 deletions(-) create mode 100644 registry/caffeine.toml create mode 100644 registry/calendar.toml create mode 100644 registry/color-picker.toml create mode 100644 registry/deployments.toml create mode 100644 registry/desktop-settings.toml create mode 100644 registry/dev-utils.toml create mode 100644 registry/dictionary.toml create mode 100644 registry/display.toml create mode 100644 registry/distrobox.toml create mode 100644 registry/docs.toml create mode 100644 registry/drives.toml create mode 100644 registry/games.toml create mode 100644 registry/gitlab.toml create mode 100644 registry/hacker-news.toml create mode 100644 registry/home-assistant.toml create mode 100644 registry/jira.toml create mode 100644 registry/kubernetes.toml create mode 100644 registry/notifications.toml create mode 100644 registry/notion.toml create mode 100644 registry/obsidian.toml create mode 100644 registry/ocr.toml create mode 100644 registry/passwords.toml create mode 100644 registry/power.toml create mode 100644 registry/quick-notes.toml create mode 100644 registry/screenshot.toml create mode 100644 registry/sentry.toml create mode 100644 registry/spotify.toml create mode 100644 registry/ssh.toml create mode 100644 registry/system-monitor.toml create mode 100644 registry/terminal-sessions.toml create mode 100644 registry/updates.toml create mode 100644 registry/weather.toml create mode 100644 registry/web-search.toml create mode 100644 registry/world-clock.toml diff --git a/README.md b/README.md index 0a69537..56becb0 100644 --- a/README.md +++ b/README.md @@ -63,22 +63,58 @@ Anyone can also install straight from a repository, without the registry: ## First-party extensions -| Extension | Commands | Permissions | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | -| [AI](extensions/ai) | Ask AI with saved conversations; fix, improve, summarize, translate, explain copied text | Claude, OpenAI, Mistral, Groq, OpenRouter, Ollama | -| [Browser](extensions/browser) | History and bookmarks of Firefox, Chrome, Brave, Chromium, Edge, Vivaldi; Firefox tabs | reads the browsers' profiles | -| [Code Projects](extensions/projects) | Recent projects of VS Code, Cursor, Zed, JetBrains and git folders, with language and git status; open with… | reads editor history, runs editors and terminals | -| [Containers](extensions/docker) | Docker/Podman grouped by Compose project: start, stop, logs, remove | `docker`, `podman` | -| [Emoji & Symbols](extensions/emoji) | Search and copy, recently used first | none | -| [GitHub](extensions/github) | Pull requests with checks and reviews, issues, repositories | `api.github.com`, avatars, `gh` for the token | -| [Linear](extensions/linear) | My issues by status with labels, cycle and sub-issues; change status, priority, assignee; search, create | `api.linear.app` | -| [Media Controls](extensions/media) | Now playing with cover and progress, play/pause, next, volume, shuffle, repeat of any MPRIS player | `busctl`, covers | -| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth (with battery), audio outputs, VPN and Tailscale | `nmcli`, `bluetoothctl`, `pactl`, `tailscale` | -| [Packages](extensions/packages) | Search apt/dnf/pacman/zypper and Flathub | runs the package tools (search only) | -| [Ports](extensions/ports) | What listens on each port, with its process and project; stop it | `ss`, `ps`, `readlink`, `kill` | -| [Services](extensions/systemd) | systemd services, running or failed: start, stop, restart, logs | `systemctl`, `journalctl` | -| [Timers](extensions/timers) | Timers and Pomodoro that ring even with Backslash closed | `systemd-run`, `systemctl` | -| [Todoist](extensions/todoist) | Today, next 7 days, no date; complete, reschedule, prioritize; search, quick add | `api.todoist.com` | +48 extensions, plus npm and System bundled with the launcher. + +| Extension | What it does | Permissions | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [AI](extensions/ai) | Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama | `api.anthropic.com`, `api.openai.com`… | +| [Browser](extensions/browser) | History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi | reads ~/.mozilla/firefox, ~/snap/firefox/common/.mozilla/firefox… | +| [Caffeine](extensions/caffeine) | Keep the computer awake for a while, or until you turn it off, even with Backslash closed | runs `systemd-run`, `systemctl`, `systemd-inhibit` | +| [Calendar](extensions/calendar) | Your upcoming events from Google, Outlook, iCloud, Fastmail or Proton calendars, with one-key meeting joins | `calendar.google.com`, `outlook.office365.com`… | +| [Code Projects](extensions/projects) | Recent projects of VS Code, VSCodium, Cursor, Zed and JetBrains IDEs, plus your git folders | runs `git`, `code`, `code-oss`…; reads ~/.config/Code, ~/.config/Code - OSS… | +| [Color Picker](extensions/color-picker) | Pick a color anywhere on the screen, keep a history, and convert colors between HEX, RGB, HSL and OKLCH | runs `systemd-run`, `hyprpicker`, `grim`…; reads ~/.cache/backslash/color-picker, /etc/os-release | +| [Containers & VMs](extensions/distrobox) | Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines | runs `distrobox`, `toolbox`, `podman`… | +| [Containers](extensions/docker) | Docker and Podman containers: start, stop, logs | runs `docker`, `podman` | +| [Deployments](extensions/deployments) | Recent Vercel deployments and Netlify deploys with their status, commit, branch and build time; open, copy, redeploy, cancel, build logs | `api.vercel.com`, `api.netlify.com`… | +| [Desktop Settings](extensions/desktop-settings) | Search every settings panel of GNOME, KDE Plasma, Xfce or Cinnamon in English or French, and flip quick toggles | runs `gsettings`, `kreadconfig6`, `kreadconfig5`…; reads /usr/bin, /usr/local/bin… | +| [Dev Utils](extensions/dev-utils) | Encode, decode, hash, convert and generate developer values right from the search bar | none | +| [Dictionary](extensions/dictionary) | Definitions, examples, pronunciation and synonyms in English and French from Wiktionary, and quick translations | `en.wiktionary.org`, `fr.wiktionary.org`… | +| [Display](extensions/display) | Screen brightness with presets, night light, and your monitors' resolution, refresh rate and scale | runs `brightnessctl`, `udevadm`, `busctl`…; reads /etc/os-release | +| [Docs](extensions/docs) | Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages | `devdocs.io`, `documents.devdocs.io`…; runs `man`, `apropos`, `kitty`… | +| [Drives](extensions/drives) | Mount, open, unmount and safely eject USB sticks, SD cards and internal drives | runs `lsblk`, `udisksctl` | +| [Emoji & Symbols](extensions/emoji) | Search emoji and symbols by name and copy them | none | +| [Games](extensions/games) | Launch your installed Steam, Lutris and Heroic (Epic, GOG, Amazon) games, with cover art, play time, size and ProtonDB ratings | `www.protondb.com`, `shared.steamstatic.com`…; runs `lutris`; reads ~/.local/share/Steam, ~/.steam… | +| [GitHub](extensions/github) | Pull requests with their checks and reviews, your issues, and repository search | `api.github.com`, `avatars.githubusercontent.com`; runs `gh` | +| [GitLab](extensions/gitlab) | Merge requests with pipelines and approvals, your issues, projects and pipelines on gitlab.com (self-hosted instances are not supported) | `gitlab.com`, `secure.gravatar.com` | +| [Hacker News & RSS](extensions/hacker-news) | Top, new, best, Ask and Show HN stories with their comments, and your RSS and Atom feeds with unread tracking | any website | +| [Home Assistant](extensions/home-assistant) | Lights, switches, covers, climate, media players, scenes, scripts and sensors of your Home Assistant: toggle, dim, set the temperature, run scenes | `localhost`, `127.0.0.1`… | +| [Jira](extensions/jira) | Your Jira Cloud issues by status with priority, sprint and labels; change status, assign, search with text or JQL, create issues | `*.atlassian.net`, `secure.gravatar.com`… | +| [Kubernetes](extensions/kubernetes) | Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces | runs `kubectl`, `kitty`, `alacritty`… | +| [Linear](extensions/linear) | Your issues with their status, priority, labels and assignee; search, triage and quick issue creation | `api.linear.app`, `public.linear.app` | +| [Media Controls](extensions/media) | Control Spotify, browsers, VLC, mpv and any MPRIS player | `i.scdn.co`; runs `busctl`; reads /tmp/backslash-media | +| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs and VPNs in two keystrokes | runs `nmcli`, `bluetoothctl`, `pactl`… | +| [Notifications](extensions/notifications) | Do Not Disturb and the notification history for mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon | runs `gdbus`, `makoctl`, `dunstctl`… | +| [Notion](extensions/notion) | Search the pages and databases shared with your integration, read them, add text to a page, create pages in a database | `api.notion.com` | +| [Obsidian](extensions/obsidian) | Search your Obsidian notes by title, tag and text, preview them, open today's daily note and capture ideas | reads ~ | +| [Packages](extensions/packages) | Search your distribution's packages and Flathub | runs `apt-cache`, `dnf`, `pacman`… | +| [Passwords](extensions/passwords) | Copy passwords, usernames and one-time codes from pass, Bitwarden, 1Password and KeePassXC | runs `pass`, `bw`, `op`…; reads ~/.password-store | +| [Ports](extensions/ports) | What listens on :3000? Dev servers and services by port, with their process and project | runs `ss`, `ps`, `readlink`… | +| [Power](extensions/power) | Switch power profiles and check your battery's charge, health, cycles and time left | runs `powerprofilesctl`, `tuned-adm`, `udevadm`… | +| [Quick Notes](extensions/quick-notes) | Jot, find, pin and paste notes kept as plain markdown files, ready for Obsidian, your editor or sync | reads ~/Documents | +| [Screenshot & Recording](extensions/screenshot) | Capture an area, a window or the whole screen, record the screen, and find your recent captures | runs `systemd-run`, `systemctl`, `grim`…; reads ~/Pictures, ~/Images… | +| [Sentry](extensions/sentry) | Unresolved issues across your projects with events, users and assignee; resolve, ignore, assign; latest event with its stack trace (sentry.io, US and EU regions) | `sentry.io`, `us.sentry.io`… | +| [Services](extensions/systemd) | Start, stop and inspect systemd services | runs `systemctl`, `journalctl` | +| [Spotify](extensions/spotify) | Search Spotify, play on any of your devices, queue songs, browse your playlists and save what's playing to your library | `api.spotify.com`, `accounts.spotify.com`… | +| [SSH Hosts](extensions/ssh) | Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager | runs `kitty`, `alacritty`, `wezterm`…; reads ~/.ssh | +| [System Monitor](extensions/system-monitor) | Live CPU, memory, disk, network, GPU and battery usage, and the processes behind it | runs `grep`, `ps`, `df`…; reads /etc/os-release | +| [System Updates](extensions/updates) | Available updates from APT, DNF, Pacman, Zypper, Flatpak, Snap and firmware, applied in a terminal | runs `apt`, `dnf`, `rpm`…; reads /etc/os-release | +| [Terminal Sessions](extensions/terminal-sessions) | Attach to, create, rename and kill your tmux and zellij sessions | runs `tmux`, `zellij`, `kitty`… | +| [Text from Screen](extensions/ocr) | Select any part of the screen and copy the text in it, recognized offline with Tesseract | runs `systemd-run`, `tesseract`, `grim`…; reads ~/.cache/backslash/ocr, /etc/os-release | +| [Timers](extensions/timers) | Timers and Pomodoro that ring even when Backslash is closed (systemd timers) | runs `systemd-run`, `systemctl` | +| [Todoist](extensions/todoist) | Today's tasks, search and quick add in natural language | `api.todoist.com` | +| [Weather](extensions/weather) | Current conditions, the next 24 hours and the week ahead for any city, from Open-Meteo | `geocoding-api.open-meteo.com`, `api.open-meteo.com` | +| [Web Search](extensions/web-search) | Search the web with live suggestions, 50 !bangs and your recent searches, on the engine you prefer | `duckduckgo.com`, `suggestqueries.google.com` | +| [World Clock](extensions/world-clock) | The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris” | none | Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged. diff --git a/registry/caffeine.toml b/registry/caffeine.toml new file mode 100644 index 0000000..6dc6a1d --- /dev/null +++ b/registry/caffeine.toml @@ -0,0 +1,14 @@ +# registry/caffeine.toml +id = "caffeine" +title = "Caffeine" +description = "Keep the computer awake for a while, or until you turn it off, even with Backslash closed" +author = "Backslash" +icon = "bs-coffee" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/caffeine" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:86031e3739bdf4dc71c5af09d32dc5737495bc16b342901f3d20b9ad01e495e5" +permissions = ["Run systemd-run", "Run systemctl", "Run systemd-inhibit", "Read the environment variable XDG_CURRENT_DESKTOP"] +commands = ["Caffeine", "Toggle Caffeine", "Keep Awake For…"] + diff --git a/registry/calendar.toml b/registry/calendar.toml new file mode 100644 index 0000000..89cfa12 --- /dev/null +++ b/registry/calendar.toml @@ -0,0 +1,14 @@ +# registry/calendar.toml +id = "calendar" +title = "Calendar" +description = "Your upcoming events from Google, Outlook, iCloud, Fastmail or Proton calendars, with one-key meeting joins" +author = "Backslash" +icon = "bs-calendar" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/calendar" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:c1cc6fcdd087c9ccc93a95025a5eb0f106d30eab74ee47ea3504b170d57f18b1" +permissions = ["Access calendar.google.com", "Access outlook.office365.com", "Access outlook.office.com", "Access outlook.live.com", "Access caldav.fastmail.com", "Access user.fm", "Access *.icloud.com", "Access calendar.proton.me", "Access calendar.zoho.com", "Access calendar.zoho.eu", "Access export.calendar.yahoo.com"] +commands = ["Upcoming Events"] + diff --git a/registry/color-picker.toml b/registry/color-picker.toml new file mode 100644 index 0000000..2a04dfa --- /dev/null +++ b/registry/color-picker.toml @@ -0,0 +1,14 @@ +# registry/color-picker.toml +id = "color-picker" +title = "Color Picker" +description = "Pick a color anywhere on the screen, keep a history, and convert colors between HEX, RGB, HSL and OKLCH" +author = "Backslash" +icon = "bs-pipette" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/color-picker" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:62169bbb07811801d92a4d8d7021deb83fa8f91b1f7d72ad48e0762a316c6a12" +permissions = ["Run systemd-run", "Run hyprpicker", "Run grim", "Run slurp", "Run gpick", "Run xcolor", "Run gdbus", "Read ~/.cache/backslash/color-picker", "Read /etc/os-release", "Write ~/.cache/backslash/color-picker", "Read the environment variable HOME", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK"] +commands = ["Pick Color", "Color History", "Convert Color"] + diff --git a/registry/deployments.toml b/registry/deployments.toml new file mode 100644 index 0000000..85bd6f0 --- /dev/null +++ b/registry/deployments.toml @@ -0,0 +1,14 @@ +# registry/deployments.toml +id = "deployments" +title = "Deployments" +description = "Recent Vercel deployments and Netlify deploys with their status, commit, branch and build time; open, copy, redeploy, cancel, build logs" +author = "Backslash" +icon = "bs-rocket" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/deployments" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:844855ac02fc1b7a73168e952f734d92308ad6d472d06ee859bb7b30ffc172cf" +permissions = ["Access api.vercel.com", "Access api.netlify.com", "Access vercel.com"] +commands = ["Recent Deployments", "Deployment Projects"] + diff --git a/registry/desktop-settings.toml b/registry/desktop-settings.toml new file mode 100644 index 0000000..a017568 --- /dev/null +++ b/registry/desktop-settings.toml @@ -0,0 +1,14 @@ +# registry/desktop-settings.toml +id = "desktop-settings" +title = "Desktop Settings" +description = "Search every settings panel of GNOME, KDE Plasma, Xfce or Cinnamon in English or French, and flip quick toggles" +author = "Backslash" +icon = "bs-settings" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/desktop-settings" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:28c2f11d713588d0c6ed545578af713fc690b96b50bbc5ba535bd3d1fe83c76a" +permissions = ["Run gsettings", "Run kreadconfig6", "Run kreadconfig5", "Run plasma-apply-colorscheme", "Run gnome-control-center", "Run systemsettings", "Run systemsettings5", "Run kcmshell6", "Run kcmshell5", "Run cinnamon-settings", "Run xfce4-settings-manager", "Run xfce4-appearance-settings", "Run xfce4-display-settings", "Run xfce4-color-settings", "Run xfce4-keyboard-settings", "Run xfce4-mouse-settings", "Run xfce4-power-manager-settings", "Run xfce4-notifyd-config", "Run xfce4-mime-settings", "Run exo-preferred-applications", "Run xfce4-session-settings", "Run xfce4-screensaver-preferences", "Run light-locker-settings", "Run xfce4-accessibility-settings", "Run xfce4-about", "Run xfce4-settings-editor", "Run xfce4-panel", "Run xfdesktop-settings", "Run xfwm4-settings", "Run xfwm4-tweaks-settings", "Run xfwm4-workspace-settings", "Run thunar-volman-settings", "Run nm-connection-editor", "Run blueman-manager", "Run blueberry", "Run overskride", "Run pavucontrol", "Run pwvucontrol", "Run pavucontrol-qt", "Run nwg-displays", "Run wdisplays", "Run arandr", "Run nwg-look", "Run lxappearance", "Run qt6ct", "Run qt5ct", "Run system-config-printer", "Read /usr/bin", "Read /usr/local/bin", "Read /bin", "Read /usr/sbin", "Read /sbin", "Read /usr/games", "Read ~/.local/bin", "Read /run/current-system/sw/bin", "Read ~/.nix-profile/bin", "Read /snap/bin", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable PATH"] +commands = ["Desktop Settings"] + diff --git a/registry/dev-utils.toml b/registry/dev-utils.toml new file mode 100644 index 0000000..a4cc91a --- /dev/null +++ b/registry/dev-utils.toml @@ -0,0 +1,14 @@ +# registry/dev-utils.toml +id = "dev-utils" +title = "Dev Utils" +description = "Encode, decode, hash, convert and generate developer values right from the search bar" +author = "Backslash" +icon = "bs-braces" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/dev-utils" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:4cc1fc6f231d622afb76994d262ac583c79ccbf66e6bc1b444b6bb275aaf605a" +permissions = [] +commands = ["Dev Utils", "Copy New UUID", "Copy New Password"] + diff --git a/registry/dictionary.toml b/registry/dictionary.toml new file mode 100644 index 0000000..14433ca --- /dev/null +++ b/registry/dictionary.toml @@ -0,0 +1,14 @@ +# registry/dictionary.toml +id = "dictionary" +title = "Dictionary" +description = "Definitions, examples, pronunciation and synonyms in English and French from Wiktionary, and quick translations" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/dictionary" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:4048588cd5c5822148ab2e9ec9a67d1713a7fda00ed043b4e49b1b76227e1ed3" +permissions = ["Access en.wiktionary.org", "Access fr.wiktionary.org", "Access api.dictionaryapi.dev", "Access api.mymemory.translated.net"] +commands = ["Define Word", "Translate"] + diff --git a/registry/display.toml b/registry/display.toml new file mode 100644 index 0000000..541f3f8 --- /dev/null +++ b/registry/display.toml @@ -0,0 +1,14 @@ +# registry/display.toml +id = "display" +title = "Display" +description = "Screen brightness with presets, night light, and your monitors' resolution, refresh rate and scale" +author = "Backslash" +icon = "bs-monitor" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/display" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:8bd5dd807e72b51c2790874ea4b001ddacf07d8b03933f0428c17d0a3047a08d" +permissions = ["Run brightnessctl", "Run udevadm", "Run busctl", "Run ddcutil", "Run gsettings", "Run kwriteconfig6", "Run kwriteconfig5", "Run hyprsunset", "Run wlsunset", "Run gammastep", "Run redshift", "Run systemd-run", "Run systemctl", "Run hyprctl", "Run swaymsg", "Run kscreen-doctor", "Run gdbus", "Run wlr-randr", "Run xrandr", "Read /etc/os-release", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK"] +commands = ["Display", "Set Brightness", "Increase Brightness", "Decrease Brightness", "Toggle Night Light"] + diff --git a/registry/distrobox.toml b/registry/distrobox.toml new file mode 100644 index 0000000..f8aa93e --- /dev/null +++ b/registry/distrobox.toml @@ -0,0 +1,14 @@ +# registry/distrobox.toml +id = "distrobox" +title = "Containers & VMs" +description = "Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines" +author = "Backslash" +icon = "bs-layers" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/distrobox" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:d8199b52e3f0e1386e2576fb019eb45ea4d45f7298eb66463a4a2fd5af7d3155" +permissions = ["Run distrobox", "Run toolbox", "Run podman", "Run virsh", "Run virt-manager", "Run virt-viewer", "Run gnome-boxes", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm"] +commands = ["Containers & VMs"] + diff --git a/registry/docs.toml b/registry/docs.toml new file mode 100644 index 0000000..e596f26 --- /dev/null +++ b/registry/docs.toml @@ -0,0 +1,14 @@ +# registry/docs.toml +id = "docs" +title = "Docs" +description = "Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/docs" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:3b9ccec4c6744ff8a86e14ee3e3c0bb7f6d6fdb16620cd65119eeeb6c56b4e76" +permissions = ["Access devdocs.io", "Access documents.devdocs.io", "Access tldr.sh", "Access raw.githubusercontent.com", "Access wiki.archlinux.org", "Run man", "Run apropos", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm"] +commands = ["Search DevDocs", "Choose DevDocs", "tldr Pages", "Search Arch Wiki", "Man Pages"] + diff --git a/registry/drives.toml b/registry/drives.toml new file mode 100644 index 0000000..b382cb6 --- /dev/null +++ b/registry/drives.toml @@ -0,0 +1,14 @@ +# registry/drives.toml +id = "drives" +title = "Drives" +description = "Mount, open, unmount and safely eject USB sticks, SD cards and internal drives" +author = "Backslash" +icon = "bs-drive" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/drives" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:1852ab872e82d685036988a4468c8e278d981f537bb512063f827da8d21f037e" +permissions = ["Run lsblk", "Run udisksctl"] +commands = ["Drives"] + diff --git a/registry/games.toml b/registry/games.toml new file mode 100644 index 0000000..b3d65f6 --- /dev/null +++ b/registry/games.toml @@ -0,0 +1,14 @@ +# registry/games.toml +id = "games" +title = "Games" +description = "Launch your installed Steam, Lutris and Heroic (Epic, GOG, Amazon) games, with cover art, play time, size and ProtonDB ratings" +author = "Backslash" +icon = "bs-gamepad" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/games" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:8d7e4c6ffc3ff7a3cc7aabde2003a8dfcad3890607db3693d1cea60ffe9295f2" +permissions = ["Access www.protondb.com", "Access shared.steamstatic.com", "Access cdn1.epicgames.com", "Access cdn2.unrealengine.com", "Access images.gog-statics.com", "Run lutris", "Read ~/.local/share/Steam", "Read ~/.steam", "Read ~/.var/app/com.valvesoftware.Steam/.local/share/Steam", "Read /mnt", "Read /media", "Read /run/media", "Read ~/.local/share/lutris", "Read ~/.cache/lutris", "Read ~/.local/share/icons/hicolor/128x128/apps", "Read ~/.config/heroic", "Read ~/.var/app/com.heroicgameslauncher.hgl/config/heroic", "Read the environment variable HOME"] +commands = ["Search Games"] + diff --git a/registry/gitlab.toml b/registry/gitlab.toml new file mode 100644 index 0000000..b3aaa56 --- /dev/null +++ b/registry/gitlab.toml @@ -0,0 +1,14 @@ +# registry/gitlab.toml +id = "gitlab" +title = "GitLab" +description = "Merge requests with pipelines and approvals, your issues, projects and pipelines on gitlab.com (self-hosted instances are not supported)" +author = "Backslash" +icon = "bs-pr-merged" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/gitlab" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:ec979e5e648ba23349c7958a8b2306684a4e3a10a8b13f568d2dc52331e5c0df" +permissions = ["Access gitlab.com", "Access secure.gravatar.com"] +commands = ["My Merge Requests", "My GitLab Issues", "Search GitLab Projects", "GitLab Pipelines"] + diff --git a/registry/hacker-news.toml b/registry/hacker-news.toml new file mode 100644 index 0000000..aaf58fa --- /dev/null +++ b/registry/hacker-news.toml @@ -0,0 +1,14 @@ +# registry/hacker-news.toml +id = "hacker-news" +title = "Hacker News & RSS" +description = "Top, new, best, Ask and Show HN stories with their comments, and your RSS and Atom feeds with unread tracking" +author = "Backslash" +icon = "bs-rss" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/hacker-news" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:59c02895ad837ba594db67c36a3ce6d5ffb48ec3cf896255b29767335e690539" +permissions = ["Access any website"] +commands = ["Hacker News", "RSS Feeds", "Add RSS Feed"] + diff --git a/registry/home-assistant.toml b/registry/home-assistant.toml new file mode 100644 index 0000000..4a54941 --- /dev/null +++ b/registry/home-assistant.toml @@ -0,0 +1,14 @@ +# registry/home-assistant.toml +id = "home-assistant" +title = "Home Assistant" +description = "Lights, switches, covers, climate, media players, scenes, scripts and sensors of your Home Assistant: toggle, dim, set the temperature, run scenes" +author = "Backslash" +icon = "bs-home" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/home-assistant" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:3ec4edae54768721cdc6ed633fb161fef4d51f157709e51d579966a8c7456fbb" +permissions = ["Access localhost", "Access 127.0.0.1", "Access homeassistant", "Access homeassistant.local", "Access *.local", "Access *.lan", "Access *.home.arpa", "Access *.ui.nabu.casa", "Access *.duckdns.org", "Access *.ts.net", "Access 10.0.0.0/8", "Access 172.16.0.0/12", "Access 192.168.0.0/16", "Access 100.64.0.0/10"] +commands = ["Home Assistant"] + diff --git a/registry/jira.toml b/registry/jira.toml new file mode 100644 index 0000000..467991c --- /dev/null +++ b/registry/jira.toml @@ -0,0 +1,14 @@ +# registry/jira.toml +id = "jira" +title = "Jira" +description = "Your Jira Cloud issues by status with priority, sprint and labels; change status, assign, search with text or JQL, create issues" +author = "Backslash" +icon = "bs-ticket" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/jira" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:dc5ba322175a2b09182e77daa7f0be0628148a733436370e6cdc5826d43df768" +permissions = ["Access *.atlassian.net", "Access secure.gravatar.com", "Access avatar-management--avatars.us-west-2.prod.public.atl-paas.net"] +commands = ["My Jira Issues", "Search Jira Issues", "Create Jira Issue"] + diff --git a/registry/kubernetes.toml b/registry/kubernetes.toml new file mode 100644 index 0000000..29ad109 --- /dev/null +++ b/registry/kubernetes.toml @@ -0,0 +1,14 @@ +# registry/kubernetes.toml +id = "kubernetes" +title = "Kubernetes" +description = "Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces" +author = "Backslash" +icon = "bs-cloud" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/kubernetes" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:bfc1d0e7de287372e4fca56e412a898457afa34aaa2c8da90014dfa9deabb9cc" +permissions = ["Run kubectl", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm"] +commands = ["Kubernetes Pods", "Switch Kubernetes Context", "Switch Kubernetes Namespace"] + diff --git a/registry/notifications.toml b/registry/notifications.toml new file mode 100644 index 0000000..e1288a4 --- /dev/null +++ b/registry/notifications.toml @@ -0,0 +1,14 @@ +# registry/notifications.toml +id = "notifications" +title = "Notifications" +description = "Do Not Disturb and the notification history for mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon" +author = "Backslash" +icon = "bs-bell" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/notifications" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:06540d55b162545f3addc6e10016edeceee645ec6a1a324a9938eda9f7950eca" +permissions = ["Run gdbus", "Run makoctl", "Run dunstctl", "Run swaync-client", "Run gsettings", "Run xfconf-query", "Run uptime", "Run gnome-control-center", "Run systemsettings", "Run kcmshell6", "Run kcmshell5", "Run xfce4-notifyd-config", "Run cinnamon-settings", "Read the environment variable XDG_CURRENT_DESKTOP"] +commands = ["Notifications", "Toggle Do Not Disturb"] + diff --git a/registry/notion.toml b/registry/notion.toml new file mode 100644 index 0000000..6dca4b5 --- /dev/null +++ b/registry/notion.toml @@ -0,0 +1,14 @@ +# registry/notion.toml +id = "notion" +title = "Notion" +description = "Search the pages and databases shared with your integration, read them, add text to a page, create pages in a database" +author = "Backslash" +icon = "bs-note" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/notion" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:78030a7d7511b47df5599083e94cbf75595415be6cbf5e317dbf542ad02557f7" +permissions = ["Access api.notion.com"] +commands = ["Search Notion", "Add to Notion Page", "Create Notion Page"] + diff --git a/registry/obsidian.toml b/registry/obsidian.toml new file mode 100644 index 0000000..00b9163 --- /dev/null +++ b/registry/obsidian.toml @@ -0,0 +1,14 @@ +# registry/obsidian.toml +id = "obsidian" +title = "Obsidian" +description = "Search your Obsidian notes by title, tag and text, preview them, open today's daily note and capture ideas" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/obsidian" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:b1bab4678df0c99e346629871d787bfb5881928d4682f90fd0c064ead6043815" +permissions = ["Read ~", "Read the environment variable HOME"] +commands = ["Search Obsidian Notes", "Open Obsidian Daily Note", "Capture to Obsidian", "Open Obsidian Vault"] + diff --git a/registry/ocr.toml b/registry/ocr.toml new file mode 100644 index 0000000..90bf5eb --- /dev/null +++ b/registry/ocr.toml @@ -0,0 +1,14 @@ +# registry/ocr.toml +id = "ocr" +title = "Text from Screen" +description = "Select any part of the screen and copy the text in it, recognized offline with Tesseract" +author = "Backslash" +icon = "bs-scan" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/ocr" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:b13950d989df9dde50a3437f6af8fcdf9287310e81a1733ec17805df70a896a1" +permissions = ["Run systemd-run", "Run tesseract", "Run grim", "Run slurp", "Run gnome-screenshot", "Run spectacle", "Run maim", "Run scrot", "Run import", "Read ~/.cache/backslash/ocr", "Read /etc/os-release", "Write ~/.cache/backslash/ocr", "Read the environment variable HOME", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK"] +commands = ["Copy Text from Screen", "Text from Screen"] + diff --git a/registry/passwords.toml b/registry/passwords.toml new file mode 100644 index 0000000..8007456 --- /dev/null +++ b/registry/passwords.toml @@ -0,0 +1,14 @@ +# registry/passwords.toml +id = "passwords" +title = "Passwords" +description = "Copy passwords, usernames and one-time codes from pass, Bitwarden, 1Password and KeePassXC" +author = "Backslash" +icon = "bs-key" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/passwords" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:2cbb41612dab41ffa8607c71bd20e6857bdac106db2b99f8a2cb5c7af644d562" +permissions = ["Run pass", "Run bw", "Run op", "Run keepassxc-cli", "Read ~/.password-store", "Read the environment variable HOME", "Read the environment variable PASSWORD_STORE_DIR", "Read the environment variable BW_SESSION"] +commands = ["Search Passwords"] + diff --git a/registry/power.toml b/registry/power.toml new file mode 100644 index 0000000..cd9d3ba --- /dev/null +++ b/registry/power.toml @@ -0,0 +1,14 @@ +# registry/power.toml +id = "power" +title = "Power" +description = "Switch power profiles and check your battery's charge, health, cycles and time left" +author = "Backslash" +icon = "bs-battery" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/power" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:9c9a3e2359562d34eaceeadd456e999abddf9fa31f3cde1df71e212d57d7069e" +permissions = ["Run powerprofilesctl", "Run tuned-adm", "Run udevadm", "Run upower"] +commands = ["Power", "Set Power Profile", "Next Power Profile"] + diff --git a/registry/quick-notes.toml b/registry/quick-notes.toml new file mode 100644 index 0000000..208535c --- /dev/null +++ b/registry/quick-notes.toml @@ -0,0 +1,14 @@ +# registry/quick-notes.toml +id = "quick-notes" +title = "Quick Notes" +description = "Jot, find, pin and paste notes kept as plain markdown files, ready for Obsidian, your editor or sync" +author = "Backslash" +icon = "bs-note" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/quick-notes" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:88757e0cc572436bd98fd7bc8d23054956221f0e8fe7d9acd4cd42c39f175ef5" +permissions = ["Read ~/Documents", "Write ~/Documents", "Read the environment variable HOME"] +commands = ["Search Quick Notes", "New Quick Note", "Capture Quick Note"] + diff --git a/registry/screenshot.toml b/registry/screenshot.toml new file mode 100644 index 0000000..7cca0ce --- /dev/null +++ b/registry/screenshot.toml @@ -0,0 +1,14 @@ +# registry/screenshot.toml +id = "screenshot" +title = "Screenshot & Recording" +description = "Capture an area, a window or the whole screen, record the screen, and find your recent captures" +author = "Backslash" +icon = "bs-camera" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/screenshot" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:df741cd33f5a61ba5a2741d34149a9a6c1d152d9e75ba448db5585562b2b28d5" +permissions = ["Run systemd-run", "Run systemctl", "Run grim", "Run slurp", "Run gnome-screenshot", "Run spectacle", "Run maim", "Run scrot", "Run import", "Run wf-recorder", "Run wl-screenrec", "Run ffmpeg", "Run slop", "Run hyprctl", "Run swaymsg", "Run wl-copy", "Run xclip", "Run gdbus", "Read ~/Pictures", "Read ~/Images", "Read ~/Videos", "Read ~/Vidéos", "Read ~/.config/user-dirs.dirs", "Read /etc/os-release", "Write ~/Pictures", "Write ~/Images", "Write ~/Videos", "Write ~/Vidéos", "Read the environment variable HOME", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK"] +commands = ["Screenshots & Recordings", "Capture Area", "Capture Window", "Capture Full Screen", "Toggle Screen Recording", "Toggle Area Recording"] + diff --git a/registry/sentry.toml b/registry/sentry.toml new file mode 100644 index 0000000..f65abf8 --- /dev/null +++ b/registry/sentry.toml @@ -0,0 +1,14 @@ +# registry/sentry.toml +id = "sentry" +title = "Sentry" +description = "Unresolved issues across your projects with events, users and assignee; resolve, ignore, assign; latest event with its stack trace (sentry.io, US and EU regions)" +author = "Backslash" +icon = "bs-bug" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/sentry" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:382828f4222336cd6108b43c2c1278da7c4690a8291a7bcb344da45785f0c615" +permissions = ["Access sentry.io", "Access us.sentry.io", "Access de.sentry.io"] +commands = ["Sentry Issues"] + diff --git a/registry/spotify.toml b/registry/spotify.toml new file mode 100644 index 0000000..e420498 --- /dev/null +++ b/registry/spotify.toml @@ -0,0 +1,14 @@ +# registry/spotify.toml +id = "spotify" +title = "Spotify" +description = "Search Spotify, play on any of your devices, queue songs, browse your playlists and save what's playing to your library" +author = "Backslash" +icon = "bs-music" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/spotify" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:f9918a26d9d2af306cf3d683f3e7b19a3dc770dc40e73f5052bb04b7e3d46224" +permissions = ["Access api.spotify.com", "Access accounts.spotify.com", "Access i.scdn.co", "Access mosaic.scdn.co", "Access image-cdn-ak.spotifycdn.com", "Access image-cdn-fa.spotifycdn.com"] +commands = ["Search Spotify", "My Spotify Playlists", "Spotify Now Playing"] + diff --git a/registry/ssh.toml b/registry/ssh.toml new file mode 100644 index 0000000..45f4f7a --- /dev/null +++ b/registry/ssh.toml @@ -0,0 +1,14 @@ +# registry/ssh.toml +id = "ssh" +title = "SSH Hosts" +description = "Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager" +author = "Backslash" +icon = "bs-server" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/ssh" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:f12d10ea79f47b307fca16ce5e4e00137afac3dbc496f6321a775675af130dd4" +permissions = ["Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read ~/.ssh", "Read the environment variable HOME"] +commands = ["SSH Hosts"] + diff --git a/registry/system-monitor.toml b/registry/system-monitor.toml new file mode 100644 index 0000000..8df9a2c --- /dev/null +++ b/registry/system-monitor.toml @@ -0,0 +1,14 @@ +# registry/system-monitor.toml +id = "system-monitor" +title = "System Monitor" +description = "Live CPU, memory, disk, network, GPU and battery usage, and the processes behind it" +author = "Backslash" +icon = "bs-activity" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/system-monitor" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:cc40d792bc1a78601031ad2849fb454d2bb1c3d367804c6153730e6319780721" +permissions = ["Run grep", "Run ps", "Run df", "Run kill", "Run nvidia-smi", "Read /etc/os-release"] +commands = ["System Monitor", "Top Processes"] + diff --git a/registry/terminal-sessions.toml b/registry/terminal-sessions.toml new file mode 100644 index 0000000..60b6bc0 --- /dev/null +++ b/registry/terminal-sessions.toml @@ -0,0 +1,14 @@ +# registry/terminal-sessions.toml +id = "terminal-sessions" +title = "Terminal Sessions" +description = "Attach to, create, rename and kill your tmux and zellij sessions" +author = "Backslash" +icon = "bs-terminal" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/terminal-sessions" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:24e92e1053d376791573129a6c6f3b1662eaef4c620d58df8922b822ccd5aa4f" +permissions = ["Run tmux", "Run zellij", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read the environment variable HOME"] +commands = ["Terminal Sessions"] + diff --git a/registry/updates.toml b/registry/updates.toml new file mode 100644 index 0000000..fc0ea38 --- /dev/null +++ b/registry/updates.toml @@ -0,0 +1,14 @@ +# registry/updates.toml +id = "updates" +title = "System Updates" +description = "Available updates from APT, DNF, Pacman, Zypper, Flatpak, Snap and firmware, applied in a terminal" +author = "Backslash" +icon = "bs-download" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/updates" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:7307a33b2fb1ce2057778541a61705e2906bdcf28af6cb8d30f162f00fa12145" +permissions = ["Run apt", "Run dnf", "Run rpm", "Run pacman", "Run checkupdates", "Run zypper", "Run flatpak", "Run snap", "Run fwupdmgr", "Run x-terminal-emulator", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run foot", "Run kitty", "Run alacritty", "Run wezterm", "Run ghostty", "Run xterm", "Read /etc/os-release", "Read the environment variable XDG_CURRENT_DESKTOP"] +commands = ["System Updates"] + diff --git a/registry/weather.toml b/registry/weather.toml new file mode 100644 index 0000000..117f157 --- /dev/null +++ b/registry/weather.toml @@ -0,0 +1,14 @@ +# registry/weather.toml +id = "weather" +title = "Weather" +description = "Current conditions, the next 24 hours and the week ahead for any city, from Open-Meteo" +author = "Backslash" +icon = "bs-cloud-sun" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/weather" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:3d140cafc2f16f97884f9bb614288abfcd23f536ffcc3f8fa9fe0198fcf04d2a" +permissions = ["Access geocoding-api.open-meteo.com", "Access api.open-meteo.com"] +commands = ["Weather"] + diff --git a/registry/web-search.toml b/registry/web-search.toml new file mode 100644 index 0000000..07165c3 --- /dev/null +++ b/registry/web-search.toml @@ -0,0 +1,14 @@ +# registry/web-search.toml +id = "web-search" +title = "Web Search" +description = "Search the web with live suggestions, 50 !bangs and your recent searches, on the engine you prefer" +author = "Backslash" +icon = "bs-search" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/web-search" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:365dc3e43a9d19e1d773c7a1b4308c1dc9585cb432c003fe1cd7466875b59c9e" +permissions = ["Access duckduckgo.com", "Access suggestqueries.google.com"] +commands = ["Search the Web"] + diff --git a/registry/world-clock.toml b/registry/world-clock.toml new file mode 100644 index 0000000..e43a212 --- /dev/null +++ b/registry/world-clock.toml @@ -0,0 +1,14 @@ +# registry/world-clock.toml +id = "world-clock" +title = "World Clock" +description = "The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris”" +author = "Backslash" +icon = "bs-clock" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/world-clock" +commit = "1013c00ada454c2eb0f6b03bcdb35dfec887562f" +checksum = "sha256:cc52af74d04d4606801e66c2c4bbb3cc2ffed3618dc12203312c67b14c717d14" +permissions = [] +commands = ["World Clock", "Add World Clock City"] + From e4fe9959069eddd97526652f4f48deb8bb94d63f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:34:25 +0000 Subject: [PATCH 10/10] CI: tests may use files, the environment and programs, not the network Several new tests check generated shell scripts with `sh -n`, drive fake CLIs or create temporary vaults and notes; they need those permissions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- .github/workflows/check.yml | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 30eb53f..ddee272 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,7 @@ jobs: - run: deno fmt --check extensions scripts - run: deno lint extensions scripts - run: deno check extensions/*/commands/*.ts - - run: deno test extensions + # Tests may use files, the environment and programs (fake CLIs, `sh -n`), never the network. + - run: deno test --allow-read --allow-write --allow-env --allow-run extensions - name: Verify registry entries (clones each extension at its commit) run: deno run -A scripts/validate.ts diff --git a/README.md b/README.md index 56becb0..3ca0074 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,6 @@ SDK), which look the same on every desktop. ```sh git clone https://github.com/backslash-app/backslash .backslash # for the SDK types deno check extensions/*/commands/*.ts -deno test extensions # parsers of each extension (lib_test.ts) +deno test --allow-read --allow-write --allow-env --allow-run extensions # what CI runs backslash dev extensions/github # live reload in the launcher ```