From f80443255e7adc3a84edff4781b2035bc8cd0d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:28:34 +0000 Subject: [PATCH 01/10] Fonts, Startup Apps, System Logs, Printers, Privacy and Default Apps Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/default-apps/backslash.toml | 50 ++ .../default-apps/commands/default-apps.ts | 258 +++++++++++ extensions/default-apps/lib.ts | 249 ++++++++++ extensions/default-apps/lib_test.ts | 131 ++++++ extensions/fonts/backslash.toml | 44 ++ extensions/fonts/commands/fonts.ts | 267 +++++++++++ extensions/fonts/commands/install-font.ts | 5 + extensions/fonts/install.ts | 52 +++ extensions/fonts/lib.ts | 168 +++++++ extensions/fonts/lib_test.ts | 107 +++++ extensions/printers/backslash.toml | 48 ++ extensions/printers/commands/printers.ts | 426 ++++++++++++++++++ extensions/printers/lib.ts | 236 ++++++++++ extensions/printers/lib_test.ts | 131 ++++++ extensions/privacy/backslash.toml | 51 +++ extensions/privacy/commands/privacy.ts | 352 +++++++++++++++ extensions/privacy/lib.ts | 357 +++++++++++++++ extensions/privacy/lib_test.ts | 218 +++++++++ extensions/startup-apps/backslash.toml | 40 ++ .../startup-apps/commands/startup-apps.ts | 409 +++++++++++++++++ extensions/startup-apps/lib.ts | 293 ++++++++++++ extensions/startup-apps/lib_test.ts | 172 +++++++ extensions/system-logs/backslash.toml | 54 +++ extensions/system-logs/commands/logs.ts | 352 +++++++++++++++ extensions/system-logs/lib.ts | 284 ++++++++++++ extensions/system-logs/lib_test.ts | 157 +++++++ extensions/system-logs/terminal.ts | 77 ++++ 27 files changed, 4988 insertions(+) create mode 100644 extensions/default-apps/backslash.toml create mode 100644 extensions/default-apps/commands/default-apps.ts create mode 100644 extensions/default-apps/lib.ts create mode 100644 extensions/default-apps/lib_test.ts create mode 100644 extensions/fonts/backslash.toml create mode 100644 extensions/fonts/commands/fonts.ts create mode 100644 extensions/fonts/commands/install-font.ts create mode 100644 extensions/fonts/install.ts create mode 100644 extensions/fonts/lib.ts create mode 100644 extensions/fonts/lib_test.ts create mode 100644 extensions/printers/backslash.toml create mode 100644 extensions/printers/commands/printers.ts create mode 100644 extensions/printers/lib.ts create mode 100644 extensions/printers/lib_test.ts create mode 100644 extensions/privacy/backslash.toml create mode 100644 extensions/privacy/commands/privacy.ts create mode 100644 extensions/privacy/lib.ts create mode 100644 extensions/privacy/lib_test.ts create mode 100644 extensions/startup-apps/backslash.toml create mode 100644 extensions/startup-apps/commands/startup-apps.ts create mode 100644 extensions/startup-apps/lib.ts create mode 100644 extensions/startup-apps/lib_test.ts create mode 100644 extensions/system-logs/backslash.toml create mode 100644 extensions/system-logs/commands/logs.ts create mode 100644 extensions/system-logs/lib.ts create mode 100644 extensions/system-logs/lib_test.ts create mode 100644 extensions/system-logs/terminal.ts diff --git a/extensions/default-apps/backslash.toml b/extensions/default-apps/backslash.toml new file mode 100644 index 0000000..2604c93 --- /dev/null +++ b/extensions/default-apps/backslash.toml @@ -0,0 +1,50 @@ +[extension] +id = "default-apps" +title = "Default Apps" +description = "See and change your default browser, mail app, file manager, viewers, players, editor and terminal" +version = "1.0.0" +author = "Backslash" +icon = "bs-apps" +license = "MIT" + +# xdg-mime and xdg-settings (xdg-utils) read and change the defaults, the way +# every desktop does. The applications folders are read to find the apps that +# can open each type. The terminal is set in ~/.config/xdg-terminals.list +# (xdg-terminal-exec). +[permissions] +run = ["xdg-mime", "xdg-settings"] +read = [ + "~/.local/share/applications", + "~/.local/share/flatpak/exports/share/applications", + "/usr/share/applications", + "/usr/local/share/applications", + "/var/lib/flatpak/exports/share/applications", + "/var/lib/snapd/desktop/applications", + "~/.nix-profile/share/applications", + "/run/current-system/sw/share/applications", + "~/.config/xdg-terminals.list", + "/etc/os-release", +] +write = ["~/.config/xdg-terminals.list"] +env = ["HOME", "XDG_DATA_HOME", "XDG_DATA_DIRS", "TERMINAL"] + +[[commands]] +name = "default-apps" +title = "Default Apps" +description = "The app that opens links, folders, pictures, videos, music, PDFs, text and terminals" +icon = "bs-apps" +keywords = [ + "default apps", + "applications par défaut", + "default browser", + "navigateur par défaut", + "open with", + "ouvrir avec", + "mime", + "file association", + "association de fichiers", + "xdg-mime", + "mimeapps", + "terminal", + "pdf", +] diff --git a/extensions/default-apps/commands/default-apps.ts b/extensions/default-apps/commands/default-apps.ts new file mode 100644 index 0000000..48807e5 --- /dev/null +++ b/extensions/default-apps/commands/default-apps.ts @@ -0,0 +1,258 @@ +import { + type Action, + action, + type Color, + copy, + exec, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + toast, +} from "@backslash/api"; +import { + type App, + candidates, + CATEGORIES, + type Category, + type Current, + currentDefault, + desktopId, + execProgram, + fallbackName, + INSTALL_XDG_UTILS, + mergeApps, + osFamily, + parseApp, + parseQuery, + parseTerminalsList, + plural, + preferTerminal, + terminalFromEnv, +} from "../lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +const CONFIG = `${HOME}/.config`; +const TERMINALS_LIST = `${CONFIG}/xdg-terminals.list`; + +/** Application folders in XDG order: the user's first. Only those in the manifest can be read. */ +function appDirs(): string[] { + const dataHome = Deno.env.get("XDG_DATA_HOME") || `${HOME}/.local/share`; + const dataDirs = (Deno.env.get("XDG_DATA_DIRS") || "/usr/local/share:/usr/share").split(":").filter(Boolean); + return [ + ...new Set([ + `${dataHome}/applications`, + `${HOME}/.local/share/flatpak/exports/share/applications`, + ...dataDirs.map((d) => `${d.replace(/\/$/, "")}/applications`), + "/var/lib/flatpak/exports/share/applications", + "/var/lib/snapd/desktop/applications", + `${HOME}/.nix-profile/share/applications`, + "/run/current-system/sw/share/applications", + ]), + ]; +} + +async function readApps(dir: string): Promise { + const apps: App[] = []; + const walk = async (path: string, rel: string, depth: number) => { + let entries: Deno.DirEntry[]; + try { + entries = await Array.fromAsync(Deno.readDir(path)); + } catch { + return; // Missing, or not in our read permission. + } + await Promise.all(entries.map(async (e) => { + const full = `${path}/${e.name}`; + const relative = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory && depth < 3) return walk(full, relative, depth + 1); + if (!e.name.endsWith(".desktop")) return; + const text = await Deno.readTextFile(full).catch(() => undefined); + const app = text === undefined ? undefined : parseApp(desktopId(relative), full, text); + if (app) apps.push(app); + })); + }; + await walk(dir, "", 0); + return apps; +} + +class NoXdgUtils extends Error {} + +async function xdg(program: "xdg-mime" | "xdg-settings", args: string[]): Promise { + try { + return await exec(program, args); + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new NoXdgUtils(program); + throw err; + } +} + +async function terminalDefault(apps: App[]): Promise { + const text = await Deno.readTextFile(TERMINALS_LIST).catch(() => undefined); + const first = text && parseTerminalsList(text).find((id) => apps.some((a) => a.id === id)); + return first || terminalFromEnv(Deno.env.get("TERMINAL"), apps); +} + +interface State { + apps: Map; + current: Map; +} + +async function load(): Promise { + const apps = mergeApps(await Promise.all(appDirs().map(readApps))); + const mimes = [...new Set(CATEGORIES.flatMap((c) => c.mimes))]; + const byMime: Record = {}; + // xdg-mime is a shell script: ask in parallel. + await Promise.all(mimes.map(async (m) => { + byMime[m] = parseQuery( + await xdg("xdg-mime", ["query", "default", m]).catch((err) => { + if (err instanceof NoXdgUtils) throw err; + return ""; + }), + ); + })); + const browser = parseQuery(await xdg("xdg-settings", ["get", "default-web-browser"]).catch(() => "")); + const terminals = [...apps.values()].filter((a) => a.categories.includes("TerminalEmulator")); + const terminal = await terminalDefault(terminals); + const current = new Map(CATEGORIES.map((c) => { + const setting = c.id === "browser" ? browser : c.id === "terminal" ? terminal : undefined; + return [c.id, currentDefault(c, byMime, setting)]; + })); + return { apps, current }; +} + +async function setDefault(cat: Category, app: App) { + if (cat.id === "terminal") { + const text = await Deno.readTextFile(TERMINALS_LIST).catch(() => ""); + await Deno.writeTextFile(TERMINALS_LIST, preferTerminal(text, app.id)); + return; + } + if (cat.setting) { + // xdg-settings also sets the http/https handlers its desktop knows about. + await xdg("xdg-settings", ["set", cat.setting, app.id]).catch(() => {}); + } + // Every type of the category the app can open (all of them when it lists none of the extras). + const own = cat.mimes.filter((m) => app.mimes.includes(m)); + await xdg("xdg-mime", ["default", app.id, ...(own.length ? own : cat.mimes)]); +} + +const ICONS: Record = { + browser: [Icons.globe, "blue"], + mail: [Icons.mail, "teal"], + files: [Icons.folder, "yellow"], + images: [Icons.image, "green"], + video: [Icons.film, "red"], + music: [Icons.music, "pink"], + pdf: [Icons.file, "orange"], + text: [Icons.text, "purple"], + terminal: [Icons.terminal, "gray"], +}; + +function categoryItem(cat: Category, st: State): Item { + const cur = st.current.get(cat.id)!; + const app = cur.id ? st.apps.get(cur.id) : undefined; + const name = app?.name ?? (cur.id ? fallbackName(cur.id) : undefined); + const options = candidates(cat, st.apps.values()); + const [glyph, tint] = ICONS[cat.id]; + const nameOf = (id?: string) => (id ? st.apps.get(id)?.name ?? fallbackName(id) : "—"); + + const change = submenu( + "Change Default", + () => + options.map((o) => + action(o.name + (o.noDisplay ? " (hidden app)" : ""), async () => { + await setDefault(cat, o); + await toast(`${o.name} is your ${cat.title.toLowerCase()}`, { style: "success" }); + return refresh(); + }, { icon: o.id === cur.id ? Icons.check : Icons.apps }) + ), + { icon: Icons.apps, shortcut: "e" }, + ); + + // The detail's markdown has no tables: one bullet per type. + const types = cat.mimes.map((m) => { + const id = cur.byMime[m]; + const odd = id && id !== cur.id; + return `- \`${m}\` → ${id ? (odd ? `**${nameOf(id)}**` : nameOf(id)) : "*not set*"}`; + }).join("\n"); + + const actions: Action[] = [ + ...(options.length ? [change] : []), + ...(cur.mixed && app + ? [action(`Use ${app.name} for All Types`, async () => { + await setDefault(cat, app); + await toast(`${app.name} opens every ${cat.title.toLowerCase()} type`, { style: "success" }); + return refresh(); + }, { icon: Icons.check })] + : []), + ...(app ? [open(app.path, "Open Desktop File", { icon: Icons.file, section: "Files" })] : []), + cat.id === "terminal" + ? open(TERMINALS_LIST, "Open xdg-terminals.list", { icon: Icons.file, section: "Files" }) + : open(`${CONFIG}/mimeapps.list`, "Open mimeapps.list", { icon: Icons.file, section: "Files" }), + ...(cur.id ? [copy(cur.id, "Copy Desktop ID", { section: "Copy" })] : []), + ...(cat.mimes.length ? [copy(cat.mimes.join("\n"), "Copy MIME Types", { section: "Copy" })] : []), + ]; + + return { + id: cat.id, + title: cat.title, + subtitle: name ?? "Not set", + icon: { source: glyph, tint: name ? tint : "gray" }, + keywords: [...cat.mimes, cur.id ?? "", name ?? ""], + accessories: [ + ...(cur.mixed ? [{ tag: "Mixed", color: "orange" as const, tooltip: "Some types open with another app" }] : []), + ...(!name ? [{ tag: "Not set", color: "gray" as const }] : []), + { text: plural(options.length, "app"), tooltip: "Installed apps that can do it" }, + ], + detail: { + markdown: [cat.description, types, cur.mixed ? "Types in **bold** open with another app." : ""].filter(Boolean) + .join("\n\n"), + metadata: [ + meta.text("Default", name ?? "Not set", { icon: glyph, color: name ? undefined : "gray" }), + ...(cur.id ? [meta.text("Desktop ID", cur.id)] : []), + ...(app?.exec ? [meta.text("Program", execProgram(app.exec), { icon: Icons.terminal })] : []), + ...(cur.id && !app ? [meta.text("Installed", "No .desktop file found", { color: "orange" })] : []), + meta.separator(), + ...(cat.mimes.length ? [meta.tags("Types", cat.mimes.map((m) => m.replace("x-scheme-handler/", "")))] : []), + meta.tags( + "Can do it", + options.slice(0, 8).map((o) => ({ text: o.name, color: o.id === cur.id ? "blue" as const : undefined })), + ), + ], + }, + actions, + }; +} + +async function setupItem(): Promise { + const family = osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); + const commands = family ? [INSTALL_XDG_UTILS[family]] : Object.values(INSTALL_XDG_UTILS); + return { + id: "no-xdg-utils", + title: "xdg-utils isn't installed", + subtitle: "Default apps are read and changed with xdg-mime and xdg-settings", + icon: { source: Icons.package, tint: "gray" }, + detail: { markdown: ["Install xdg-utils:", "", "```", ...commands, "```"].join("\n") }, + actions: [copy(commands[0], "Copy Install Command")], + }; +} + +export default list({ + placeholder: "Filter default apps or MIME types…", + status: (items) => { + const mixed = items.filter((i) => i.accessories?.some((a) => a.tag === "Mixed")).length; + return `${items.length} ${items.length === 1 ? "category" : "categories"}${mixed ? ` · ${mixed} mixed` : ""}`; + }, + items: async () => { + let st: State; + try { + st = await load(); + } catch (err) { + if (err instanceof NoXdgUtils) return [await setupItem()]; + throw err; + } + return CATEGORIES.map((c) => categoryItem(c, st)); + }, +}); diff --git a/extensions/default-apps/lib.ts b/extensions/default-apps/lib.ts new file mode 100644 index 0000000..7daa94a --- /dev/null +++ b/extensions/default-apps/lib.ts @@ -0,0 +1,249 @@ +// Pure helpers for Default Apps: categories, .desktop files and MIME handlers. + +export interface Category { + id: string; + title: string; + /** Representative MIME types; the first one decides what "the default" is. */ + mimes: string[]; + /** Apps are found by a Categories= value instead of MIME types (terminals). */ + desktopCategory?: string; + /** Set with `xdg-settings set ` rather than `xdg-mime default`. */ + setting?: string; + description: string; +} + +export const CATEGORIES: Category[] = [ + { + id: "browser", + title: "Web Browser", + mimes: ["x-scheme-handler/http", "x-scheme-handler/https", "text/html", "application/xhtml+xml"], + setting: "default-web-browser", + description: "Opens links from every app.", + }, + { + id: "mail", + title: "Mail", + mimes: ["x-scheme-handler/mailto", "message/rfc822"], + description: "Opens mailto: links and saved emails.", + }, + { + id: "files", + title: "File Manager", + mimes: ["inode/directory"], + description: "Opens folders, e.g. “Show in folder” in apps.", + }, + { + id: "images", + title: "Image Viewer", + mimes: [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "image/bmp", + "image/tiff", + "image/avif", + ], + description: "Opens pictures.", + }, + { + id: "video", + title: "Video Player", + mimes: ["video/mp4", "video/x-matroska", "video/webm", "video/quicktime", "video/x-msvideo", "video/mpeg"], + description: "Plays video files.", + }, + { + id: "music", + title: "Music Player", + mimes: ["audio/mpeg", "audio/flac", "audio/ogg", "audio/x-wav", "audio/mp4", "audio/x-vorbis+ogg", "audio/opus"], + description: "Plays audio files.", + }, + { + id: "pdf", + title: "PDF Viewer", + mimes: ["application/pdf"], + description: "Opens PDF documents.", + }, + { + id: "text", + title: "Text Editor", + mimes: [ + "text/plain", + "text/markdown", + "application/x-shellscript", + "text/x-python", + "application/json", + "text/x-csrc", + "application/xml", + ], + description: "Opens plain text, code and config files.", + }, + { + id: "terminal", + title: "Terminal", + mimes: [], + desktopCategory: "TerminalEmulator", + description: + "Saved in ~/.config/xdg-terminals.list, read by xdg-terminal-exec, which desktops and apps use to open a terminal.", + }, +]; + +export interface App { + /** Desktop file id, e.g. `org.gnome.Nautilus.desktop`. */ + id: string; + name: string; + exec: string; + mimes: string[]; + categories: string[]; + noDisplay: boolean; + path: string; +} + +/** The [Desktop Entry] group of a .desktop file, translations left out. */ +export function parseDesktop(text: string): Record { + const out: Record = {}; + let inEntry = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("[")) { + inEntry = line === "[Desktop Entry]"; + continue; + } + const eq = line.indexOf("="); + if (!inEntry || eq < 0) continue; + const key = line.slice(0, eq).trim(); + if (!key.includes("[")) out[key] = line.slice(eq + 1).trim(); + } + return out; +} + +const list = (v?: string) => (v ?? "").split(";").map((s) => s.trim()).filter(Boolean); + +/** An application entry, or undefined for hidden entries, links and directories. */ +export function parseApp(id: string, path: string, text: string): App | undefined { + const e = parseDesktop(text); + if ((e.Type ?? "Application") !== "Application" || e.Hidden?.toLowerCase() === "true" || !e.Name) return undefined; + return { + id, + name: e.Name, + exec: e.Exec ?? "", + mimes: list(e.MimeType), + categories: list(e.Categories), + noDisplay: e.NoDisplay?.toLowerCase() === "true", + path, + }; +} + +/** The desktop id of a file below an applications folder: `kde4/okular.desktop` → `kde4-okular.desktop`. */ +export function desktopId(relative: string): string { + return relative.replace(/^\/+/, "").replace(/\//g, "-"); +} + +/** First folder wins: the user's data folder shadows the system's. */ +export function mergeApps(byDir: App[][]): Map { + const out = new Map(); + for (const apps of byDir) for (const a of apps) if (!out.has(a.id)) out.set(a.id, a); + return out; +} + +/** Apps able to handle a category, the ones covering the most of its MIME types first. */ +export function candidates(cat: Category, apps: Iterable): App[] { + const scored: { app: App; score: number }[] = []; + for (const app of apps) { + const score = cat.desktopCategory + ? (app.categories.includes(cat.desktopCategory) ? 1 : 0) + : cat.mimes.filter((m) => app.mimes.includes(m)).length; + if (score > 0) scored.push({ app, score }); + } + return scored.sort((a, b) => + Number(a.app.noDisplay) - Number(b.app.noDisplay) || b.score - a.score || + a.app.name.localeCompare(b.app.name, undefined, { sensitivity: "base" }) + ).map((s) => s.app); +} + +export interface Current { + /** The app most of the category's types open with. */ + id?: string; + /** Types opening with something else. */ + mixed: boolean; + byMime: Record; +} + +/** The default of a category from each MIME type's default (`xdg-mime query default`). */ +export function currentDefault(cat: Category, byMime: Record, setting?: string): Current { + const ids = cat.mimes.map((m) => byMime[m]).filter((x): x is string => !!x); + const counts = new Map(); + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); + const first = cat.mimes.length ? byMime[cat.mimes[0]] : undefined; + const id = setting || first || [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; + const mixed = cat.mimes.some((m) => byMime[m] && byMime[m] !== id); + return { id, mixed, byMime }; +} + +/** `xdg-mime query default` output: one desktop id, or nothing. Some setups list several, `;`-separated. */ +export function parseQuery(out: string): string | undefined { + const id = out.trim().split(/[;\n]/)[0]?.trim(); + return id && id.endsWith(".desktop") ? id : undefined; +} + +/** xdg-terminals.list: desktop ids (optionally `:action`), comments with #. */ +export function parseTerminalsList(text: string): string[] { + return text.split("\n").map((l) => l.replace(/#.*$/, "").trim()).filter(Boolean).map((l) => l.split(":")[0]); +} + +/** The list with `id` first, the others kept after it. */ +export function preferTerminal(text: string, id: string): string { + const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")); + const rest = lines.filter((l) => l.split(":")[0] !== id); + return [id, ...rest].join("\n") + "\n"; +} + +/** The program of an Exec line: `env X=1 /usr/bin/foot --server %U` → `foot`. */ +export function execProgram(exec: string): string { + const words = exec.replace(/%[a-zA-Z]/g, "").trim().split(/\s+/); + let i = 0; + if (words[i] === "env") i++; + while (words[i] && /^\w+=/.test(words[i])) i++; + return (words[i] ?? "").replace(/^["']|["']$/g, "").split("/").pop() ?? ""; +} + +/** The terminal app matching $TERMINAL (a program name or path). */ +export function terminalFromEnv(env: string | undefined, apps: App[]): string | undefined { + if (!env) return undefined; + const prog = env.trim().split(/\s+/)[0].split("/").pop(); + return apps.find((a) => execProgram(a.exec) === prog || a.id === `${prog}.desktop`)?.id; +} + +/** A friendly name for an id with no installed .desktop file. */ +export function fallbackName(id: string): string { + const base = id.replace(/\.desktop$/, "").split(".").pop() ?? id; + return base.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +// --------------------------------------------------------------------------- +// 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 INSTALL_XDG_UTILS: Record = { + debian: "sudo apt install xdg-utils", + fedora: "sudo dnf install xdg-utils", + arch: "sudo pacman -S xdg-utils", + suse: "sudo zypper install xdg-utils", +}; diff --git a/extensions/default-apps/lib_test.ts b/extensions/default-apps/lib_test.ts new file mode 100644 index 0000000..2f879f6 --- /dev/null +++ b/extensions/default-apps/lib_test.ts @@ -0,0 +1,131 @@ +import { assert, assertEquals } from "@std/assert"; +import { + type App, + candidates, + CATEGORIES, + currentDefault, + desktopId, + execProgram, + fallbackName, + mergeApps, + osFamily, + parseApp, + parseQuery, + parseTerminalsList, + preferTerminal, + terminalFromEnv, +} from "./lib.ts"; + +const cat = (id: string) => CATEGORIES.find((c) => c.id === id)!; + +const FIREFOX = `[Desktop Entry] +Version=1.0 +Name=Firefox +Name[fr]=Firefox +GenericName=Web Browser +Exec=/usr/lib/firefox/firefox %u +Type=Application +MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;application/pdf; +Categories=Network;WebBrowser; + +[Desktop Action new-window] +Name=New Window +Exec=/usr/lib/firefox/firefox --new-window %u +MimeType=should/not-count; +`; + +const app = (id: string, name: string, mimes: string[], extra: Partial = {}): App => ({ + id, + name, + exec: id.replace(".desktop", ""), + mimes, + categories: [], + noDisplay: false, + path: `/usr/share/applications/${id}`, + ...extra, +}); + +Deno.test("parseApp reads the main group and skips hidden entries", () => { + const ff = parseApp("firefox.desktop", "/usr/share/applications/firefox.desktop", FIREFOX)!; + assertEquals(ff.name, "Firefox"); + assertEquals(ff.mimes.length, 6); + assert(!ff.mimes.includes("should/not-count")); + assertEquals(ff.categories, ["Network", "WebBrowser"]); + assertEquals(parseApp("x.desktop", "/x", "[Desktop Entry]\nName=X\nHidden=true\n"), undefined); + assertEquals(parseApp("x.desktop", "/x", "[Desktop Entry]\nName=X\nType=Link\n"), undefined); + assert(parseApp("x.desktop", "/x", "[Desktop Entry]\nName=X\nNoDisplay=true\n")!.noDisplay); +}); + +Deno.test("desktopId and mergeApps follow the XDG rules", () => { + assertEquals(desktopId("kde4/okular.desktop"), "kde4-okular.desktop"); + const user = app("firefox.desktop", "Firefox (mine)", []); + const system = app("firefox.desktop", "Firefox", []); + assertEquals( + mergeApps([[user], [system, app("vlc.desktop", "VLC", [])]]).get("firefox.desktop")?.name, + "Firefox (mine)", + ); +}); + +Deno.test("candidates ranks apps by how many types they cover, hidden apps last", () => { + const apps = [ + app("org.gnome.Loupe.desktop", "Image Viewer", ["image/png", "image/jpeg", "image/webp", "image/gif"]), + app("gimp.desktop", "GNU Image Manipulation Program", ["image/png", "image/jpeg"]), + app("display-im6.q16.desktop", "ImageMagick", ["image/png"], { noDisplay: true }), + app("vlc.desktop", "VLC", ["video/mp4"]), + ]; + assertEquals(candidates(cat("images"), apps).map((a) => a.id), [ + "org.gnome.Loupe.desktop", + "gimp.desktop", + "display-im6.q16.desktop", + ]); + const foot = app("foot.desktop", "Foot", [], { categories: ["System", "TerminalEmulator"] }); + assertEquals(candidates(cat("terminal"), [...apps, foot]).map((a) => a.id), ["foot.desktop"]); +}); + +Deno.test("currentDefault spots mixed defaults", () => { + const browser = currentDefault(cat("browser"), { + "x-scheme-handler/http": "firefox.desktop", + "x-scheme-handler/https": "firefox.desktop", + "text/html": "firefox.desktop", + "application/xhtml+xml": "chromium.desktop", + }, "firefox.desktop"); + assertEquals([browser.id, browser.mixed], ["firefox.desktop", true]); + const pdf = currentDefault(cat("pdf"), { "application/pdf": "org.gnome.Evince.desktop" }); + assertEquals([pdf.id, pdf.mixed], ["org.gnome.Evince.desktop", false]); + const images = currentDefault(cat("images"), { "image/jpeg": "loupe.desktop", "image/gif": "loupe.desktop" }); + assertEquals([images.id, images.mixed], ["loupe.desktop", false]); + const none = currentDefault(cat("video"), {}); + assertEquals([none.id, none.mixed], [undefined, false]); +}); + +Deno.test("parseQuery reads xdg-mime and xdg-settings answers", () => { + assertEquals(parseQuery("org.gnome.Evince.desktop\n"), "org.gnome.Evince.desktop"); + assertEquals(parseQuery("okularApplication_pdf.desktop;okular.desktop;"), "okularApplication_pdf.desktop"); + assertEquals(parseQuery(""), undefined); + assertEquals(parseQuery("xdg-settings: unknown desktop environment"), undefined); +}); + +Deno.test("xdg-terminals.list", () => { + const text = "# preferred terminals\nkitty.desktop\nfoot.desktop:new-window\n\n"; + assertEquals(parseTerminalsList(text), ["kitty.desktop", "foot.desktop"]); + assertEquals( + preferTerminal(text, "org.gnome.Ptyxis.desktop"), + "org.gnome.Ptyxis.desktop\nkitty.desktop\nfoot.desktop:new-window\n", + ); + assertEquals(preferTerminal(text, "kitty.desktop"), "kitty.desktop\nfoot.desktop:new-window\n"); + assertEquals(preferTerminal("", "foot.desktop"), "foot.desktop\n"); +}); + +Deno.test("execProgram, terminalFromEnv and fallbackName", () => { + assertEquals(execProgram("env GDK_BACKEND=x11 /usr/bin/foot --server %U"), "foot"); + const apps = [ + app("org.wezfurlong.wezterm.desktop", "WezTerm", [], { exec: "wezterm start --cwd ." }), + app("Alacritty.desktop", "Alacritty", [], { exec: "alacritty" }), + ]; + assertEquals(terminalFromEnv("/usr/bin/wezterm", apps), "org.wezfurlong.wezterm.desktop"); + assertEquals(terminalFromEnv("alacritty -e", apps), "Alacritty.desktop"); + assertEquals(terminalFromEnv(undefined, apps), undefined); + assertEquals(fallbackName("org.gnome.Nautilus.desktop"), "Nautilus"); + assertEquals(fallbackName("google-chrome.desktop"), "Google Chrome"); + assertEquals(osFamily("ID=fedora\n"), "fedora"); +}); diff --git a/extensions/fonts/backslash.toml b/extensions/fonts/backslash.toml new file mode 100644 index 0000000..ce0b2bd --- /dev/null +++ b/extensions/fonts/backslash.toml @@ -0,0 +1,44 @@ +[extension] +id = "fonts" +title = "Fonts" +description = "Browse installed font families with previews, copy their CSS and install new ones" +version = "1.0.0" +author = "Backslash" +icon = "bs-font" +license = "MIT" + +# fc-list and fc-cache (fontconfig) list fonts and refresh the cache after an install. +# pango-view (Pango) or ImageMagick's convert, when installed, render previews +# into ~/.cache/backslash/fonts. cp copies a font file from wherever you typed, +# so the extension needs no read access to your folders. +[permissions] +run = ["fc-list", "fc-cache", "pango-view", "convert", "cp"] +read = ["~/.cache/backslash/fonts", "~/.local/share/fonts"] +write = ["~/.cache/backslash/fonts", "~/.local/share/fonts"] +env = ["HOME"] + +[[commands]] +name = "fonts" +title = "Fonts" +description = "Installed font families, with a preview, their styles and files" +icon = "bs-font" +keywords = [ + "font", + "fonts", + "police", + "polices", + "typeface", + "typographie", + "typography", + "monospace", + "css", + "font-family", + "fontconfig", +] + +[[commands]] +name = "install-font" +title = "Install Font" +description = "Copy a .ttf or .otf file into your fonts folder" +icon = "bs-download" +keywords = ["font", "police", "install", "installer", "ttf", "otf", "ajouter"] diff --git a/extensions/fonts/commands/fonts.ts b/extensions/fonts/commands/fonts.ts new file mode 100644 index 0000000..a461a4c --- /dev/null +++ b/extensions/fonts/commands/fonts.ts @@ -0,0 +1,267 @@ +import { + type Action, + action, + copy, + exec, + Icons, + type Item, + list, + meta, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + basename, + cssFontFamily, + dirname, + type Family, + FC_FORMAT, + type FontFace, + groupFamilies, + isFontFile, + pangoFont, + parseFcList, + plural, + previewName, + regularFile, + regularStyle, + SAMPLE, + SAMPLE_EXTRA, + tilde, +} from "../lib.ts"; +import { HOME, installForm, LEGACY_FONTS, PREVIEWS, USER_FONTS } from "../install.ts"; + +type Renderer = "pango-view" | "convert"; + +/** pango-view (Pango) renders by family name; ImageMagick's convert by file. */ +async function findRenderer(): Promise { + for (const [program, flag] of [["pango-view", "--version"], ["convert", "-version"]] as const) { + try { + await exec(program, [flag]); + return program; + } catch (err) { + if (err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable) continue; + return program; // Installed, but grumpy about the flag. + } + } + return undefined; +} + +async function render(renderer: Renderer, fam: Family, file: string | undefined, out: string): Promise { + const text = `${fam.name}\n${SAMPLE}\n${SAMPLE_EXTRA}`; + const markup = `${escapeMarkup(fam.name)}\n${escapeMarkup(`${SAMPLE}\n${SAMPLE_EXTRA}`)}`; + try { + if (renderer === "pango-view") { + // Through a file: pango-view decodes arguments with the locale, which may not be UTF-8. + const input = `${out}.txt`; + await Deno.writeTextFile(input, markup); + await exec("pango-view", [ + "-q", + `--font=${pangoFont(fam.name, 26)}`, + "--background=#ffffff", + "--foreground=#1a1a1a", + "--margin=28", + "--dpi=96", + "--markup", + `--output=${out}`, + input, + ]).finally(() => Deno.remove(input).catch(() => {})); + } else { + if (!file) return false; + await exec("convert", [ + "-background", + "#ffffff", + "-fill", + "#1a1a1a", + "-font", + file, + "-pointsize", + "26", + "-bordercolor", + "#ffffff", + "-border", + "28", + `label:${text}`, + out, + ]); + } + return true; + } catch { + return false; + } +} + +const escapeMarkup = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); + +const exists = (path: string) => Deno.stat(path).then(() => true, () => false); + +function sampleMarkdown(fam: Family, home: string): string { + const files = fam.files.slice(0, 12).map((f) => `- \`${tilde(f, home)}\``); + if (fam.files.length > 12) files.push(`- … and ${fam.files.length - 12} more`); + return [ + "```", + SAMPLE, + SAMPLE.toUpperCase(), + SAMPLE_EXTRA, + fam.mono ? "fn main() { let x = 0x1F; } // 0O Il1| -> => != ===" : "", + "```", + "", + `**${plural(fam.files.length, "file")}**`, + "", + ...files, + ].filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n"); +} + +let total = 0; +const families = (n: number) => `${n} ${n === 1 ? "family" : "families"}`; +let rendering = ""; + +function item(fam: Family, faces: FontFace[], preview?: string): Item { + const css = cssFontFamily(fam.name, fam.mono); + const folder = dirname(fam.files[0] ?? USER_FONTS); + const removable = fam.files.filter((f) => f.startsWith(USER_FONTS + "/")); + const actions: Action[] = [ + copy(fam.name, "Copy Family Name"), + copy(css, "Copy CSS font-family", { icon: Icons.braces, shortcut: "c", section: "Copy" }), + copy(fam.files.join("\n"), fam.files.length === 1 ? "Copy File Path" : "Copy File Paths", { + icon: Icons.file, + section: "Copy", + }), + open(folder, "Open Folder", { icon: Icons.folder, shortcut: "o" }), + action("Install Font…", () => installForm("", false), { icon: Icons.plus, shortcut: "n" }), + ]; + if (removable.length) { + actions.push(action("Uninstall", async () => { + for (const f of removable) await Deno.remove(f); + await exec("fc-cache", ["-f", USER_FONTS]).catch(() => {}); + await toast(`${fam.name} uninstalled`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${plural(removable.length, "file")} of ${fam.name} from ~/.local/share/fonts?`, + section: "Danger", + })); + } + return { + id: fam.name, + title: fam.name, + subtitle: plural(fam.styles.length, "style"), + icon: { source: Icons.font, tint: fam.user ? "purple" : fam.mono ? "teal" : "blue" }, + section: fam.user ? "Your Fonts" : "System Fonts", + keywords: [...fam.styles, ...fam.formats, fam.mono ? "monospace" : "", fam.user ? "user" : ""], + accessories: [ + ...(fam.mono ? [{ tag: "Mono", color: "teal" as const }] : []), + { text: fam.formats.join(" · "), tooltip: "File formats" }, + ], + detail: { + image: preview, + markdown: sampleMarkdown(fam, HOME), + metadata: [ + meta.text("Family", fam.name, { icon: Icons.font }), + meta.tags( + "Styles", + fam.styles.map((s) => ({ text: s, color: s === regularStyle(fam.styles) ? "blue" : undefined })), + ), + meta.text("Spacing", fam.mono ? "Monospace" : "Proportional", fam.mono ? { color: "teal" } : {}), + meta.tags("Formats", fam.formats), + meta.separator(), + meta.text("Installed", fam.user ? "For you" : "System-wide", { icon: fam.user ? Icons.person : Icons.server }), + meta.text("Folder", tilde(folder, HOME), { icon: Icons.folder }), + ...(regularFile(faces, fam.name) ? [meta.text("Regular file", basename(regularFile(faces, fam.name)!))] : []), + meta.text("CSS", css, { icon: Icons.code }), + ], + }, + actions, + }; +} + +export default list({ + placeholder: "Search font families, styles…", + filter: { + options: [ + { value: "all", title: "All Fonts" }, + { value: "mono", title: "Monospace" }, + { value: "user", title: "Yours" }, + ], + }, + empty: { + title: "No fonts found", + description: "Press Ctrl+N to install one from a .ttf or .otf file, or type its path in the search bar", + icon: Icons.font, + }, + status: (items) => + `${items.length === total ? families(total) : `${items.length} of ${families(total)}`}${ + rendering ? ` · ${rendering}` : "" + }`, + async *items({ filter, query, signal }) { + let out: string; + try { + out = await exec("fc-list", ["--format", FC_FORMAT]); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + throw new Error("fc-list was not found: install fontconfig (it ships with every desktop)"); + } + throw err; + } + const faces = parseFcList(out); + const all = groupFamilies(faces, [USER_FONTS, LEGACY_FONTS]); + let shown = all; + updateCommandMetadata({ subtitle: families(all.length) }).catch(() => {}); + if (filter === "mono") shown = all.filter((f) => f.mono); + if (filter === "user") shown = all.filter((f) => f.user); + total = shown.length; + + // A font file path typed before opening: offer to install it first. + const install: Item[] = isFontFile(query.trim()) + ? [{ + id: "install", + title: `Install ${basename(query.trim())}`, + subtitle: "Copy into ~/.local/share/fonts", + icon: { source: Icons.download, tint: "green" }, + section: "Install", + keywords: [query], + actions: [action("Install Font", () => installForm(query.trim(), false), { icon: Icons.download })], + }] + : []; + + const previews = new Map(); + await Promise.all(shown.map(async (f) => { + const path = `${PREVIEWS}/${previewName(f.name)}`; + if (await exists(path)) previews.set(f.name, path); + })); + const build = () => [...install, ...shown.map((f) => item(f, faces, previews.get(f.name)))]; + yield build(); + + const missing = shown.filter((f) => !previews.has(f.name)); + if (missing.length === 0) return; + const renderer = await findRenderer(); + if (!renderer) return; + await Deno.mkdir(PREVIEWS, { recursive: true }); + let done = 0; + let since = 0; + const queue = [...missing]; + const worker = async () => { + for (let fam = queue.shift(); fam && !signal.aborted; fam = queue.shift()) { + const path = `${PREVIEWS}/${previewName(fam.name)}`; + if (await render(renderer, fam, regularFile(faces, fam.name), path)) previews.set(fam.name, path); + done++; + since++; + } + }; + const workers = Promise.all(Array.from({ length: 4 }, worker)); + let finished = false; + workers.finally(() => finished = true); + while (!finished && !signal.aborted) { + await Promise.race([workers, new Promise((r) => setTimeout(r, 1500))]); + rendering = finished ? "" : `rendering previews ${done}/${missing.length}`; + if (since > 0 || finished) { + since = 0; + yield build(); + } + } + rendering = ""; + }, +}); diff --git a/extensions/fonts/commands/install-font.ts b/extensions/fonts/commands/install-font.ts new file mode 100644 index 0000000..ac6591f --- /dev/null +++ b/extensions/fonts/commands/install-font.ts @@ -0,0 +1,5 @@ +import type { Context } from "@backslash/api"; +import { installForm } from "../install.ts"; + +// A path typed before opening the command fills the form, e.g. "Install Font ~/Downloads/Inter.ttf". +export default (ctx: Context) => installForm(ctx.query.trim(), true); diff --git a/extensions/fonts/install.ts b/extensions/fonts/install.ts new file mode 100644 index 0000000..dcbbddd --- /dev/null +++ b/extensions/fonts/install.ts @@ -0,0 +1,52 @@ +import { close, exec, field, form, type FormSpec } from "@backslash/api"; +import { basename, expandPath, FONT_EXTENSIONS, isFontFile } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +/** Where fontconfig looks for the user's own fonts (XDG). */ +export const USER_FONTS = `${HOME}/.local/share/fonts`; +/** The legacy folder, still read by fontconfig. */ +export const LEGACY_FONTS = `${HOME}/.fonts`; +export const PREVIEWS = `${HOME}/.cache/backslash/fonts`; + +/** Copies a font file into ~/.local/share/fonts and refreshes fontconfig's cache. Returns the new path. */ +export async function installFont(input: string): Promise { + const source = expandPath(input, HOME); + if (!source) throw new Error("Type the path of a font file (.ttf, .otf…)"); + if (!source.startsWith("/")) throw new Error(`Use an absolute path, e.g. ~/Downloads/${basename(source)}`); + if (!isFontFile(source)) { + throw new Error( + `Not a font file: ${basename(source)} (expected ${FONT_EXTENSIONS.map((e) => `.${e}`).join(", ")})`, + ); + } + await Deno.mkdir(USER_FONTS, { recursive: true }); + const target = `${USER_FONTS}/${basename(source)}`; + if (await Deno.stat(target).then(() => true, () => false)) { + throw new Error(`${basename(source)} is already installed in ~/.local/share/fonts`); + } + // cp reads the file wherever it is, so the extension needs no read access to your folders. + await exec("cp", ["--no-clobber", "--", source, target]).catch((err) => { + const msg = String(err?.message ?? err); + if (/No such file/i.test(msg)) throw new Error(`File not found: ${source}`); + throw new Error(msg.replace(/^cp exited with \d+: /, "")); + }); + await exec("fc-cache", ["-f", USER_FONTS]).catch(() => {}); + return target; +} + +export function installForm(path: string, standalone: boolean): FormSpec { + return form({ + title: "Install Font", + submitTitle: "Install", + fields: [ + field.text("path", "Font file", { + required: true, + placeholder: "~/Downloads/Inter-Regular.ttf", + value: path, + }), + ], + async submit(values) { + const target = await installFont(String(values.path)); + return close(`Installed ${basename(target)}${standalone ? "" : ": reopen Fonts to see it"}`); + }, + }); +} diff --git a/extensions/fonts/lib.ts b/extensions/fonts/lib.ts new file mode 100644 index 0000000..757ed5e --- /dev/null +++ b/extensions/fonts/lib.ts @@ -0,0 +1,168 @@ +// Pure helpers for the Fonts extension: fc-list parsing, grouping and naming. + +/** One font file as fc-list describes it. */ +export interface FontFace { + family: string; + style: string; + file: string; + /** fontconfig spacing: 0 proportional, 90 dual width, 100 monospace, 110 charcell. */ + spacing?: number; +} + +/** All the faces of one family. */ +export interface Family { + name: string; + styles: string[]; + files: string[]; + mono: boolean; + /** Installed in the user's own font folder. */ + user: boolean; + /** Formats of its files, e.g. ["TTF", "OTF"]. */ + formats: string[]; +} + +/** The `fc-list --format` that {@link parseFcList} reads: first family and style, file, spacing. */ +export const FC_FORMAT = "%{family[0]}\\t%{style[0]}\\t%{file}\\t%{spacing}\\n"; + +/** Files fontconfig can install, by extension. */ +export const FONT_EXTENSIONS = ["ttf", "otf", "ttc", "otc", "woff", "woff2", "pfb", "pcf", "bdf"]; + +export function parseFcList(out: string): FontFace[] { + const faces: FontFace[] = []; + for (const line of out.split("\n")) { + const [family, style, file, spacing] = line.split("\t"); + if (!family?.trim() || !file?.trim()) continue; + const n = Number(spacing); + faces.push({ + family: family.trim(), + style: style?.trim() || "Regular", + file: file.trim(), + spacing: spacing?.trim() && Number.isFinite(n) ? n : undefined, + }); + } + return faces; +} + +/** Usual order of styles: weights from thin to black, upright before italic. */ +const WEIGHTS = [ + "thin", + "hairline", + "extralight", + "ultralight", + "light", + "book", + "regular", + "normal", + "roman", + "medium", + "semibold", + "demibold", + "bold", + "extrabold", + "ultrabold", + "black", + "heavy", +]; + +export function styleRank(style: string): number { + const s = style.toLowerCase().replace(/[\s-]/g, ""); + const italic = /italic|oblique/.test(s) ? 0.5 : 0; + const condensed = /condensed|narrow/.test(s) ? 100 : 0; + const bare = s.replace(/italic|oblique|condensed|narrow/g, ""); + let weight = WEIGHTS.indexOf(bare || "regular"); + if (weight < 0) weight = WEIGHTS.findIndex((w) => bare.startsWith(w)); + if (weight < 0) weight = WEIGHTS.indexOf("regular"); + return condensed + weight + italic; +} + +export function sortStyles(styles: string[]): string[] { + return [...styles].sort((a, b) => styleRank(a) - styleRank(b) || a.localeCompare(b)); +} + +const isMono = (spacing?: number) => spacing === 90 || spacing === 100 || spacing === 110; + +export function fileFormat(file: string): string { + return (file.split(".").pop() ?? "").toUpperCase(); +} + +/** Faces grouped into families, sorted by name. `userDirs` are the folders the user installs fonts into. */ +export function groupFamilies(faces: FontFace[], userDirs: string[] = []): Family[] { + const map = new Map(); + for (const f of faces) { + let fam = map.get(f.family); + if (!fam) { + fam = { name: f.family, styles: [], files: [], mono: false, user: false, formats: [] }; + map.set(f.family, fam); + } + if (!fam.styles.includes(f.style)) fam.styles.push(f.style); + if (!fam.files.includes(f.file)) fam.files.push(f.file); + const format = fileFormat(f.file); + if (format && !fam.formats.includes(format)) fam.formats.push(format); + if (isMono(f.spacing)) fam.mono = true; + if (userDirs.some((d) => f.file.startsWith(d.endsWith("/") ? d : d + "/"))) fam.user = true; + } + return [...map.values()] + .map((f) => ({ ...f, styles: sortStyles(f.styles), files: f.files.sort() })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +/** The style to preview: Regular when there is one. */ +export function regularStyle(styles: string[]): string { + return styles.find((s) => /^(regular|normal|book|roman)$/i.test(s)) ?? sortStyles(styles)[0] ?? "Regular"; +} + +/** The file of a family's regular face, for renderers that take a file. */ +export function regularFile(faces: FontFace[], family: string): string | undefined { + const own = faces.filter((f) => f.family === family); + const style = regularStyle(own.map((f) => f.style)); + return own.find((f) => f.style === style)?.file ?? own[0]?.file; +} + +const GENERIC = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]); + +/** `font-family: "Fira Code", monospace;` */ +export function cssFontFamily(name: string, mono: boolean): string { + const quoted = GENERIC.has(name.toLowerCase()) ? name : /^[A-Za-z][\w-]*$/.test(name) ? name : JSON.stringify(name); + return `font-family: ${quoted}, ${ + mono ? "monospace" : /serif/i.test(name) && !/sans/i.test(name) ? "serif" : "sans-serif" + };`; +} + +/** A file name for the cached preview of a family. */ +export function previewName(family: string): string { + const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "font"; + // A short hash keeps families that differ only by punctuation apart. + let h = 0; + for (const c of family) h = (h * 31 + c.codePointAt(0)!) >>> 0; + return `${slug}-${h.toString(36)}.png`; +} + +/** The pango font description of a family, safe for families whose name ends in a style word. */ +export const pangoFont = (family: string, size: number) => `${family}, ${size}`; + +export const SAMPLE = "The quick brown fox jumps over the lazy dog"; +export const SAMPLE_EXTRA = "0123456789 ({[<>]}) &@#%?! àéîõü ß Ω"; + +/** A path typed in the search bar, with `~` and `file://` understood. */ +export function expandPath(input: string, home: string): string { + let p = input.trim().replace(/^["']|["']$/g, ""); + if (p.startsWith("file://")) p = decodeURIComponent(p.slice(7)); + if (p === "~" || p.startsWith("~/")) p = home + p.slice(1); + return p; +} + +export function isFontFile(path: string): boolean { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + return path.includes("/") && FONT_EXTENSIONS.includes(ext); +} + +export const basename = (p: string) => p.split("/").pop() ?? p; +export const dirname = (p: string) => p.slice(0, p.lastIndexOf("/")) || "/"; + +export function tilde(p: string, home: string): string { + return home && (p === home || p.startsWith(home + "/")) ? "~" + p.slice(home.length) : p; +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} diff --git a/extensions/fonts/lib_test.ts b/extensions/fonts/lib_test.ts new file mode 100644 index 0000000..8d8b8f1 --- /dev/null +++ b/extensions/fonts/lib_test.ts @@ -0,0 +1,107 @@ +import { assert, assertEquals } from "@std/assert"; +import { + cssFontFamily, + expandPath, + groupFamilies, + isFontFile, + pangoFont, + parseFcList, + previewName, + regularFile, + regularStyle, + sortStyles, + tilde, +} from "./lib.ts"; + +// fc-list --format '%{family[0]}\t%{style[0]}\t%{file}\t%{spacing}\n' on Ubuntu 24.04, plus a user font. +const FC_LIST = [ + "JetBrains Mono\tMedium\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Medium.ttf\t100", + "JetBrains Mono\tBold Italic\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-BoldItalic.ttf\t100", + "JetBrains Mono\tRegular\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf\t100", + "JetBrains Mono\tThin\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Thin.ttf\t100", + "Liberation Serif\tBold Italic\t/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf\t", + "Liberation Serif\tRegular\t/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf\t", + "Inter Display\tSemiBold Italic\t/usr/share/fonts/opentype/inter/InterDisplay-SemiBoldItalic.otf\t", + "Inter Display\tLight\t/usr/share/fonts/opentype/inter/InterDisplay-Light.otf\t", + "DejaVu Sans\tBook\t/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf\t", + "DejaVu Sans\tBold\t/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\t", + "Fira Code\tRegular\t/home/ada/.local/share/fonts/FiraCode-Regular.ttf\t100", + "Fira Code\tRegular\t/home/ada/.local/share/fonts/FiraCode-Regular.woff2\t100", + "\t\t\t", + "", +].join("\n"); + +Deno.test("parseFcList reads faces and skips blank lines", () => { + const faces = parseFcList(FC_LIST); + assertEquals(faces.length, 12); + assertEquals(faces[0], { + family: "JetBrains Mono", + style: "Medium", + file: "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Medium.ttf", + spacing: 100, + }); + assertEquals(faces[4].spacing, undefined); +}); + +Deno.test("groupFamilies merges faces, orders styles and spots user fonts", () => { + const fams = groupFamilies(parseFcList(FC_LIST), ["/home/ada/.local/share/fonts", "/home/ada/.fonts"]); + assertEquals(fams.map((f) => f.name), [ + "DejaVu Sans", + "Fira Code", + "Inter Display", + "JetBrains Mono", + "Liberation Serif", + ]); + const jb = fams.find((f) => f.name === "JetBrains Mono")!; + assertEquals(jb.styles, ["Thin", "Regular", "Medium", "Bold Italic"]); + assert(jb.mono); + assert(!jb.user); + const fira = fams.find((f) => f.name === "Fira Code")!; + assert(fira.user); + assertEquals(fira.styles, ["Regular"]); + assertEquals(fira.formats, ["TTF", "WOFF2"]); + assert(!fams.find((f) => f.name === "Liberation Serif")!.mono); +}); + +Deno.test("sortStyles puts weights in order, italics after uprights", () => { + assertEquals( + sortStyles(["Bold", "Italic", "Black", "Light Italic", "Regular", "Condensed Bold", "SemiBold"]), + ["Light Italic", "Regular", "Italic", "SemiBold", "Bold", "Black", "Condensed Bold"], + ); +}); + +Deno.test("regularStyle and regularFile pick the upright face", () => { + assertEquals(regularStyle(["Bold", "Book", "Oblique"]), "Book"); + assertEquals(regularStyle(["Light", "SemiBold Italic"]), "Light"); + const faces = parseFcList(FC_LIST); + assertEquals( + regularFile(faces, "JetBrains Mono"), + "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf", + ); + assertEquals(regularFile(faces, "Nope"), undefined); +}); + +Deno.test("cssFontFamily quotes names and adds a generic family", () => { + assertEquals(cssFontFamily("JetBrains Mono", true), `font-family: "JetBrains Mono", monospace;`); + assertEquals(cssFontFamily("Inter", false), "font-family: Inter, sans-serif;"); + assertEquals(cssFontFamily("Liberation Serif", false), `font-family: "Liberation Serif", serif;`); + assertEquals(cssFontFamily("Noto Sans", false), `font-family: "Noto Sans", sans-serif;`); +}); + +Deno.test("previewName is a safe, distinct file name", () => { + assert(/^[a-z0-9-]+\.png$/.test(previewName("Noto Sans CJK JP"))); + assert(previewName("Foo-Bar").startsWith("foo-bar-")); + assert(previewName("Foo Bar") !== previewName("Foo-Bar")); + assertEquals(pangoFont("Iosevka Term Bold", 26), "Iosevka Term Bold, 26"); +}); + +Deno.test("expandPath and isFontFile understand typed paths", () => { + assertEquals(expandPath("~/Downloads/Inter.ttf", "/home/ada"), "/home/ada/Downloads/Inter.ttf"); + assertEquals(expandPath("file:///tmp/My%20Font.otf", "/home/ada"), "/tmp/My Font.otf"); + assertEquals(expandPath(" '/tmp/a.ttf' ", "/home/ada"), "/tmp/a.ttf"); + assert(isFontFile("~/Downloads/Inter.TTF")); + assert(!isFontFile("Inter.ttf")); + assert(!isFontFile("/tmp/notes.txt")); + assertEquals(tilde("/home/ada/.local/share/fonts", "/home/ada"), "~/.local/share/fonts"); + assertEquals(tilde("/home/adam/x", "/home/ada"), "/home/adam/x"); +}); diff --git a/extensions/printers/backslash.toml b/extensions/printers/backslash.toml new file mode 100644 index 0000000..3413e28 --- /dev/null +++ b/extensions/printers/backslash.toml @@ -0,0 +1,48 @@ +[extension] +id = "printers" +title = "Printers" +description = "See your printers and their queues, cancel jobs, pause printers and print files" +version = "1.0.0" +author = "Backslash" +icon = "bs-printer" +license = "MIT" + +# CUPS client tools: lpstat lists printers and jobs, lpq names the queued +# files, lp prints, cancel removes jobs, lpoptions sets your default printer, +# cupsenable / cupsdisable / cupsaccept pause and resume (printer admin rights +# needed). The settings apps are only started to open the printer settings. +[permissions] +run = [ + "lpstat", + "lpq", + "lp", + "cancel", + "lpoptions", + "cupsenable", + "cupsdisable", + "cupsaccept", + "gnome-control-center", + "systemsettings", + "system-config-printer", +] +read = ["/etc/os-release"] +env = ["HOME", "XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "printers" +title = "Printers" +description = "Printers and print queues; type a file path to print it" +icon = "bs-printer" +keywords = [ + "printer", + "printers", + "print", + "imprimante", + "imprimantes", + "imprimer", + "impression", + "queue", + "file d'attente", + "cups", + "lp", +] diff --git a/extensions/printers/commands/printers.ts b/extensions/printers/commands/printers.ts new file mode 100644 index 0000000..5b173d8 --- /dev/null +++ b/extensions/printers/commands/printers.ts @@ -0,0 +1,426 @@ +import { + type Action, + action, + close, + type Context, + copy, + field, + form, + Icons, + type Item, + list, + meta, + open, + openExternal, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + connection, + expandPath, + explainCupsError, + installCups, + type Job, + looksLikePath, + lpstatProblem, + osFamily, + parseJobs, + parseLpq, + parseLpstat, + prettyName, + type Printer, + size, + type Status, +} from "../lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +const DESKTOP = (Deno.env.get("XDG_CURRENT_DESKTOP") ?? "").toLowerCase(); +const CUPS = "http://localhost:631"; +const TEST_PAGE = "/usr/share/cups/data/testprint"; + +class NotInstalled extends Error {} + +/** Runs a CUPS tool in the C locale (its messages are translated otherwise). */ +async function cups(program: string, args: string[], signal?: AbortSignal): Promise { + let res: Deno.CommandOutput; + try { + res = await new Deno.Command(program, { + args, + env: { LC_ALL: "C" }, + stdin: "null", + stdout: "piped", + stderr: "piped", + signal, + }).output(); + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new NotInstalled(program); + throw err; + } + const dec = new TextDecoder(); + const out = dec.decode(res.stdout); + if (res.code !== 0) { + throw new Error(`${program} exited with ${res.code}: ${dec.decode(res.stderr).trim() || out.trim()}`); + } + return out; +} + +/** Like {@link cups}, with errors explained. */ +async function admin(program: string, args: string[]): Promise { + try { + return await cups(program, args); + } catch (err) { + if (err instanceof NotInstalled) throw new Error(`${program} was not found: install the CUPS client tools`); + throw new Error(explainCupsError(String((err as Error).message))); + } +} + +async function status(signal: AbortSignal): Promise { + const p = await cups("lpstat", ["-l", "-p", "-d"], signal); + const [a, v, o, q] = await Promise.all([ + cups("lpstat", ["-a"], signal).catch(() => ""), + cups("lpstat", ["-v"], signal).catch(() => ""), + cups("lpstat", ["-o"], signal).catch(() => ""), + // lpq (cups-bsd) knows the file names; optional. + cups("lpq", ["-a"], signal).catch(() => ""), + ]); + const st = parseLpstat(p, a, v); + const titles = parseLpq(q); + const jobs = parseJobs(o).map((j) => ({ ...j, ...titles.get(j.number) })); + return { ...st, jobs }; +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +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 || err instanceof Deno.errors.NotCapable) return false; + throw err; + } +} + +const settingsAction = action("Open Printer Settings", async () => { + const candidates: [string, string[]][] = DESKTOP.includes("kde") + ? [["systemsettings", ["kcm_printer_manager"]], ["system-config-printer", []]] + : [["gnome-control-center", ["printers"]], ["system-config-printer", []], ["systemsettings", [ + "kcm_printer_manager", + ]]]; + for (const [program, args] of candidates) if (launch(program, args)) return close(); + // No settings app: the CUPS web page does everything. + await openExternal(`${CUPS}/printers/`); + return close(); +}, { icon: Icons.settings, shortcut: "," }); + +const webAction = open(`${CUPS}/printers/`, "Open CUPS Web Page", { icon: Icons.globe, shortcut: "w" }); + +// --- Printing a file ------------------------------------------------------------- + +async function printFile(path: string, printer: string | undefined, options: string[] = []): Promise { + if (!path.startsWith("/")) throw new Error(`Use an absolute path, e.g. ~/Documents/${path.split("/").pop()}`); + const out = await admin("lp", [...(printer ? ["-d", printer] : []), ...options, path]); + return out.match(/request id is (\S+)/)?.[1] ?? ""; +} + +function printForm(path: string, printers: Printer[], defaultName?: string) { + return form({ + title: "Print File", + submitTitle: "Print", + fields: [ + field.text("path", "File", { required: true, value: path, placeholder: "~/Documents/report.pdf" }), + field.dropdown( + "printer", + "Printer", + printers.map((p) => ({ + value: p.name, + title: `${p.description ?? prettyName(p.name)}${p.isDefault ? " (default)" : ""}`, + })), + { value: defaultName ?? printers[0]?.name }, + ), + field.text("copies", "Copies", { value: "1" }), + field.checkbox("duplex", "Two-sided"), + field.checkbox("gray", "Black and white"), + ], + async submit(values) { + const file = expandPath(String(values.path), HOME); + const copies = Math.max(1, Math.min(99, Number(values.copies) || 1)); + const id = await printFile(file, String(values.printer) || undefined, [ + "-n", + String(copies), + ...(values.duplex ? ["-o", "sides=two-sided-long-edge"] : []), + ...(values.gray ? ["-o", "print-color-mode=monochrome"] : []), + ]); + return close(`Sent to ${prettyName(String(values.printer))}${id ? ` (${id})` : ""}`); + }, + }); +} + +function printFileItem(query: string, st: Status): Item { + const file = expandPath(query, HOME); + const name = file.split("/").pop() ?? file; + const target = st.printers.find((p) => p.isDefault) ?? st.printers[0]; + const on = (p: Printer, options = {}) => + action(p.description ?? prettyName(p.name), async () => { + const id = await printFile(file, p.name); + return close(`Printing ${name} on ${prettyName(p.name)}${id ? ` (${id})` : ""}`); + }, { icon: Icons.printer, ...options }); + return { + id: "print-file", + title: `Print ${name}`, + subtitle: target ? `on ${target.description ?? prettyName(target.name)}` : undefined, + icon: { source: Icons.printer, tint: "blue" }, + section: "Print", + keywords: [query, file], + detail: { + metadata: [ + meta.text("File", file, { icon: Icons.file }), + ...(target + ? [meta.text("Printer", target.description ?? prettyName(target.name), { icon: Icons.printer })] + : []), + ], + }, + actions: [ + ...(target ? [on(target)] : []), + submenu("Print On", st.printers.map((p) => on(p)), { icon: Icons.printer }), + action("Print With Options…", () => printForm(query, st.printers, st.defaultName), { + icon: Icons.settings, + shortcut: "p", + }), + ], + }; +} + +// --- Items ----------------------------------------------------------------------- + +function printerItem(p: Printer, jobs: Job[], all: Printer[]): Item { + const mine = jobs.filter((j) => j.printer === p.name); + const paused = p.state === "disabled"; + const label = p.description ?? prettyName(p.name); + const stateText = paused ? "Paused" : p.state === "printing" ? "Printing" : p.state === "idle" ? "Ready" : "Unknown"; + const color = paused ? "orange" : p.state === "printing" ? "blue" : "green"; + const actions: Action[] = [ + open(`${CUPS}/printers/${encodeURIComponent(p.name)}`, "Open in CUPS", { icon: Icons.globe }), + action("Print File…", () => printForm("", [p, ...all.filter((x) => x !== p)], p.name), { + icon: Icons.file, + shortcut: "p", + }), + paused + ? action("Resume Printer", async () => { + await admin("cupsenable", [p.name]); + await admin("cupsaccept", [p.name]).catch(() => {}); + await toast(`${prettyName(p.name)} resumed`, { style: "success" }); + return refresh(); + }, { icon: Icons.play, shortcut: "r", section: "Printer" }) + : action("Pause Printer", async () => { + await admin("cupsdisable", [p.name]); + await toast(`${prettyName(p.name)} paused`, { style: "success" }); + return refresh(); + }, { icon: Icons.pause, shortcut: "r", section: "Printer" }), + ...(!p.isDefault + ? [action("Set as Default", async () => { + await admin("lpoptions", ["-d", p.name]); + await toast(`${prettyName(p.name)} is your default printer`, { style: "success" }); + return refresh(); + }, { icon: Icons.star, shortcut: "d", section: "Printer" })] + : []), + action("Print Test Page", async () => { + const id = await printFile(TEST_PAGE, p.name); + await toast(`Test page sent${id ? ` (${id})` : ""}`, { style: "success" }); + return refresh(); + }, { icon: Icons.check, section: "Printer" }), + ...(mine.length + ? [action(`Cancel All Jobs (${mine.length})`, async () => { + await admin("cancel", ["-a", p.name]); + await toast(`Queue of ${prettyName(p.name)} cleared`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Cancel all ${mine.length} job${mine.length === 1 ? "" : "s"} on ${prettyName(p.name)}?`, + section: "Danger", + })] + : []), + copy(p.name, "Copy Queue Name", { section: "Copy" }), + ...(p.device ? [copy(p.device, "Copy Device URI", { section: "Copy" })] : []), + settingsAction, + ]; + return { + id: `printer:${p.name}`, + title: label, + subtitle: p.location ?? (label !== prettyName(p.name) ? p.name : undefined), + icon: { source: Icons.printer, tint: color }, + section: "Printers", + keywords: [p.name, p.location ?? "", p.device ?? "", stateText], + accessories: [ + ...(p.isDefault ? [{ icon: Icons.starFilled, tooltip: "Default printer", color: "yellow" as const }] : []), + ...(mine.length ? [{ text: `${mine.length} job${mine.length === 1 ? "" : "s"}`, icon: Icons.inbox }] : []), + ...(!p.accepting ? [{ tag: "Rejecting jobs", color: "red" as const }] : []), + { tag: stateText, color }, + ], + detail: { + stats: [ + { label: "State", value: stateText, color }, + { label: "Queued", value: mine.length }, + ], + markdown: [ + p.message ? `> ${p.message}` : "", + paused + ? "Paused printers keep their queue. Resuming needs printer admin rights: if it's refused, " + + "add yourself to the `lpadmin` group or use the CUPS web page." + : "", + ].filter(Boolean).join("\n\n") || undefined, + metadata: [ + meta.text("Queue", p.name, { icon: Icons.printer }), + ...(p.isDefault ? [meta.text("Default", "Yes", { icon: Icons.starFilled, color: "yellow" })] : []), + meta.text("Accepting jobs", p.accepting ? "Yes" : "No", { color: p.accepting ? undefined : "red" }), + ...(p.job ? [meta.text("Printing", p.job)] : []), + ...(p.alerts ? [meta.text("Alerts", p.alerts, { icon: Icons.alert, color: "orange" })] : []), + meta.separator(), + ...(p.location ? [meta.text("Location", p.location, { icon: Icons.pin })] : []), + ...(connection(p.device) ? [meta.text("Connection", connection(p.device)!, { icon: Icons.plug })] : []), + ...(p.since ? [meta.text(paused ? "Paused since" : "Since", p.since, { icon: Icons.clock })] : []), + meta.link("CUPS", "localhost:631", `${CUPS}/printers/${encodeURIComponent(p.name)}`), + ], + }, + actions, + }; +} + +function jobItem(j: Job & { rank?: string; title?: string }, printers: Printer[]): Item { + const printer = printers.find((p) => p.name === j.printer); + const active = j.rank === "active" || printer?.job === j.id; + return { + id: `job:${j.id}`, + title: j.title ?? `Job ${j.number}`, + subtitle: printer?.description ?? prettyName(j.printer), + icon: { source: active ? Icons.inProgress : Icons.hourglass, tint: active ? "blue" : "gray" }, + section: "Queue", + keywords: [j.id, j.user, j.printer], + accessories: [ + { text: size(j.size), tooltip: "Size" }, + { avatar: j.user, tooltip: j.user }, + active ? { tag: "Printing", color: "blue" } : { tag: j.rank ?? "Waiting", color: "gray" }, + ], + detail: { + metadata: [ + meta.text("Job", j.id, { icon: Icons.hash }), + ...(j.title ? [meta.text("Document", j.title, { icon: Icons.file })] : []), + meta.text("Printer", printer?.description ?? prettyName(j.printer), { icon: Icons.printer }), + meta.person("Owner", j.user), + meta.text("Size", size(j.size)), + meta.text("Submitted", j.date, { icon: Icons.clock }), + meta.text("Status", active ? "Printing" : j.rank ? `In queue (${j.rank})` : "In queue"), + ], + }, + actions: [ + action("Cancel Job", async () => { + await admin("cancel", [j.id]); + await toast(`Job ${j.id} cancelled`, { style: "success" }); + return refresh(); + }, { icon: Icons.cross, destructive: true, confirm: `Cancel ${j.title ?? `job ${j.id}`}?` }), + open(`${CUPS}/jobs/`, "Open Jobs in CUPS", { icon: Icons.globe }), + copy(j.id, "Copy Job ID", { section: "Copy" }), + ], + }; +} + +async function setupItems(kind: "missing" | "no-scheduler"): Promise { + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const family = osFamily(os); + if (kind === "no-scheduler") { + const cmd = "sudo systemctl enable --now cups"; + return [{ + id: "no-scheduler", + title: "The printing service isn't running", + subtitle: "Start CUPS to see your printers", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: + `CUPS is installed but its service (cupsd) is stopped. Start it, and at every boot:\n\n\`\`\`\n${cmd}\n\`\`\``, + }, + actions: [copy(cmd, "Copy Command")], + }]; + } + const commands = installCups(family); + return [{ + id: "no-cups", + title: "CUPS isn't installed", + subtitle: "Linux prints through CUPS: install it to add printers", + icon: { source: Icons.printer, tint: "gray" }, + detail: { + markdown: [ + "Printers on Linux are managed by **CUPS**. Install it, start its service, then add a printer from the settings or the CUPS web page (http://localhost:631).", + "", + ...commands.flatMap((c) => [family ? "" : `**${c.family}**`, "```", c.command, "```"]), + ].filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n"), + }, + actions: [ + copy(commands[0].command, "Copy Install Command"), + open("https://www.cups.org", "Open cups.org", { icon: Icons.globe }), + ], + }]; +} + +const noPrinters: Item = { + id: "no-printers", + title: "No printers yet", + subtitle: "Add one in the settings or on the CUPS web page", + icon: { source: Icons.printer, tint: "gray" }, + detail: { + markdown: + "Network printers are usually found by themselves (IPP Everywhere). If yours isn't, add it from the printer settings or at http://localhost:631/admin.", + }, + actions: [settingsAction, open(`${CUPS}/admin`, "Add Printer in CUPS", { icon: Icons.plus })], +}; + +let summary = ""; + +export default (ctx: Context) => + list({ + placeholder: "Filter printers and jobs, or type a file path to print…", + actions: [webAction, settingsAction], + status: () => summary, + // Refreshed every 4 s while open: the queue moves as pages come out. + async *items({ signal }) { + let last = ""; + while (!signal.aborted) { + let items: Item[]; + try { + const st = await status(signal); + const queued = st.jobs.length; + summary = `${st.printers.length} printer${st.printers.length === 1 ? "" : "s"}${ + queued ? ` · ${queued} job${queued === 1 ? "" : "s"} queued` : "" + }${st.defaultName ? ` · default ${prettyName(st.defaultName)}` : ""}`; + updateCommandMetadata({ subtitle: queued ? `${queued} job${queued === 1 ? "" : "s"} queued` : null }) + .catch(() => {}); + items = st.printers.length === 0 ? [noPrinters] : [ + ...(looksLikePath(ctx.query) ? [printFileItem(ctx.query, st)] : []), + ...st.printers.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)) + .map((p) => printerItem(p, st.jobs, st.printers)), + ...st.jobs.map((j) => jobItem(j, st.printers)), + ]; + } catch (err) { + if (signal.aborted) return; + if (err instanceof NotInstalled) items = await setupItems("missing"); + else { + const problem = lpstatProblem(String((err as Error).message)); + if (problem === "no-scheduler") items = await setupItems("no-scheduler"); + else if (problem === "no-printers") items = [noPrinters]; + else throw err; + } + summary = ""; + const key = JSON.stringify(items.map((i) => i.id)); + if (key !== last) yield items; + return; + } + const key = JSON.stringify(items.map((i) => [i.id, i.title, i.accessories, i.detail?.stats])); + if (key !== last) { + last = key; + yield items; + } + await new Promise((r) => setTimeout(r, 4000)); + } + }, + }); diff --git a/extensions/printers/lib.ts b/extensions/printers/lib.ts new file mode 100644 index 0000000..e316770 --- /dev/null +++ b/extensions/printers/lib.ts @@ -0,0 +1,236 @@ +// Pure helpers for Printers: lpstat / lpq output (C locale) and what to install. + +export type PrinterState = "idle" | "printing" | "disabled" | "unknown"; + +export interface Printer { + name: string; + state: PrinterState; + /** The job being printed, e.g. `Office-57`. */ + job?: string; + /** "Paused", "Printing page 2, 45% complete.", "Waiting for printer to become available." */ + message?: string; + since?: string; + description?: string; + location?: string; + device?: string; + accepting: boolean; + isDefault: boolean; + alerts?: string; +} + +export interface Status { + printers: Printer[]; + defaultName?: string; +} + +/** + * `lpstat -l -p -d`, plus optionally `lpstat -a` (accepting) and `lpstat -v` (devices). + * Run with LC_ALL=C: lpstat translates its messages. + */ +export function parseLpstat(p: string, a = "", v = ""): Status { + const printers: Printer[] = []; + let defaultName: string | undefined; + let current: Printer | undefined; + for (const raw of p.split("\n")) { + const line = raw.replace(/\s+$/, ""); + const def = line.match(/^system default destination: (.+)$/); + if (def) { + defaultName = def[1].trim(); + continue; + } + const m = line.match(/^printer (\S+) (.*)$/); + if (m) { + const rest = m[2]; + let state: PrinterState = "unknown"; + let job: string | undefined; + if (/^is idle\b/.test(rest)) state = "idle"; + else if (/^now printing\b/.test(rest)) { + state = "printing"; + job = rest.match(/^now printing (\S+?)\.?\s/)?.[1]; + } else if (/^disabled\b/.test(rest)) state = "disabled"; + current = { + name: m[1], + state, + job, + since: rest.match(/since (.+?)(?:\s+-)?$/)?.[1], + accepting: true, + isDefault: false, + }; + printers.push(current); + continue; + } + if (!current || !/^\s/.test(line)) continue; + const t = line.trim(); + const kv = t.match( + /^(Description|Location|Alerts|Connection|Interface|On fault|After fault|Users allowed|Form mounted|Content types|Printer types):\s*(.*)$/, + ); + if (kv) { + if (kv[1] === "Description" && kv[2]) current.description = kv[2]; + if (kv[1] === "Location" && kv[2]) current.location = kv[2]; + if (kv[1] === "Alerts" && kv[2] && kv[2] !== "none") current.alerts = kv[2]; + continue; + } + if (/^(Banner required|Charset sets|Default pitch|Default page size|Default port settings|none|\(.*\))/i.test(t)) { + continue; + } + if (!current.message && t) current.message = t; + } + for (const line of a.split("\n")) { + const m = line.match(/^(\S+) (not )?accepting requests/); + const p = m && printers.find((x) => x.name === m[1]); + if (p) p.accepting = !m![2]; + } + for (const line of v.split("\n")) { + const m = line.match(/^device for (\S+): (.+)$/); + const p = m && printers.find((x) => x.name === m[1]); + if (p) p.device = m![2].trim(); + } + for (const p of printers) p.isDefault = p.name === defaultName; + return { printers, defaultName }; +} + +export interface Job { + /** `Office-57` */ + id: string; + printer: string; + number: number; + user: string; + size: number; + date: string; + title?: string; + rank?: string; +} + +/** `lpstat -o`: `Office-57 ada 1024000 Tue 28 Oct 2026 10:05:12 AM CET`. */ +export function parseJobs(out: string): Job[] { + const jobs: Job[] = []; + for (const line of out.split("\n")) { + const m = line.match(/^(\S+)-(\d+)\s+(\S+)\s+(\d+)\s+(.+)$/); + if (!m) continue; + // Printer names may contain dashes: the job number is after the last one. + jobs.push({ + id: `${m[1]}-${m[2]}`, + printer: m[1], + number: Number(m[2]), + user: m[3], + size: Number(m[4]), + date: m[5].trim(), + }); + } + return jobs; +} + +/** `lpq -a`: rank, owner, job number and file name of each job. */ +export function parseLpq(out: string): Map { + const map = new Map(); + for (const line of out.split("\n")) { + const m = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(.+?)\s+(\d+) bytes\s*$/); + if (m && m[1] !== "Rank") map.set(Number(m[3]), { rank: m[1], title: m[4].trim() }); + } + return map; +} + +/** Why lpstat failed, in words a person can act on. */ +export function lpstatProblem(error: string): "no-scheduler" | "no-printers" | undefined { + if (/scheduler is not running|Unable to connect to server|connection refused/i.test(error)) return "no-scheduler"; + if (/No destinations added/i.test(error)) return "no-printers"; + return undefined; +} + +/** Turns cupsenable / cupsdisable / cancel errors into advice. */ +export function explainCupsError(error: string): string { + const msg = error.replace(/^\S+ exited with \d+: /, "").trim(); + if (/forbidden|not authorized|unauthorized|operation not permitted|permission denied|authentication/i.test(msg)) { + return "This needs printer admin rights: add yourself to the lpadmin group " + + "(sudo usermod -aG lpadmin $USER, then log in again) or use the CUPS web page"; + } + if (/scheduler is not running|Unable to connect/i.test(msg)) return "The printing service (cupsd) isn't running"; + return msg; +} + +export function size(bytes: number): string { + if (!bytes) return "–"; + const units = ["B", "KB", "MB", "GB"]; + let n = bytes; + let i = 0; + while (n >= 1000 && i < units.length - 1) { + n /= 1000; + i++; + } + return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} + +/** "ipp://192.168.1.20/ipp/print" → "Network (IPP) · 192.168.1.20". */ +export function connection(device?: string): string | undefined { + if (!device) return undefined; + const scheme = device.split(":")[0].toLowerCase(); + const host = device.match(/^[a-z]+:\/\/(?:[^@/]*@)?([^/:?]+)/i)?.[1]; + const kinds: Record = { + ipp: "Network (IPP)", + ipps: "Network (IPP, secure)", + http: "Network (IPP)", + https: "Network (IPP, secure)", + socket: "Network (JetDirect)", + lpd: "Network (LPD)", + smb: "Windows share", + dnssd: "Network (Bonjour)", + implicitclass: "Network (shared)", + usb: "USB", + parallel: "Parallel port", + serial: "Serial port", + "cups-pdf": "PDF file", + hp: "HP (HPLIP)", + ippusb: "USB (IPP)", + }; + const kind = kinds[scheme] ?? scheme.toUpperCase(); + return host && !["usb", "hp", "ippusb"].includes(scheme) ? `${kind} · ${decodeURIComponent(host)}` : kind; +} + +/** A printer queue name made readable: `HP_LaserJet_M404` → `HP LaserJet M404`. */ +export function prettyName(name: string): string { + return name.replace(/_/g, " "); +} + +/** A path typed in the search bar, with `~` and `file://` understood. */ +export function expandPath(input: string, home: string): string { + let p = input.trim().replace(/^["']|["']$/g, ""); + if (p.startsWith("file://")) p = decodeURIComponent(p.slice(7)); + if (p === "~" || p.startsWith("~/")) p = home + p.slice(1); + return p; +} + +export function looksLikePath(input: string): boolean { + const t = input.trim(); + return /^(~\/|\/|file:\/\/)/.test(t) && /\.[a-z0-9]{1,5}$/i.test(t); +} + +// --------------------------------------------------------------------------- +// 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 INSTALL: Record = { + debian: "sudo apt install cups", + fedora: "sudo dnf install cups", + arch: "sudo pacman -S cups", + suse: "sudo zypper install cups", +}; + +/** The commands that install and start CUPS, for the family or every family when unknown. */ +export function installCups(family?: Family): { family: Family; command: string }[] { + return (family ? [family] : (Object.keys(INSTALL) as Family[])).map((f) => ({ + family: f, + command: `${INSTALL[f]} && sudo systemctl enable --now cups`, + })); +} diff --git a/extensions/printers/lib_test.ts b/extensions/printers/lib_test.ts new file mode 100644 index 0000000..6d2a795 --- /dev/null +++ b/extensions/printers/lib_test.ts @@ -0,0 +1,131 @@ +import { assert, assertEquals } from "@std/assert"; +import { + connection, + expandPath, + explainCupsError, + installCups, + looksLikePath, + lpstatProblem, + osFamily, + parseJobs, + parseLpq, + parseLpstat, + prettyName, + size, +} from "./lib.ts"; + +// CUPS 2.4, LC_ALL=C. +const LPSTAT_P = `printer Brother_HL-L2350DW disabled since Mon 27 Oct 2026 06:22:10 PM CET - + Paused + Form mounted: + Content types: any + Printer types: unknown + Description: Brother HL-L2350DW + Alerts: paused + Location: Study + Connection: direct + Interface: /etc/cups/ppd/Brother_HL-L2350DW.ppd +printer HP_LaserJet_M404 now printing HP_LaserJet_M404-58. enabled since Tue 28 Oct 2026 10:05:12 AM CET + Printing page 2, 45% complete. + Form mounted: + Description: HP LaserJet Pro M404dn + Alerts: none + Location: Office, 2nd floor + Users allowed: + (all) + Banner required +printer PDF is idle. enabled since Sun 26 Oct 2026 09:00:00 AM CET +system default destination: HP_LaserJet_M404 +`; +const LPSTAT_A = `Brother_HL-L2350DW not accepting requests since Mon 27 Oct 2026 06:22:10 PM CET - + Rejecting Jobs +HP_LaserJet_M404 accepting requests since Tue 28 Oct 2026 10:05:12 AM CET +PDF accepting requests since Sun 26 Oct 2026 09:00:00 AM CET +`; +const LPSTAT_V = `device for Brother_HL-L2350DW: usb://Brother/HL-L2350DW%20series?serial=U12345 +device for HP_LaserJet_M404: ipp://192.168.1.20/ipp/print +device for PDF: cups-pdf:/ +`; + +Deno.test("parseLpstat reads state, details, acceptance and devices", () => { + const { printers, defaultName } = parseLpstat(LPSTAT_P, LPSTAT_A, LPSTAT_V); + assertEquals(defaultName, "HP_LaserJet_M404"); + assertEquals(printers.map((p) => [p.name, p.state, p.accepting, p.isDefault]), [ + ["Brother_HL-L2350DW", "disabled", false, false], + ["HP_LaserJet_M404", "printing", true, true], + ["PDF", "idle", true, false], + ]); + const [brother, hp, pdf] = printers; + assertEquals([brother.message, brother.alerts, brother.location], ["Paused", "paused", "Study"]); + assertEquals(brother.since, "Mon 27 Oct 2026 06:22:10 PM CET"); + assertEquals([hp.job, hp.message, hp.description], [ + "HP_LaserJet_M404-58", + "Printing page 2, 45% complete.", + "HP LaserJet Pro M404dn", + ]); + assertEquals(hp.alerts, undefined); + assertEquals(hp.location, "Office, 2nd floor"); + assertEquals(pdf.device, "cups-pdf:/"); + assertEquals(parseLpstat("no system default destination\n").printers, []); +}); + +Deno.test("parseJobs and parseLpq", () => { + const jobs = parseJobs( + "HP_LaserJet_M404-58 ada 1024000 Tue 28 Oct 2026 10:05:12 AM CET\n" + + "Brother_HL-L2350DW-7 bob 2048 Tue 28 Oct 2026 10:06:40 AM CET\n", + ); + assertEquals(jobs.map((j) => [j.id, j.printer, j.number, j.user, j.size]), [ + ["HP_LaserJet_M404-58", "HP_LaserJet_M404", 58, "ada", 1024000], + ["Brother_HL-L2350DW-7", "Brother_HL-L2350DW", 7, "bob", 2048], + ]); + assertEquals(jobs[0].date, "Tue 28 Oct 2026 10:05:12 AM CET"); + const lpq = parseLpq( + "HP_LaserJet_M404 is ready and printing\nRank Owner Job File(s) Total Size\n" + + "active ada 58 Quarterly report.pdf 1024000 bytes\n1st ada 59 notes.txt 2048 bytes\n", + ); + assertEquals(lpq.get(58), { rank: "active", title: "Quarterly report.pdf" }); + assertEquals(lpq.get(59)?.rank, "1st"); +}); + +Deno.test("errors become advice", () => { + assertEquals(lpstatProblem("lpstat exited with 1: lpstat: Scheduler is not running."), "no-scheduler"); + assertEquals(lpstatProblem("lpstat: No destinations added."), "no-printers"); + assertEquals(lpstatProblem("something else"), undefined); + assert(explainCupsError("cupsdisable exited with 1: cupsdisable: Forbidden").includes("lpadmin")); + assertEquals( + explainCupsError("cancel exited with 1: cancel: Job #9 does not exist."), + "cancel: Job #9 does not exist.", + ); +}); + +Deno.test("connection describes device URIs", () => { + assertEquals(connection("ipp://192.168.1.20/ipp/print"), "Network (IPP) · 192.168.1.20"); + assertEquals(connection("usb://Brother/HL-L2350DW%20series?serial=U12345"), "USB"); + assertEquals(connection("socket://printer.lan:9100"), "Network (JetDirect) · printer.lan"); + assertEquals(connection("dnssd://HP%20M404._ipp._tcp.local/"), "Network (Bonjour) · HP M404._ipp._tcp.local"); + assertEquals(connection(undefined), undefined); +}); + +Deno.test("small helpers", () => { + assertEquals(size(1024000), "1.0 MB"); + assertEquals(size(2048), "2.0 KB"); + assertEquals(size(512), "512 B"); + assertEquals(prettyName("HP_LaserJet_M404"), "HP LaserJet M404"); + assertEquals(expandPath("~/Documents/a.pdf", "/home/ada"), "/home/ada/Documents/a.pdf"); + assertEquals(expandPath("file:///tmp/My%20File.pdf", "/home/ada"), "/tmp/My File.pdf"); + assert(looksLikePath("~/Documents/report.pdf")); + assert(looksLikePath("/tmp/photo.jpg")); + assert(!looksLikePath("HP")); + assert(!looksLikePath("~/Documents")); +}); + +Deno.test("install hints follow the distribution", () => { + assertEquals(osFamily('NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n'), "debian"); + assertEquals(osFamily('ID="opensuse-tumbleweed"\nID_LIKE="opensuse suse"\n'), "suse"); + assertEquals(osFamily("ID=nixos\n"), undefined); + assertEquals(installCups("arch"), [{ + family: "arch", + command: "sudo pacman -S cups && sudo systemctl enable --now cups", + }]); + assertEquals(installCups().length, 4); +}); diff --git a/extensions/privacy/backslash.toml b/extensions/privacy/backslash.toml new file mode 100644 index 0000000..22ef7f8 --- /dev/null +++ b/extensions/privacy/backslash.toml @@ -0,0 +1,51 @@ +[extension] +id = "privacy" +title = "Privacy" +description = "See which apps use your camera, microphone, screen and location, and stop them" +version = "1.0.0" +author = "Backslash" +icon = "bs-shield" +license = "MIT" + +# pw-dump (PipeWire) shows camera, screen-sharing and microphone streams; +# pactl (pipewire-pulse or PulseAudio) lists recording streams and mutes them; +# find and fuser tell who holds /dev/video*; ps names the processes; kill quits +# them; busctl asks GeoClue which apps follow your location. The settings apps +# are only started to open the matching settings page. +[permissions] +run = [ + "pw-dump", + "pactl", + "find", + "fuser", + "ps", + "kill", + "busctl", + "gnome-control-center", + "systemsettings", + "pavucontrol", +] +env = ["XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "privacy" +title = "Privacy" +description = "Apps using the camera, microphone, screen sharing or location right now" +icon = "bs-shield" +keywords = [ + "privacy", + "confidentialité", + "vie privée", + "camera", + "caméra", + "webcam", + "microphone", + "micro", + "mic", + "screen sharing", + "partage d'écran", + "location", + "localisation", + "who is listening", + "spy", +] diff --git a/extensions/privacy/commands/privacy.ts b/extensions/privacy/commands/privacy.ts new file mode 100644 index 0000000..1c357fb --- /dev/null +++ b/extensions/privacy/commands/privacy.ts @@ -0,0 +1,352 @@ +import { + type Action, + action, + close, + type Color, + copy, + exec, + Icons, + type Item, + list, + meta, + type Metadata, + storage, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + busValue, + clock, + deviceUses, + type Kind, + KIND_TITLE, + mergeUses, + parseFuser, + parseGeoclueClients, + parsePs, + parsePwDump, + parseSourceOutputs, + prettyApp, + type Proc, + since, + subtitle, + type Use, +} from "../lib.ts"; + +const DESKTOP = (Deno.env.get("XDG_CURRENT_DESKTOP") ?? "").toLowerCase(); + +/** Runs a program; undefined when it is missing or fails. */ +async function tryRun(program: string, args: string[], signal?: AbortSignal): Promise { + try { + const { code, stdout } = await new Deno.Command(program, { + args, + stdin: "null", + stdout: "piped", + stderr: "null", + signal, + }).output(); + const out = new TextDecoder().decode(stdout); + // fuser exits with 1 when nobody holds the file. + return code === 0 || program === "fuser" ? out : undefined; + } catch { + return undefined; + } +} + +interface Scan { + uses: Use[]; + procs: Map; + /** What could be checked. */ + pipewire: boolean; + pulse: boolean; + cameras: string[]; +} + +async function cameraHolders(signal: AbortSignal): Promise<{ device: string; pids: number[] }[]> { + // find instead of reading /dev from Deno: only the names of the video devices are needed. + const list = await tryRun("find", ["/dev", "-maxdepth", "1", "-name", "video*"], signal); + const devices = (list ?? "").split("\n").map((l) => l.trim()).filter(Boolean).sort(); + return await Promise.all(devices.map(async (device) => ({ + device, + pids: parseFuser(await tryRun("fuser", [device], signal) ?? ""), + }))); +} + +async function geoclue(signal: AbortSignal): Promise { + const tree = await tryRun("busctl", ["--system", "--list", "tree", "org.freedesktop.GeoClue2"], signal); + if (!tree) return []; + const uses: Use[] = []; + for (const path of parseGeoclueClients(tree)) { + const get = (prop: string) => + tryRun("busctl", [ + "--system", + "--json=short", + "get-property", + "org.freedesktop.GeoClue2", + path, + "org.freedesktop.GeoClue2.Client", + prop, + ], signal).then((o) => (o ? busValue(o) : undefined)); + const [active, id] = await Promise.all([get("Active"), get("DesktopId")]); + if (active !== true) continue; + const appId = typeof id === "string" && id ? id : undefined; + uses.push({ + kind: "location", + key: `location:${appId ?? path}`, + app: appId ? prettyApp(appId.split(".").pop()!) : "An app", + appId, + device: "GeoClue", + via: "geoclue", + }); + } + return uses; +} + +async function scan(signal: AbortSignal): Promise { + const [dump, outputs, sources, holders, location] = await Promise.all([ + tryRun("pw-dump", [], signal), + tryRun("pactl", ["-f", "json", "list", "source-outputs"], signal), + tryRun("pactl", ["-f", "json", "list", "sources"], signal), + cameraHolders(signal), + geoclue(signal), + ]); + const pids = [...new Set(holders.flatMap((h) => h.pids))]; + const pw = dump ? parsePwDump(dump) : []; + const pulse = outputs ? parseSourceOutputs(outputs, sources ?? "[]") : []; + // Ask ps about every pid we know of, for names and start times. + const allPids = [...new Set([...pids, ...pw.map((u) => u.pid), ...pulse.map((u) => u.pid)])].filter(( + p, + ): p is number => !!p); + const procs = parsePs( + allPids.length + ? await tryRun("ps", ["-o", "pid=,etimes=,user=,comm=,args=", "-p", allPids.join(",")], signal) ?? "" + : "", + ); + const direct = deviceUses(holders, procs); + // PulseAudio first for the microphone: it knows the stream and source to mute. + const uses = mergeUses(pulse, pw, direct, location); + // The camera held only by PipeWire with no app found: still in use. + const pwHeld = holders.filter((h) => h.pids.some((p) => procs.get(p)?.comm === "pipewire")); + if (!uses.some((u) => u.kind === "camera") && pwHeld.length && !dump) { + uses.push({ + kind: "camera", + key: "camera:pipewire", + app: "An app (through PipeWire)", + device: pwHeld[0].device, + via: "device", + }); + } + return { + uses, + procs, + pipewire: !!dump, + pulse: !!outputs, + cameras: holders.map((h) => h.device), + }; +} + +// --- "In use since" -------------------------------------------------------------- + +const SEEN_KEY = "first-seen"; +let seen: Record | undefined; + +async function firstSeen(uses: Use[]): Promise> { + seen ??= (await storage.get>(SEEN_KEY).catch(() => undefined)) ?? {}; + const now = Date.now(); + const next: Record = {}; + for (const u of uses) next[u.key] = seen[u.key] ?? now; + const changed = Object.keys(next).length !== Object.keys(seen).length || + Object.keys(next).some((k) => seen![k] !== next[k]); + seen = next; + if (changed) storage.set(SEEN_KEY, next).catch(() => {}); + return next; +} + +// --- Settings ---------------------------------------------------------------------- + +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 || err instanceof Deno.errors.NotCapable) return false; + throw err; + } +} + +const GNOME_PANEL: Record = { + camera: "camera", + microphone: "microphone", + screen: "sharing", + location: "location", +}; + +function settings(kind: Kind): Action { + return action(`Open ${kind === "screen" ? "Sharing" : KIND_TITLE[kind]} Settings`, () => { + const tries: [string, string[]][] = DESKTOP.includes("kde") + ? [["systemsettings", [kind === "microphone" ? "kcm_pulseaudio" : "kcm_app-permissions"]]] + : [["gnome-control-center", [GNOME_PANEL[kind]]], ["gnome-control-center", ["privacy"]]]; + if (kind === "microphone") tries.push(["pavucontrol", ["--tab=2"]]); + for (const [program, args] of tries) if (launch(program, args)) return close(); + throw new Error("No settings app found (GNOME Settings, KDE System Settings or pavucontrol)"); + }, { icon: Icons.settings, shortcut: "," }); +} + +// --- Items ------------------------------------------------------------------------- + +const KIND_ICON: Record = { + camera: Icons.camera, + microphone: Icons.mic, + screen: Icons.monitor, + location: Icons.pin, +}; +const KIND_COLOR: Record = { camera: "green", microphone: "orange", screen: "blue", location: "purple" }; + +function useItem(u: Use, procs: Map, firstAt: number): Item { + const p = u.pid ? procs.get(u.pid) : undefined; + const forMs = Date.now() - firstAt; + const color: Color = u.paused ? "gray" : KIND_COLOR[u.kind]; + const kill = (signal: "TERM" | "KILL", title: string, options: Record) => + action(title, async () => { + await exec("kill", [`-${signal}`, String(u.pid)]); + await toast(`${signal === "TERM" ? "Asked" : "Forced"} ${u.app} to quit`, { style: "success" }); + }, { destructive: true, section: "App", ...options }); + const actions: Action[] = []; + if (u.kind === "microphone" && u.sourceName) { + actions.push(action(u.sourceMuted ? "Unmute Microphone" : "Mute Microphone", async () => { + await exec("pactl", ["set-source-mute", u.sourceName!, u.sourceMuted ? "0" : "1"]); + await toast(u.sourceMuted ? "Microphone unmuted" : "Microphone muted for every app", { style: "success" }); + }, { icon: u.sourceMuted ? Icons.mic : Icons.speakerOff, shortcut: "m" })); + } + if (u.kind === "microphone" && u.streamIndex !== undefined) { + actions.push(action(u.streamMuted ? `Unmute for ${u.app}` : `Mute for ${u.app} Only`, async () => { + await exec("pactl", ["set-source-output-mute", String(u.streamIndex), u.streamMuted ? "0" : "1"]); + await toast(`${u.app} ${u.streamMuted ? "hears you again" : "no longer hears you"}`, { style: "success" }); + }, { icon: Icons.speakerOff })); + } + if (u.pid) { + actions.push( + kill("TERM", `Quit ${u.app}`, { + icon: Icons.stop, + confirm: `Quit ${u.app} (pid ${u.pid})? Unsaved work may be lost.`, + shortcut: "k", + }), + kill("KILL", "Force Quit", { icon: Icons.cross, confirm: `Kill ${u.app} right away?` }), + ); + } + actions.push(settings(u.kind)); + if (u.pid) actions.push(copy(String(u.pid), "Copy PID", { section: "Copy" })); + if (p?.args) actions.push(copy(p.args, "Copy Command Line", { section: "Copy" })); + + const metadata: Metadata[] = [ + meta.text("App", u.app, { icon: KIND_ICON[u.kind] }), + ...(u.appId ? [meta.text("App ID", u.appId)] : []), + ...(u.device ? [meta.text(u.kind === "screen" ? "Stream" : "Device", u.device, { icon: Icons.plug })] : []), + meta.text("Status", u.paused ? "Paused" : "In use", { color }), + meta.text("In use for", `${forMs < 60_000 ? "Just now" : since(forMs / 1000)} (since ${clock(firstAt)})`, { + icon: Icons.clock, + }), + meta.separator(), + ...(u.pid ? [meta.text("PID", u.pid)] : []), + ...(p + ? [meta.text("Program", p.comm, { icon: Icons.terminal })] + : u.binary + ? [meta.text("Program", u.binary)] + : []), + ...(p ? [meta.text("Running for", since(p.elapsed))] : []), + ...(p ? [meta.person("User", p.user)] : []), + ...(u.kind === "microphone" && u.sourceMuted !== undefined + ? [meta.text("Microphone", u.sourceMuted ? "Muted" : "Live", { color: u.sourceMuted ? "gray" : "orange" })] + : []), + meta.text( + "Found with", + { pipewire: "PipeWire", pulse: "PulseAudio", device: "Device files", geoclue: "GeoClue" }[u.via], + ), + ]; + return { + id: u.key, + title: u.app, + subtitle: u.device, + icon: { source: KIND_ICON[u.kind], tint: color }, + section: KIND_TITLE[u.kind], + keywords: [u.binary ?? "", u.appId ?? "", String(u.pid ?? ""), u.kind], + accessories: [ + ...(u.kind === "microphone" && u.sourceMuted ? [{ icon: Icons.speakerOff, tooltip: "Microphone muted" }] : []), + { + text: forMs < 60_000 ? "just now" : since(forMs / 1000), + icon: Icons.clock, + tooltip: `Since ${clock(firstAt)}`, + }, + u.paused ? { tag: "Paused", color: "gray" } : { tag: u.kind === "screen" ? "Sharing" : "In use", color }, + ], + detail: { + markdown: p?.args ? "```\n" + (p.args.length > 400 ? p.args.slice(0, 400) + "…" : p.args) + "\n```" : undefined, + metadata, + }, + actions, + }; +} + +function idleItem(kind: Kind, note?: { title: string; text: string }): Item { + return { + id: `idle:${kind}`, + title: note?.title ?? "Not in use", + subtitle: note?.text, + icon: { source: note ? Icons.question : KIND_ICON[kind], tint: "gray" }, + section: KIND_TITLE[kind], + keywords: [kind], + accessories: note ? [] : [{ icon: Icons.check, tooltip: "Nothing is using it" }], + detail: { + markdown: note ? note.text : `No app is using the ${KIND_TITLE[kind].toLowerCase()} right now.`, + }, + actions: [settings(kind)], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter apps…", + status: () => summary, + // Checked every 3 s while open. + async *items({ signal }) { + let last = ""; + let lastSubtitle: string | null | undefined; + while (!signal.aborted) { + const s = await scan(signal); + if (signal.aborted) return; + const firsts = await firstSeen(s.uses); + const text = subtitle(s.uses); + if (text !== lastSubtitle) { + lastSubtitle = text; + updateCommandMetadata({ subtitle: text }).catch(() => {}); + } + summary = text ?? "Nothing is watching or listening"; + const items: Item[] = []; + const notes: Record = { + camera: s.cameras.length === 0 ? { title: "No camera", text: "No video device is connected" } : undefined, + microphone: !s.pulse && !s.pipewire + ? { + title: "Can't check", + text: "Install pipewire-pulse or pulseaudio-utils (pactl), or PipeWire's pw-dump, to see who records", + } + : undefined, + screen: !s.pipewire + ? { title: "Can't check", text: "Screen sharing is seen through PipeWire: pw-dump isn't available" } + : undefined, + location: undefined, + }; + for (const kind of ["camera", "microphone", "screen", "location"] as Kind[]) { + const mine = s.uses.filter((u) => u.kind === kind).sort((a, b) => Number(a.paused) - Number(b.paused)); + if (mine.length) items.push(...mine.map((u) => useItem(u, s.procs, firsts[u.key] ?? Date.now()))); + else items.push(idleItem(kind, notes[kind])); + } + const key = JSON.stringify(items.map((i) => [i.id, i.subtitle, i.accessories, i.actions?.map((a) => a.title)])); + if (key !== last) { + last = key; + yield items; + } + await new Promise((r) => setTimeout(r, 3000)); + } + }, +}); diff --git a/extensions/privacy/lib.ts b/extensions/privacy/lib.ts new file mode 100644 index 0000000..8f777c5 --- /dev/null +++ b/extensions/privacy/lib.ts @@ -0,0 +1,357 @@ +// Pure helpers for Privacy: who uses the camera, the microphone, the screen and the location. + +export type Kind = "camera" | "microphone" | "screen" | "location"; + +/** One app using a sensitive device. */ +export interface Use { + kind: Kind; + /** A stable key, for "in use since". */ + key: string; + app: string; + pid?: number; + binary?: string; + /** Flatpak / portal app id. */ + appId?: string; + /** The device or source used: "Integrated Camera", "Built-in Audio Analog Stereo"… */ + device?: string; + /** How it was found. */ + via: "pipewire" | "pulse" | "device" | "geoclue"; + /** PulseAudio source-output index, to mute the stream. */ + streamIndex?: number; + /** Name of the PulseAudio source (microphone), to mute it. */ + sourceName?: string; + sourceMuted?: boolean; + streamMuted?: boolean; + /** Corked: the stream exists but is paused. */ + paused?: boolean; +} + +// --------------------------------------------------------------------------- +// PipeWire (pw-dump) + +interface PwObject { + id: number; + type: string; + info?: { + props?: Record; + state?: string; + "output-node-id"?: number; + "input-node-id"?: number; + }; +} + +const str = (v: unknown) => (v === undefined || v === null || v === "" ? undefined : String(v)); +const int = (v: unknown) => { + const n = Number(v); + return v !== undefined && v !== null && v !== "" && Number.isFinite(n) ? n : undefined; +}; + +/** Daemons that hold devices on behalf of apps: they aren't "the app using the camera". */ +export const PLUMBING = new Set([ + "pipewire", + "wireplumber", + "pipewire-media-session", + "pipewire-pulse", + "pulseaudio", + "xdg-desktop-portal", +]); + +const SCREEN_HINT = + /screen|screencast|xdpw|xdg-desktop-portal|gnome-shell|mutter|kwin|hyprland|wlroots|monitor|desktop/i; + +/** Camera, microphone and screen-sharing users seen in `pw-dump`. */ +export function parsePwDump(json: string): Use[] { + let objects: PwObject[]; + try { + objects = JSON.parse(json); + } catch { + return []; + } + if (!Array.isArray(objects)) return []; + const nodes = new Map(); + const clients = new Map>(); + const links: { from: number; to: number; state?: string }[] = []; + for (const o of objects) { + if (o.type === "PipeWire:Interface:Node") nodes.set(o.id, o); + else if (o.type === "PipeWire:Interface:Client") clients.set(o.id, o.info?.props ?? {}); + else if (o.type === "PipeWire:Interface:Link" && o.info) { + const from = o.info["output-node-id"]; + const to = o.info["input-node-id"]; + if (from !== undefined && to !== undefined) links.push({ from, to, state: o.info.state }); + } + } + const props = (n: PwObject) => n.info?.props ?? {}; + const cls = (n?: PwObject) => str(n && props(n)["media.class"]) ?? ""; + /** The app behind a node: its own props, else its client's. */ + const owner = (n: PwObject) => { + const p = props(n); + const c = clients.get(int(p["client.id"]) ?? -1) ?? {}; + const pick = (k: string) => str(p[k]) ?? str(c[k]); + return { + app: pick("application.name") ?? pick("application.process.binary") ?? str(p["node.name"]) ?? "Unknown app", + pid: int(p["application.process.id"]) ?? int(c["application.process.id"]), + binary: pick("application.process.binary"), + appId: pick("pipewire.access.portal.app_id") ?? pick("flatpak.app_id"), + }; + }; + const label = (n: PwObject) => + str(props(n)["node.description"]) ?? str(props(n)["node.nick"]) ?? str(props(n)["node.name"]); + const isCamera = (n: PwObject) => { + const p = props(n); + return cls(n) === "Video/Source" && + (/^(v4l2|libcamera)$/.test(str(p["device.api"]) ?? "") || /^(v4l2|libcamera)/.test(str(p["node.name"]) ?? "") || + /^(v4l2|libcamera):/.test(str(p["object.path"]) ?? "")); + }; + const isScreen = (n: PwObject) => { + if (cls(n) !== "Video/Source" || isCamera(n)) return false; + const o = owner(n); + const text = [str(props(n)["node.name"]), label(n), o.app, o.binary].join(" "); + return SCREEN_HINT.test(text); + }; + const isMic = (n?: PwObject) => !!n && /^Audio\/Source(\/Virtual)?$/.test(cls(n)); + + const uses: Use[] = []; + const consumersOf = (src: PwObject) => + links.filter((l) => l.from === src.id && l.state !== "error").map((l) => nodes.get(l.to)).filter(( + n, + ): n is PwObject => !!n && cls(n).startsWith("Stream/Input")); + + for (const n of nodes.values()) { + if (isCamera(n) || isScreen(n)) { + const kind: Kind = isCamera(n) ? "camera" : "screen"; + const consumers = [...new Map(consumersOf(n).map((c) => [c.id, c])).values()]; + for (const c of consumers) { + const o = owner(c); + uses.push({ kind, key: `${kind}:pw:${o.pid ?? o.app}`, ...o, device: label(n), via: "pipewire" }); + } + // A running screencast whose viewer can't be told: the screen is shared all the same. + if (consumers.length === 0 && n.info?.state === "running" && kind === "screen") { + const o = owner(n); + uses.push({ kind, key: `screen:pw:${n.id}`, ...o, device: label(n), via: "pipewire" }); + } + } + } + for (const n of nodes.values()) { + if (cls(n) !== "Stream/Input/Audio") continue; + const sources = links.filter((l) => l.to === n.id).map((l) => nodes.get(l.from)).filter(isMic); + if (sources.length === 0) continue; + const o = owner(n); + if (PLUMBING.has(o.binary ?? "")) continue; + uses.push({ + kind: "microphone", + key: `microphone:pw:${o.pid ?? o.app}`, + ...o, + device: label(sources[0]!), + via: "pipewire", + paused: n.info?.state !== undefined && n.info.state !== "running", + }); + } + return dedupe(uses); +} + +// --------------------------------------------------------------------------- +// PulseAudio / pipewire-pulse (pactl -f json) + +interface PaSource { + index: number; + name: string; + description?: string; + mute?: boolean; + properties?: Record; +} + +interface PaSourceOutput { + index: number; + source: number; + client?: string; + corked?: boolean; + mute?: boolean; + properties?: Record; +} + +/** Recording streams from `pactl -f json list source-outputs`, monitors and level meters left out. */ +export function parseSourceOutputs(outputsJson: string, sourcesJson: string): Use[] { + let outputs: PaSourceOutput[]; + let sources: PaSource[]; + try { + outputs = JSON.parse(outputsJson); + sources = JSON.parse(sourcesJson || "[]"); + } catch { + return []; + } + if (!Array.isArray(outputs)) return []; + const byIndex = new Map((Array.isArray(sources) ? sources : []).map((s) => [s.index, s])); + const uses: Use[] = []; + for (const o of outputs) { + const p = o.properties ?? {}; + const src = byIndex.get(o.source); + const isMonitor = src ? src.name.endsWith(".monitor") || src.properties?.["device.class"] === "monitor" : false; + if (isMonitor) continue; + // pavucontrol's and desktop sound panels' level meters. + if (p["media.name"] === "Peak detect" || p["stream.monitor"] === "true" || p["media.role"] === "abstract") continue; + if (PLUMBING.has(p["application.process.binary"] ?? "")) continue; + const pid = int(p["application.process.id"]); + const app = p["application.name"] ?? p["application.process.binary"] ?? p["node.name"] ?? "Unknown app"; + uses.push({ + kind: "microphone", + key: `microphone:pw:${pid ?? app}`, + app, + pid, + binary: p["application.process.binary"], + appId: p["pipewire.access.portal.app_id"] ?? p["application.id"], + device: src?.description ?? src?.name, + via: "pulse", + streamIndex: o.index, + sourceName: src?.name, + sourceMuted: src?.mute, + streamMuted: o.mute, + paused: o.corked, + }); + } + return dedupe(uses); +} + +/** One row per app and kind (an app may open several streams). */ +function dedupe(uses: Use[]): Use[] { + const out = new Map(); + for (const u of uses) { + const prev = out.get(u.key); + if (!prev || (prev.paused && !u.paused)) out.set(u.key, u); + } + return [...out.values()]; +} + +// --------------------------------------------------------------------------- +// Devices and processes + +/** `fuser /dev/video0` stdout: ` 1234 5678m` → pids. */ +export function parseFuser(out: string): number[] { + return [...out.matchAll(/(\d+)[a-zA-Z]*/g)].map((m) => Number(m[1])); +} + +export interface Proc { + pid: number; + /** Seconds since it started. */ + elapsed: number; + user: string; + comm: string; + args: string; +} + +/** `ps -o pid=,etimes=,user=,comm=,args= -p …` */ +export function parsePs(out: string): Map { + const map = new Map(); + for (const line of out.split("\n")) { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/); + if (m) map.set(Number(m[1]), { pid: Number(m[1]), elapsed: Number(m[2]), user: m[3], comm: m[4], args: m[5] }); + } + return map; +} + +/** Camera users found by holding /dev/video*, PipeWire's own processes left out. */ +export function deviceUses(holders: { device: string; pids: number[] }[], procs: Map): Use[] { + const uses: Use[] = []; + for (const { device, pids } of holders) { + for (const pid of pids) { + const p = procs.get(pid); + if (p && PLUMBING.has(p.comm)) continue; + const name = p?.comm ?? `PID ${pid}`; + uses.push({ + kind: "camera", + key: `camera:pw:${pid}`, + app: prettyApp(name), + pid, + binary: p?.comm, + device, + via: "device", + }); + } + } + return dedupe(uses); +} + +/** "firefox" → "Firefox", "google-chrome" → "Google Chrome". */ +export function prettyApp(name: string): string { + if (/[A-Z]/.test(name)) return name; + return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Merges the sources: the first one to report an app (by pid) wins, later ones fill the blanks. */ +export function mergeUses(...lists: Use[][]): Use[] { + const out = new Map(); + for (const list of lists) { + for (const u of list) { + const key = u.pid ? `${u.kind}:pid:${u.pid}` : u.key; + const prev = out.get(key); + if (!prev) out.set(key, { ...u, key: u.pid ? `${u.kind}:${u.pid}` : u.key }); + else { + for (const [k, v] of Object.entries(u)) { + if ((prev as unknown as Record)[k] === undefined) { + (prev as unknown as Record)[k] = v; + } + } + } + } + } + return [...out.values()]; +} + +// --------------------------------------------------------------------------- +// GeoClue (busctl) + +/** Client object paths from `busctl --list tree org.freedesktop.GeoClue2`. */ +export function parseGeoclueClients(out: string): string[] { + return out.split("\n").map((l) => l.trim().replace(/^[└├│─\s]+/, "")).filter((l) => + /^\/org\/freedesktop\/GeoClue2\/Client\/\d+$/.test(l) + ); +} + +/** `busctl --json=short get-property …` → the value. */ +export function busValue(json: string): unknown { + try { + return JSON.parse(json)?.data; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Words + +export const KIND_TITLE: Record = { + camera: "Camera", + microphone: "Microphone", + screen: "Screen Sharing", + location: "Location", +}; + +/** "Camera and microphone in use", "Screen shared", or null. */ +export function subtitle(uses: Use[]): string | null { + const active = uses.filter((u) => !u.paused); + const has = (k: Kind) => active.some((u) => u.kind === k); + const devices = [has("camera") && "camera", has("microphone") && "microphone", has("location") && "location"] + .filter((x): x is string => !!x); + const parts: string[] = []; + if (devices.length) { + const words = devices.length === 1 ? devices[0] : `${devices.slice(0, -1).join(", ")} and ${devices.at(-1)}`; + parts.push(`${words[0].toUpperCase()}${words.slice(1)} in use`); + } + if (has("screen")) parts.push(parts.length ? "screen shared" : "Screen shared"); + return parts.length ? parts.join(", ") : null; +} + +/** "3 min", "1 h 05 min", "12 s". */ +export function since(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} h ${String(m % 60).padStart(2, "0")} min`; + return `${Math.floor(h / 24)} d ${h % 24} h`; +} + +export function clock(ms: number): string { + const d = new Date(ms); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} diff --git a/extensions/privacy/lib_test.ts b/extensions/privacy/lib_test.ts new file mode 100644 index 0000000..77c1066 --- /dev/null +++ b/extensions/privacy/lib_test.ts @@ -0,0 +1,218 @@ +import { assert, assertEquals } from "@std/assert"; +import { + busValue, + deviceUses, + mergeUses, + parseFuser, + parseGeoclueClients, + parsePs, + parsePwDump, + parseSourceOutputs, + prettyApp, + since, + subtitle, + type Use, +} from "./lib.ts"; + +// pw-dump of PipeWire 1.0, trimmed: Firefox uses the camera and the microphone, cava reads the +// speakers' monitor (not the mic), OBS records a screencast from xdg-desktop-portal-wlr. +const PW = JSON.stringify([ + { id: 0, type: "PipeWire:Interface:Core", info: { name: "pipewire-0" } }, + { + id: 33, + type: "PipeWire:Interface:Client", + info: { props: { "application.name": "WirePlumber", "application.process.binary": "wireplumber" } }, + }, + { + id: 75, + type: "PipeWire:Interface:Client", + info: { + props: { + "application.name": "Firefox", + "application.process.binary": "firefox", + "application.process.id": 4242, + "pipewire.access.portal.app_id": "org.mozilla.firefox", + }, + }, + }, + { + id: 90, + type: "PipeWire:Interface:Client", + info: { props: { "application.name": "xdg-desktop-portal-wlr", "application.process.id": 1500 } }, + }, + { + id: 91, + type: "PipeWire:Interface:Client", + info: { + props: { "application.name": "OBS Studio", "application.process.binary": "obs", "application.process.id": 5100 }, + }, + }, + { + id: 45, + type: "PipeWire:Interface:Node", + info: { + state: "running", + props: { + "media.class": "Video/Source", + "node.name": "v4l2_input.pci-0000_00_14.0-usb-0_6_1.0", + "node.description": "Integrated Camera (V4L2)", + "device.api": "v4l2", + "client.id": 33, + }, + }, + }, + { + id: 56, + type: "PipeWire:Interface:Node", + info: { props: { "media.class": "Audio/Source", "node.description": "Built-in Audio Analog Stereo" } }, + }, + { + id: 57, + type: "PipeWire:Interface:Node", + info: { props: { "media.class": "Audio/Sink", "node.description": "Speakers" } }, + }, + { + id: 79, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Video", "client.id": 75 } }, + }, + { + id: 82, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Audio", "client.id": 75 } }, + }, + { + id: 83, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Audio", "application.name": "cava" } }, + }, + { + id: 95, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Video/Source", "node.name": "xdpw_stream", "client.id": 90 } }, + }, + { + id: 96, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Video", "node.name": "OBS", "client.id": 91 } }, + }, + { id: 100, type: "PipeWire:Interface:Link", info: { "output-node-id": 45, "input-node-id": 79, state: "active" } }, + { id: 101, type: "PipeWire:Interface:Link", info: { "output-node-id": 56, "input-node-id": 82, state: "active" } }, + { id: 102, type: "PipeWire:Interface:Link", info: { "output-node-id": 57, "input-node-id": 83, state: "active" } }, + { id: 103, type: "PipeWire:Interface:Link", info: { "output-node-id": 95, "input-node-id": 96, state: "active" } }, +]); + +Deno.test("parsePwDump finds camera, microphone and screen users", () => { + const uses = parsePwDump(PW); + assertEquals(uses.map((u) => [u.kind, u.app, u.pid, u.device]), [ + ["camera", "Firefox", 4242, "Integrated Camera (V4L2)"], + ["screen", "OBS Studio", 5100, "xdpw_stream"], + ["microphone", "Firefox", 4242, "Built-in Audio Analog Stereo"], + ]); + assertEquals(uses[0].appId, "org.mozilla.firefox"); + assertEquals(parsePwDump("not json"), []); +}); + +Deno.test("parsePwDump shows a running screencast even without a viewer", () => { + const dump = JSON.parse(PW).filter((o: { id: number }) => o.id !== 103); + const uses = parsePwDump(JSON.stringify(dump)); + const screen = uses.find((u) => u.kind === "screen")!; + assertEquals([screen.app, screen.pid], ["xdg-desktop-portal-wlr", 1500]); +}); + +Deno.test("parseSourceOutputs keeps mic recordings, drops monitors and level meters", () => { + const outputs = JSON.stringify([ + { + index: 42, + source: 56, + corked: false, + mute: false, + properties: { + "application.name": "Firefox", + "application.process.id": "4242", + "application.process.binary": "firefox", + }, + }, + { index: 43, source: 57, properties: { "application.name": "cava" } }, + { index: 44, source: 56, properties: { "media.name": "Peak detect", "application.process.binary": "pavucontrol" } }, + { + index: 45, + source: 56, + corked: true, + properties: { "application.name": "Discord", "application.process.id": "777" }, + }, + ]); + const sources = JSON.stringify([ + { index: 56, name: "alsa_input.pci-0000_00_1f.3.analog-stereo", description: "Built-in Audio", mute: true }, + { index: 57, name: "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor", description: "Monitor of Speakers" }, + ]); + const uses = parseSourceOutputs(outputs, sources); + assertEquals(uses.map((u) => [u.app, u.pid, u.streamIndex, u.paused]), [["Firefox", 4242, 42, false], [ + "Discord", + 777, + 45, + true, + ]]); + assertEquals(uses[0].sourceName, "alsa_input.pci-0000_00_1f.3.analog-stereo"); + assertEquals(uses[0].sourceMuted, true); + assertEquals(parseSourceOutputs("[]", ""), []); +}); + +Deno.test("fuser, ps and device holders", () => { + assertEquals(parseFuser(" 1180m 7777"), [1180, 7777]); + assertEquals(parseFuser(""), []); + const procs = parsePs( + " 1180 9000 ada pipewire /usr/bin/pipewire\n 7777 65 ada zoom /opt/zoom/zoom --x\n", + ); + assertEquals(procs.get(7777), { pid: 7777, elapsed: 65, user: "ada", comm: "zoom", args: "/opt/zoom/zoom --x" }); + const uses = deviceUses([{ device: "/dev/video0", pids: [1180, 7777] }], procs); + assertEquals(uses.map((u) => [u.app, u.pid, u.device]), [["Zoom", 7777, "/dev/video0"]]); +}); + +Deno.test("mergeUses keeps one row per app, filling blanks", () => { + const a: Use = { kind: "microphone", key: "m1", app: "Firefox", pid: 4242, via: "pulse", streamIndex: 42 }; + const b: Use = { + kind: "microphone", + key: "m2", + app: "Firefox", + pid: 4242, + via: "pipewire", + appId: "org.mozilla.firefox", + }; + const c: Use = { kind: "camera", key: "c", app: "Firefox", pid: 4242, via: "pipewire" }; + const merged = mergeUses([a], [b, c]); + assertEquals(merged.length, 2); + assertEquals([merged[0].via, merged[0].streamIndex, merged[0].appId, merged[0].key], [ + "pulse", + 42, + "org.mozilla.firefox", + "microphone:4242", + ]); +}); + +Deno.test("GeoClue client listing", () => { + assertEquals( + parseGeoclueClients( + "/org/freedesktop/GeoClue2\n/org/freedesktop/GeoClue2/Client\n/org/freedesktop/GeoClue2/Client/3\n", + ), + ["/org/freedesktop/GeoClue2/Client/3"], + ); + assertEquals(parseGeoclueClients("└─ /org/freedesktop/GeoClue2/Client/1"), ["/org/freedesktop/GeoClue2/Client/1"]); + assertEquals(busValue('{"type":"b","data":true}'), true); + assertEquals(busValue("nope"), undefined); +}); + +Deno.test("words", () => { + const u = (kind: Use["kind"], paused = false): Use => ({ kind, key: kind, app: "x", via: "pipewire", paused }); + assertEquals(subtitle([]), null); + assertEquals(subtitle([u("camera")]), "Camera in use"); + assertEquals(subtitle([u("camera"), u("microphone")]), "Camera and microphone in use"); + assertEquals(subtitle([u("screen")]), "Screen shared"); + assertEquals(subtitle([u("microphone"), u("screen")]), "Microphone in use, screen shared"); + assertEquals(subtitle([u("microphone", true)]), null); + assertEquals(since(42), "42 s"); + assertEquals(since(3900), "1 h 05 min"); + assertEquals(prettyApp("google-chrome"), "Google Chrome"); + assertEquals(prettyApp("OBS"), "OBS"); + assert(since(90000).startsWith("1 d")); +}); diff --git a/extensions/startup-apps/backslash.toml b/extensions/startup-apps/backslash.toml new file mode 100644 index 0000000..f1d5b0c --- /dev/null +++ b/extensions/startup-apps/backslash.toml @@ -0,0 +1,40 @@ +[extension] +id = "startup-apps" +title = "Startup Apps" +description = "See and change what starts when you log in: autostart apps, systemd user services and Hyprland" +version = "1.0.0" +author = "Backslash" +icon = "bs-rocket" +license = "MIT" + +# Autostart entries are .desktop files in ~/.config/autostart (yours) and +# /etc/xdg/autostart (installed by packages); turning a system entry off writes +# a copy with Hidden=true in your folder, as GNOME Tweaks does. systemctl +# --user lists and toggles your systemd services. ~/.config/hypr is only read, +# for Hyprland's exec-once lines. +[permissions] +run = ["systemctl"] +read = ["~/.config/autostart", "/etc/xdg/autostart", "~/.config/hypr"] +write = ["~/.config/autostart"] +env = ["HOME", "XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "startup-apps" +title = "Startup Apps" +description = "Apps and services started at login; turn them on or off, add your own" +icon = "bs-rocket" +keywords = [ + "startup", + "autostart", + "login", + "boot", + "démarrage", + "démarrage automatique", + "connexion", + "session", + "systemd", + "services", + "exec-once", + "hyprland", + "tweaks", +] diff --git a/extensions/startup-apps/commands/startup-apps.ts b/extensions/startup-apps/commands/startup-apps.ts new file mode 100644 index 0000000..74d298f --- /dev/null +++ b/extensions/startup-apps/commands/startup-apps.ts @@ -0,0 +1,409 @@ +import { + type Action, + action, + copy, + exec, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + pop, + storage, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Autostart, + type DesktopFile, + desktopFileName, + type ExecOnce, + mergeAutostart, + newDesktopFile, + parseHyprland, + parseShow, + parseUnitFiles, + programName, + resolveSource, + tilde, + toggledText, + type UnitInfo, +} from "../lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +const DESKTOP = Deno.env.get("XDG_CURRENT_DESKTOP") ?? ""; +const USER_DIR = `${HOME}/.config/autostart`; +const SYSTEM_DIR = "/etc/xdg/autostart"; +const HYPR_DIR = `${HOME}/.config/hypr`; + +// --- Loading ------------------------------------------------------------------- + +async function readDir(dir: string): Promise { + const files: DesktopFile[] = []; + try { + for await (const e of Deno.readDir(dir)) { + if (!e.name.endsWith(".desktop") || e.isDirectory) continue; + const path = `${dir}/${e.name}`; + const text = await Deno.readTextFile(path).catch(() => undefined); + if (text !== undefined) files.push({ path, text }); + } + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + return files; +} + +interface Unit extends UnitInfo { + enabled: boolean; + preset?: string; +} + +const DISABLED_KEY = "disabled-units"; + +/** User units enabled at login, plus those turned off here (so they can be turned back on). */ +async function loadUnits(): Promise { + let out: string; + try { + out = await exec("systemctl", [ + "--user", + "list-unit-files", + "--state=enabled", + "--type=service", + "--no-legend", + "--plain", + "--no-pager", + ]); + } catch { + return undefined; // No systemd, or no user manager. + } + const files = parseUnitFiles(out); + const disabledHere = (await storage.get(DISABLED_KEY).catch(() => undefined)) ?? []; + const names = [...new Set([...files.map((f) => f.unit), ...disabledHere])]; + if (names.length === 0) return []; + const show = await exec("systemctl", [ + "--user", + "show", + "--property=Id,Description,ActiveState,SubState,FragmentPath,WantedBy,ExecStart,UnitFileState", + "--no-pager", + "--", + ...names, + ]).catch(() => undefined); + const infos = new Map(parseShow(show ?? "").map((i) => [i.id, i])); + const enabled = new Set(files.map((f) => f.unit)); + // A unit disabled here and later re-enabled elsewhere, or removed, needs no reminder. + const stillDisabled = show === undefined + ? disabledHere.filter((u) => !enabled.has(u)) + : disabledHere.filter((u) => !enabled.has(u) && infos.get(u)?.fragmentPath); + if (stillDisabled.length !== disabledHere.length) storage.set(DISABLED_KEY, stillDisabled).catch(() => {}); + return [...enabled, ...stillDisabled].map((unit) => ({ + ...(infos.get(unit) ?? { id: unit, wantedBy: [] }), + id: unit, + enabled: enabled.has(unit), + preset: files.find((f) => f.unit === unit)?.preset, + })); +} + +async function loadHyprland(): Promise<{ execs: ExecOnce[]; config?: string }> { + const main = `${HYPR_DIR}/hyprland.conf`; + const execs: ExecOnce[] = []; + const seen = new Set(); + const visit = async (file: string, depth: number) => { + if (seen.has(file) || depth > 5 || !file.startsWith(HYPR_DIR + "/")) return; + seen.add(file); + const text = await Deno.readTextFile(file).catch(() => undefined); + if (text === undefined) return; + const parsed = parseHyprland(text, file); + execs.push(...parsed.execs); + for (const s of parsed.sources) await visit(resolveSource(s, file, HOME), depth + 1); + }; + await visit(main, 0); + return { execs, config: seen.size ? main : undefined }; +} + +// --- Changes ------------------------------------------------------------------- + +const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +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); + }); + } +} + +async function setAutostart(a: Autostart, on: boolean) { + const userPath = `${USER_DIR}/${a.id}`; + const userText = a.origin === "system" ? undefined : await Deno.readTextFile(userPath); + const systemPath = a.origin === "system" ? a.path : a.systemPath; + const systemText = systemPath ? await Deno.readTextFile(systemPath) : undefined; + await Deno.mkdir(USER_DIR, { recursive: true }); + await Deno.writeTextFile(userPath, toggledText(userText, systemText, on)); +} + +async function setUnit(u: Unit, on: boolean) { + await exec("systemctl", ["--user", on ? "enable" : "disable", "--", u.id]).catch((err) => { + throw new Error(String(err?.message ?? err).replace(/^systemctl exited with \d+: /, "")); + }); + const list = (await storage.get(DISABLED_KEY).catch(() => undefined)) ?? []; + await storage.set(DISABLED_KEY, on ? list.filter((x) => x !== u.id) : [...new Set([...list, u.id])]); +} + +const addForm = () => + form({ + title: "Add Startup App", + submitTitle: "Add", + fields: [ + field.text("name", "Name", { required: true, placeholder: "Syncthing" }), + field.text("command", "Command", { required: true, placeholder: "syncthing serve --no-browser" }), + field.text("comment", "Description", { placeholder: "Optional" }), + ], + async submit(values) { + const name = String(values.name).trim(); + const command = String(values.command).trim(); + if (!name || !command) throw new Error("A name and a command are needed"); + await Deno.mkdir(USER_DIR, { recursive: true }); + const taken = (await readDir(USER_DIR)).map((f) => f.path.split("/").pop()!); + const file = desktopFileName(name, taken); + await Deno.writeTextFile(`${USER_DIR}/${file}`, newDesktopFile(name, command, String(values.comment ?? ""))); + await toast(`${name} starts at login`, { style: "success" }); + changed(); + return pop(); + }, + }); + +// --- Items --------------------------------------------------------------------- + +const ORIGIN: Record = { + user: "Added by you", + system: "Installed by the system", + override: "System entry, changed by you", +}; + +function autostartItem(a: Autostart): Item { + const toggle = action(a.enabled ? "Disable at Login" : "Enable at Login", async () => { + await setAutostart(a, !a.enabled); + await toast(`${a.name} ${a.enabled ? "won't start" : "starts"} at login`, { style: "success" }); + changed(); + }, { icon: a.enabled ? Icons.pause : Icons.play, shortcut: "t" }); + const actions: Action[] = [ + toggle, + open(a.path, "Open Desktop File", { icon: Icons.file, shortcut: "o" }), + ...(a.systemPath && a.systemPath !== a.path ? [open(a.systemPath, "Open System File", { icon: Icons.file })] : []), + open(USER_DIR, "Open Autostart Folder", { icon: Icons.folder }), + copy(a.exec, "Copy Command", { section: "Copy", shortcut: "c" }), + copy(a.path, "Copy File Path", { section: "Copy" }), + ]; + if (a.origin === "user") { + actions.push(action("Remove", async () => { + await Deno.remove(a.path); + await toast(`${a.name} removed`, { style: "success" }); + changed(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${a.id} from ~/.config/autostart?`, + shortcut: "BackSpace", + section: "Danger", + })); + } else if (a.origin === "override") { + actions.push(action("Restore System Default", async () => { + await Deno.remove(a.path); + await toast(`${a.name} restored`, { style: "success" }); + changed(); + }, { + icon: Icons.history, + destructive: true, + confirm: `Delete your copy of ${a.id} and use the system's again?`, + section: "Danger", + })); + } + const metadata: Metadata[] = [ + meta.text("Status", a.enabled ? "Starts at login" : "Disabled", { + icon: a.enabled ? Icons.done : Icons.canceled, + color: a.enabled ? "green" : "gray", + }), + meta.text("Program", programName(a.exec) || "–", { icon: Icons.terminal }), + ...(a.delay ? [meta.text("Delay", `${a.delay} s`, { icon: Icons.timer })] : []), + ...(a.entry.OnlyShowIn ? [meta.tags("Only in", a.entry.OnlyShowIn.split(";").filter(Boolean))] : []), + ...(a.entry.NotShowIn ? [meta.tags("Not in", a.entry.NotShowIn.split(";").filter(Boolean))] : []), + meta.separator(), + meta.text("Origin", ORIGIN[a.origin], { icon: a.origin === "system" ? Icons.server : Icons.person }), + meta.text("File", tilde(a.path, HOME), { icon: Icons.file }), + ...(a.systemPath && a.systemPath !== a.path ? [meta.text("System file", a.systemPath)] : []), + ]; + return { + id: `xdg:${a.id}`, + title: a.name, + subtitle: a.comment ?? programName(a.exec), + icon: { source: Icons.rocket, tint: a.enabled ? (a.otherDesktop ? "gray" : "green") : "gray" }, + section: "Autostart Apps", + keywords: [a.id, a.exec, programName(a.exec), a.enabled ? "enabled" : "disabled"], + accessories: [ + ...(a.otherDesktop && a.enabled ? [{ tag: "Other desktops", color: "gray" as const }] : []), + ...(a.origin !== "system" ? [{ icon: Icons.person, tooltip: ORIGIN[a.origin] }] : []), + a.enabled ? { tag: "On", color: "green" as const } : { tag: "Off", color: "gray" as const }, + ], + detail: { + markdown: [ + a.comment ?? "", + a.otherDesktop + ? `> Not started in this desktop (${DESKTOP || "unknown"}): its OnlyShowIn / NotShowIn rule leaves it out.` + : "", + "```\n" + (a.exec || "(no command)") + "\n```", + ].filter(Boolean).join("\n\n"), + metadata, + }, + actions, + }; +} + +function unitItem(u: Unit): Item { + const running = u.activeState === "active"; + const failed = u.activeState === "failed"; + const kind = u.id.split(".").pop() ?? "service"; + const svc = (verb: string, done: string) => + action(`${verb[0].toUpperCase()}${verb.slice(1)} Now`, async () => { + await exec("systemctl", ["--user", verb, "--", u.id]); + await toast(`${u.id} ${done}`, { style: "success" }); + changed(); + }, { + icon: verb === "stop" ? Icons.stop : verb === "start" ? Icons.play : Icons.refresh, + section: "Service", + ...(verb === "stop" ? { confirm: `Stop ${u.id} now?` } : {}), + }); + return { + id: `unit:${u.id}`, + title: u.description && u.description !== u.id ? u.description : u.id, + subtitle: u.description && u.description !== u.id ? u.id : undefined, + icon: { source: Icons.server, tint: !u.enabled ? "gray" : failed ? "red" : running ? "blue" : "gray" }, + section: "Systemd User Services", + keywords: [u.id, kind, "systemd", u.execStart ?? "", u.enabled ? "enabled" : "disabled"], + accessories: [ + ...(failed ? [{ tag: "Failed", color: "red" as const }] : running ? [{ text: "running" }] : []), + u.enabled ? { tag: "On", color: "green" as const } : { tag: "Off", color: "gray" as const }, + ], + detail: { + markdown: u.execStart ? "```\n" + u.execStart + "\n```" : undefined, + metadata: [ + meta.text("Unit", u.id, { icon: Icons.server }), + meta.text("At login", u.enabled ? "Enabled" : "Disabled", { + icon: u.enabled ? Icons.done : Icons.canceled, + color: u.enabled ? "green" : "gray", + }), + meta.text("State", [u.activeState, u.subState].filter(Boolean).join(" · ") || "unknown", { + color: failed ? "red" : running ? "green" : undefined, + }), + ...(u.wantedBy.length ? [meta.tags("Wanted by", u.wantedBy)] : []), + ...(u.preset ? [meta.text("Vendor preset", u.preset)] : []), + meta.separator(), + ...(u.fragmentPath ? [meta.text("File", tilde(u.fragmentPath, HOME), { icon: Icons.file })] : []), + ], + }, + actions: [ + action(u.enabled ? "Disable at Login" : "Enable at Login", async () => { + await setUnit(u, !u.enabled); + await toast(`${u.id} ${u.enabled ? "disabled" : "enabled"}`, { style: "success" }); + changed(); + }, { icon: u.enabled ? Icons.pause : Icons.play, shortcut: "t" }), + ...(u.fragmentPath ? [open(u.fragmentPath, "Open Unit File", { icon: Icons.file, shortcut: "o" })] : []), + running ? svc("stop", "stopped") : svc("start", "started"), + ...(running ? [svc("restart", "restarted")] : []), + copy(u.id, "Copy Unit Name", { section: "Copy" }), + copy(`systemctl --user status ${u.id}`, "Copy Status Command", { section: "Copy" }), + ], + }; +} + +function hyprItem(e: ExecOnce, config: string): Item { + const where = `${tilde(e.file, HOME)}:${e.line}`; + return { + id: `hypr:${e.file}:${e.line}`, + title: programName(e.command) || e.command, + subtitle: e.command !== programName(e.command) ? e.command : undefined, + icon: { source: Icons.terminal, tint: "teal" }, + section: "Hyprland", + keywords: [e.command, "hyprland", e.keyword], + accessories: [{ text: where.split("/").pop(), tooltip: where }, { tag: e.keyword, color: "teal" }], + detail: { + markdown: [ + "```", + e.command, + "```", + "", + e.keyword === "exec-once" + ? "Started once when Hyprland starts. Edit the line in your config to change it." + : "Started at login and again each time the config is reloaded.", + ].join("\n"), + metadata: [ + meta.text("Program", programName(e.command), { icon: Icons.terminal }), + meta.text("Keyword", e.keyword), + meta.text("Defined in", where, { icon: Icons.file }), + meta.text("Status", "Read-only here", { icon: Icons.lock, color: "gray" }), + ], + }, + actions: [ + open(e.file, "Open Config File", { icon: Icons.file }), + ...(e.file !== config ? [open(config, "Open hyprland.conf", { icon: Icons.file })] : []), + copy(e.command, "Copy Command", { section: "Copy", shortcut: "c" }), + copy(where, "Copy Location", { section: "Copy" }), + ], + }; +} + +let counts = { shown: 0, enabled: 0 }; + +const addAction = action("Add Startup App…", () => addForm(), { icon: Icons.plus, shortcut: "n" }); + +export default list({ + placeholder: "Filter apps that start at login…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "enabled", title: "Enabled" }, + { value: "disabled", title: "Disabled" }, + ], + }, + empty: { + title: "Nothing starts at login", + description: "Press Ctrl+N to add an app that starts when you log in", + icon: Icons.rocket, + }, + actions: [addAction, open(USER_DIR, "Open Autostart Folder", { icon: Icons.folder })], + status: (items) => + `${counts.enabled} at login${items.length !== counts.shown ? ` · ${items.length} of ${counts.shown} shown` : ""}`, + items: ({ filter, signal }) => + live(async () => { + const [user, system, units, hypr] = await Promise.all([ + readDir(USER_DIR), + readDir(SYSTEM_DIR), + loadUnits(), + loadHyprland(), + ]); + const autostart = mergeAutostart(user, system, DESKTOP); + const on = (a: { enabled: boolean }) => filter !== "enabled" || a.enabled; + const off = (a: { enabled: boolean }) => filter !== "disabled" || !a.enabled; + const keep = (a: { enabled: boolean }) => on(a) && off(a); + const enabled = autostart.filter((a) => a.enabled && !a.otherDesktop).length + + (units ?? []).filter((u) => u.enabled).length + hypr.execs.length; + updateCommandMetadata({ subtitle: `${enabled} at login` }).catch(() => {}); + const items = [ + ...autostart.filter(keep).map(autostartItem).map((i) => ({ ...i, actions: [...i.actions!, addAction] })), + ...(units ?? []).filter(keep).map(unitItem), + ...(filter === "disabled" ? [] : hypr.execs.map((e) => hyprItem(e, hypr.config!))), + ]; + counts = { shown: items.length, enabled }; + return items; + }, signal), +}); diff --git a/extensions/startup-apps/lib.ts b/extensions/startup-apps/lib.ts new file mode 100644 index 0000000..1cc429f --- /dev/null +++ b/extensions/startup-apps/lib.ts @@ -0,0 +1,293 @@ +// Pure helpers for Startup Apps: XDG autostart files, systemd user units and Hyprland's exec-once. + +/** The keys of a .desktop file's [Desktop Entry] group (localized keys left out). */ +export type DesktopEntry = Record; + +export function parseDesktop(text: string): DesktopEntry { + const out: DesktopEntry = {}; + let inEntry = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("[")) { + inEntry = line === "[Desktop Entry]"; + continue; + } + if (!inEntry) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + if (key.includes("[")) continue; + out[key] = line.slice(eq + 1).trim(); + } + return out; +} + +/** + * Sets (or with `undefined`, removes) a key of the [Desktop Entry] group, + * keeping the rest of the file as it is. + */ +export function setDesktopKey(text: string, key: string, value: string | undefined): string { + const lines = text.trim() ? text.replace(/\n+$/, "").split("\n") : []; + let start = lines.findIndex((l) => l.trim() === "[Desktop Entry]"); + if (start < 0) { + lines.unshift("[Desktop Entry]"); + start = 0; + } + let end = lines.findIndex((l, i) => i > start && l.trim().startsWith("[")); + if (end < 0) end = lines.length; + const at = lines.findIndex((l, i) => i > start && i < end && l.split("=")[0].trim() === key); + if (value === undefined) { + if (at >= 0) lines.splice(at, 1); + } else if (at >= 0) { + lines[at] = `${key}=${value}`; + } else { + // After the last key of the group, before blank lines that separate the next one. + let insert = end; + while (insert > start + 1 && lines[insert - 1].trim() === "") insert--; + lines.splice(insert, 0, `${key}=${value}`); + } + return lines.join("\n") + "\n"; +} + +const isTrue = (v?: string) => v?.toLowerCase() === "true"; +const isFalse = (v?: string) => v?.toLowerCase() === "false"; + +/** Whether the desktop starts the entry: `Hidden=true` and `X-GNOME-Autostart-enabled=false` turn it off. */ +export function isEnabled(e: DesktopEntry): boolean { + return !isTrue(e.Hidden) && !isFalse(e["X-GNOME-Autostart-enabled"]); +} + +/** Whether OnlyShowIn / NotShowIn let the entry start in this desktop (`XDG_CURRENT_DESKTOP`, colon separated). */ +export function showsIn(e: DesktopEntry, currentDesktop: string): boolean { + const current = currentDesktop.split(":").map((d) => d.trim().toLowerCase()).filter(Boolean); + const list = (v?: string) => (v ?? "").split(";").map((d) => d.trim().toLowerCase()).filter(Boolean); + const only = list(e.OnlyShowIn); + const not = list(e.NotShowIn); + if (current.length === 0) return true; + if (only.length && !only.some((d) => current.includes(d))) return false; + if (not.some((d) => current.includes(d))) return false; + return true; +} + +/** Exec without field codes (%u, %F…): what actually runs. */ +export function cleanExec(exec: string): string { + return exec.replace(/\s+%[fFuUdDnNickvm]/g, "").replace(/%%/g, "%").trim(); +} + +/** The program of a command line: `env FOO=1 /usr/bin/app --x` → `app`. */ +export function programName(exec: string): string { + const words = cleanExec(exec).match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + let i = 0; + if (words[i] === "env") { + i++; + while (words[i] && (/^\w+=/.test(words[i]) || words[i].startsWith("-"))) i++; + } + while (words[i] && /^\w+=/.test(words[i])) i++; + const prog = (words[i] ?? "").replace(/^["']|["']$/g, ""); + return prog.split("/").pop() ?? prog; +} + +export type Origin = "user" | "system" | "override"; + +/** One XDG autostart entry, after the user's folder has overridden the system's. */ +export interface Autostart { + /** The file name, e.g. `nm-applet.desktop`; the same name in the user folder overrides the system one. */ + id: string; + name: string; + comment?: string; + exec: string; + enabled: boolean; + /** `user`: only in ~/.config/autostart; `system`: only in /etc/xdg/autostart; `override`: the user's copy of a system entry. */ + origin: Origin; + /** The file that decides (the user's when there is one). */ + path: string; + systemPath?: string; + /** OnlyShowIn / NotShowIn keep it from starting here. */ + otherDesktop: boolean; + delay?: number; + terminal: boolean; + entry: DesktopEntry; +} + +export interface DesktopFile { + path: string; + text: string; +} + +/** + * Merges the user's autostart folder over the system's, as session managers do: + * a user file with the same name replaces the system one. + */ +export function mergeAutostart(user: DesktopFile[], system: DesktopFile[], currentDesktop = ""): Autostart[] { + const name = (p: string) => p.split("/").pop() ?? p; + const sys = new Map(system.map((f) => [name(f.path), f])); + const out = new Map(); + const add = (file: DesktopFile, origin: Origin, base?: DesktopFile) => { + const own = parseDesktop(file.text); + // An override may hold little more than Hidden=true: show the system entry's name and command then. + const e = base ? { ...parseDesktop(base.text), ...own } : own; + const id = name(file.path); + const delay = Number(e["X-GNOME-Autostart-Delay"]); + out.set(id, { + id, + name: e.Name || id.replace(/\.desktop$/, ""), + comment: e.Comment || e.GenericName || undefined, + exec: cleanExec(e.Exec ?? ""), + enabled: isEnabled(own), + origin, + path: file.path, + systemPath: base?.path, + otherDesktop: !showsIn(e, currentDesktop), + delay: Number.isFinite(delay) && delay > 0 ? delay : undefined, + terminal: isTrue(e.Terminal), + entry: e, + }); + }; + for (const f of system) add(f, "system"); + for (const f of user) { + const base = sys.get(name(f.path)); + add(f, base ? "override" : "user", base); + } + return [...out.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +/** + * The user's file after turning an entry on or off, as GNOME Tweaks does: the + * user's own file is edited, a system entry gets a copy in the user's folder. + */ +export function toggledText(userText: string | undefined, systemText: string | undefined, on: boolean): string { + let text = userText ?? systemText ?? "[Desktop Entry]\n"; + text = setDesktopKey(text, "Hidden", on ? "false" : "true"); + if (!on) return setDesktopKey(text, "X-GNOME-Autostart-enabled", "false"); + return parseDesktop(text)["X-GNOME-Autostart-enabled"] === undefined + ? text + : setDesktopKey(text, "X-GNOME-Autostart-enabled", "true"); +} + +/** A new autostart entry. */ +export function newDesktopFile(name: string, command: string, comment = ""): string { + const lines = [ + "[Desktop Entry]", + "Type=Application", + `Name=${name.replace(/\n/g, " ").trim()}`, + ...(comment.trim() ? [`Comment=${comment.replace(/\n/g, " ").trim()}`] : []), + `Exec=${command.replace(/\n/g, " ").trim()}`, + "Terminal=false", + "X-GNOME-Autostart-enabled=true", + "Hidden=false", + ]; + return lines.join("\n") + "\n"; +} + +/** A file name for a new entry that doesn't collide with `taken`. */ +export function desktopFileName(name: string, taken: string[]): string { + const slug = name.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "startup-app"; + let id = `${slug}.desktop`; + for (let i = 2; taken.includes(id); i++) id = `${slug}-${i}.desktop`; + return id; +} + +// --------------------------------------------------------------------------- +// systemd + +export interface UnitFile { + unit: string; + state: string; + preset?: string; +} + +/** `systemctl --user list-unit-files --no-legend --plain` lines. */ +export function parseUnitFiles(out: string): UnitFile[] { + return out.split("\n").map((l) => l.trim().split(/\s+/)).filter((w) => w.length >= 2 && w[0].includes(".")) + .map(([unit, state, preset]) => ({ unit, state, preset: preset && preset !== "-" ? preset : undefined })); +} + +export interface UnitInfo { + id: string; + description?: string; + activeState?: string; + subState?: string; + fragmentPath?: string; + wantedBy: string[]; + execStart?: string; +} + +/** `systemctl show -p …` for several units: blocks of Key=Value separated by blank lines. */ +export function parseShow(out: string): UnitInfo[] { + return out.split(/\n\s*\n/).map((block) => { + const kv: Record = {}; + for (const line of block.split("\n")) { + const eq = line.indexOf("="); + if (eq > 0) kv[line.slice(0, eq)] = line.slice(eq + 1); + } + return kv; + }).filter((kv) => kv.Id).map((kv) => ({ + id: kv.Id, + description: kv.Description || undefined, + activeState: kv.ActiveState || undefined, + subState: kv.SubState || undefined, + fragmentPath: kv.FragmentPath || undefined, + wantedBy: (kv.WantedBy ?? "").split(/\s+/).filter(Boolean), + execStart: kv.ExecStart ? execFromShow(kv.ExecStart) : undefined, + })); +} + +/** `{ path=/usr/bin/foo ; argv[]=/usr/bin/foo --bar ; … }` → `/usr/bin/foo --bar`. */ +export function execFromShow(v: string): string | undefined { + return v.match(/argv\[\]=([^;]*)/)?.[1]?.trim() || undefined; +} + +// --------------------------------------------------------------------------- +// Hyprland + +export interface ExecOnce { + command: string; + file: string; + line: number; + /** `exec-once` or `exec` (run again at each reload). */ + keyword: "exec-once" | "exec"; +} + +/** `exec-once` lines of a Hyprland config, and the files it `source`s. */ +export function parseHyprland(text: string, file: string): { execs: ExecOnce[]; sources: string[] } { + const execs: ExecOnce[] = []; + const sources: string[] = []; + text.split("\n").forEach((raw, i) => { + // `##` is a literal #; a single # starts a comment. + const line = stripComment(raw).trim(); + const m = line.match(/^(exec-once|exec|source)\s*=\s*(.+)$/); + if (!m) return; + if (m[1] === "source") sources.push(m[2].trim()); + else execs.push({ command: m[2].trim(), file, line: i + 1, keyword: m[1] as "exec-once" | "exec" }); + }); + return { execs, sources }; +} + +/** Removes a `# comment`; `##` stands for a literal `#`. */ +function stripComment(line: string): string { + let out = ""; + for (let i = 0; i < line.length; i++) { + if (line[i] !== "#") out += line[i]; + else if (line[i + 1] === "#") out += line[i++]; + else break; + } + return out; +} + +/** A `source =` path relative to the config, with `~` expanded. */ +export function resolveSource(path: string, fromFile: string, home: string): string { + if (path === "~" || path.startsWith("~/")) return home + path.slice(1); + if (path.startsWith("$HOME/")) return home + path.slice(5); + if (path.startsWith("/")) return path; + return `${fromFile.slice(0, fromFile.lastIndexOf("/"))}/${path}`; +} + +export function tilde(p: string, home: string): string { + return home && (p === home || p.startsWith(home + "/")) ? "~" + p.slice(home.length) : p; +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} diff --git a/extensions/startup-apps/lib_test.ts b/extensions/startup-apps/lib_test.ts new file mode 100644 index 0000000..ba8a39b --- /dev/null +++ b/extensions/startup-apps/lib_test.ts @@ -0,0 +1,172 @@ +import { assert, assertEquals } from "@std/assert"; +import { + cleanExec, + desktopFileName, + execFromShow, + isEnabled, + mergeAutostart, + newDesktopFile, + parseDesktop, + parseHyprland, + parseShow, + parseUnitFiles, + programName, + resolveSource, + setDesktopKey, + showsIn, + toggledText, +} from "./lib.ts"; + +const KEYRING = `[Desktop Entry] +Type=Application +Name=SSH Key Agent +Name[fr]=Agent de clés SSH +Comment=GNOME Keyring: SSH Agent +Exec=/usr/bin/gnome-keyring-daemon --start --components=ssh +OnlyShowIn=GNOME;Unity;MATE; +X-GNOME-Autostart-Phase=PreDisplayServer +X-GNOME-AutoRestart=false +X-GNOME-Autostart-Notify=true +X-GNOME-Bugzilla-Component=general + +[Desktop Action Quit] +Name=Quit +Exec=pkill gnome-keyring +`; + +const NM = `[Desktop Entry] +Name=Network +Comment=Manage your network connections +Icon=nm-device-wireless +Exec=nm-applet %U +Terminal=false +Type=Application +NotShowIn=KDE;GNOME; +X-GNOME-Autostart-enabled=true +X-GNOME-Autostart-Delay=3 +`; + +Deno.test("parseDesktop reads the main group only, without translations", () => { + const e = parseDesktop(KEYRING); + assertEquals(e.Name, "SSH Key Agent"); + assertEquals(e.Exec, "/usr/bin/gnome-keyring-daemon --start --components=ssh"); + assertEquals(e["Name[fr]"], undefined); +}); + +Deno.test("isEnabled honours Hidden and X-GNOME-Autostart-enabled", () => { + assert(isEnabled(parseDesktop(NM))); + assert(!isEnabled({ Hidden: "true" })); + assert(!isEnabled({ "X-GNOME-Autostart-enabled": "false" })); + assert(isEnabled({ Hidden: "false" })); +}); + +Deno.test("showsIn applies OnlyShowIn and NotShowIn to XDG_CURRENT_DESKTOP", () => { + const keyring = parseDesktop(KEYRING); + assert(showsIn(keyring, "ubuntu:GNOME")); + assert(!showsIn(keyring, "Hyprland")); + assert(showsIn(keyring, "")); + assert(!showsIn(parseDesktop(NM), "KDE")); + assert(showsIn(parseDesktop(NM), "sway")); +}); + +Deno.test("cleanExec and programName", () => { + assertEquals(cleanExec("nm-applet %U"), "nm-applet"); + assertEquals(programName("env GDK_BACKEND=x11 /usr/bin/discord --start-minimized %U"), "discord"); + assertEquals(programName("FOO=1 waybar -c ~/.config/waybar/config"), "waybar"); + assertEquals(programName(`"/opt/My App/app" --x`), "app"); +}); + +Deno.test("setDesktopKey edits, adds and removes keys in the main group", () => { + const hidden = setDesktopKey(KEYRING, "Hidden", "true"); + assertEquals(parseDesktop(hidden).Hidden, "true"); + // Added to [Desktop Entry], not to the action group that follows. + assert(hidden.indexOf("Hidden=true") < hidden.indexOf("[Desktop Action Quit]")); + assertEquals(hidden.split("\n").filter((l) => l === "").length, 2); + const again = setDesktopKey(hidden, "Hidden", "false"); + assertEquals(again.match(/Hidden=/g)?.length, 1); + assertEquals(parseDesktop(setDesktopKey(again, "Hidden", undefined)).Hidden, undefined); + assertEquals(setDesktopKey("", "Hidden", "true"), "[Desktop Entry]\nHidden=true\n"); +}); + +Deno.test("mergeAutostart: user files replace system ones", () => { + const entries = mergeAutostart( + [ + { path: "/home/ada/.config/autostart/nm-applet.desktop", text: "[Desktop Entry]\nHidden=true\n" }, + { path: "/home/ada/.config/autostart/syncthing.desktop", text: newDesktopFile("Syncthing", "syncthing serve") }, + ], + [ + { path: "/etc/xdg/autostart/nm-applet.desktop", text: NM }, + { path: "/etc/xdg/autostart/gnome-keyring-ssh.desktop", text: KEYRING }, + ], + "Hyprland", + ); + assertEquals(entries.map((e) => [e.name, e.origin, e.enabled]), [ + ["Network", "override", false], + ["SSH Key Agent", "system", true], + ["Syncthing", "user", true], + ]); + const nm = entries[0]; + assertEquals(nm.exec, "nm-applet"); + assertEquals(nm.delay, 3); + assertEquals(nm.systemPath, "/etc/xdg/autostart/nm-applet.desktop"); + assert(entries[1].otherDesktop); + assert(!nm.otherDesktop); +}); + +Deno.test("toggledText writes a GNOME Tweaks style override", () => { + const off = toggledText(undefined, NM, false); + const e = parseDesktop(off); + assertEquals([e.Hidden, e["X-GNOME-Autostart-enabled"], e.Exec], ["true", "false", "nm-applet %U"]); + assert(!isEnabled(e)); + const on = parseDesktop(toggledText(off, NM, true)); + assert(isEnabled(on)); + assertEquals(on["X-GNOME-Autostart-enabled"], "true"); + // A user entry without the GNOME key doesn't get one when turned back on. + const mine = newDesktopFile("Syncthing", "syncthing serve").replace("X-GNOME-Autostart-enabled=true\n", ""); + assertEquals(parseDesktop(toggledText(mine, undefined, true))["X-GNOME-Autostart-enabled"], undefined); +}); + +Deno.test("newDesktopFile and desktopFileName", () => { + const e = parseDesktop(newDesktopFile("Élément", "element-desktop --hidden", "Matrix")); + assertEquals([e.Type, e.Name, e.Exec, e.Comment], ["Application", "Élément", "element-desktop --hidden", "Matrix"]); + assertEquals(desktopFileName("Élément Desktop", []), "element-desktop.desktop"); + assertEquals(desktopFileName("Syncthing", ["syncthing.desktop", "syncthing-2.desktop"]), "syncthing-3.desktop"); + assertEquals(desktopFileName("!!!", []), "startup-app.desktop"); +}); + +Deno.test("parseUnitFiles and parseShow read systemctl output", () => { + assertEquals(parseUnitFiles("syncthing.service enabled enabled\nmako.service enabled -\n\n"), [ + { unit: "syncthing.service", state: "enabled", preset: "enabled" }, + { unit: "mako.service", state: "enabled", preset: undefined }, + ]); + const show = parseShow( + "Id=syncthing.service\nDescription=Syncthing\nActiveState=active\nSubState=running\n" + + "FragmentPath=/usr/lib/systemd/user/syncthing.service\nWantedBy=default.target\n" + + "ExecStart={ path=/usr/bin/syncthing ; argv[]=/usr/bin/syncthing serve --no-browser ; ignore_errors=no }\n\n" + + "Id=mako.service\nActiveState=failed\nWantedBy=\n", + ); + assertEquals(show[0].execStart, "/usr/bin/syncthing serve --no-browser"); + assertEquals(show[0].wantedBy, ["default.target"]); + assertEquals([show[1].id, show[1].activeState, show[1].description], ["mako.service", "failed", undefined]); + assertEquals(execFromShow("{ path=/bin/true ; argv[]= ; }"), undefined); +}); + +Deno.test("parseHyprland finds exec-once lines and sources", () => { + const conf = [ + "monitor=,preferred,auto,1", + "exec-once = waybar", + " exec-once=hyprpaper # wallpaper", + "# exec-once = disabled", + `exec = notify-send "a ## b"`, + "source = ~/.config/hypr/apps.conf", + ].join("\n"); + const { execs, sources } = parseHyprland(conf, "/home/ada/.config/hypr/hyprland.conf"); + assertEquals(execs.map((e) => [e.command, e.line, e.keyword]), [ + ["waybar", 2, "exec-once"], + ["hyprpaper", 3, "exec-once"], + [`notify-send "a # b"`, 5, "exec"], + ]); + assertEquals(sources, ["~/.config/hypr/apps.conf"]); + assertEquals(resolveSource("~/x.conf", "/h/.config/hypr/hyprland.conf", "/h"), "/h/x.conf"); + assertEquals(resolveSource("conf/a.conf", "/h/.config/hypr/hyprland.conf", "/h"), "/h/.config/hypr/conf/a.conf"); +}); diff --git a/extensions/system-logs/backslash.toml b/extensions/system-logs/backslash.toml new file mode 100644 index 0000000..a1a3d52 --- /dev/null +++ b/extensions/system-logs/backslash.toml @@ -0,0 +1,54 @@ +[extension] +id = "system-logs" +title = "System Logs" +description = "Errors and warnings of this boot from systemd's journal, grouped by service" +version = "1.0.0" +author = "Backslash" +icon = "bs-activity" +license = "MIT" + +# journalctl reads the system journal (when your account may: systemd-journal +# or adm group) and your own. The terminals only follow a unit's logs live. +[permissions] +run = [ + "journalctl", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "logs" +title = "System Logs" +description = "Errors and warnings of this boot by unit, previous boots, and live logs in a terminal" +icon = "bs-activity" +keywords = [ + "logs", + "journal", + "journalctl", + "errors", + "warnings", + "erreurs", + "avertissements", + "journaux", + "systemd", + "syslog", + "dmesg", + "kernel", + "boot", + "démarrage", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Used to follow logs live. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/system-logs/commands/logs.ts b/extensions/system-logs/commands/logs.ts new file mode 100644 index 0000000..0c7acbb --- /dev/null +++ b/extensions/system-logs/commands/logs.ts @@ -0,0 +1,352 @@ +import { + type Action, + action, + close, + type Color, + type Context, + copy, + detail, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Boot, + codeBlock, + duration, + type Entry, + FIELDS, + firstLine, + fullTime, + type Group, + groupEntries, + logLine, + mergeEntries, + orderGroups, + parseBoots, + parseJournal, + plural, + PRIORITIES, + shortTime, + shortUnit, + sourceArgs, +} from "../lib.ts"; +import { runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +/** Entries read per journal, newest first: enough for a boot's problems without stalling. */ +const LIMIT = 3000; +/** Rows shown at most; repeated messages are already folded into one. */ +const MAX_ROWS = 400; + +interface Run { + out: string; + err: string; + ok: boolean; +} + +class NoJournal extends Error {} + +/** journalctl with its stderr, which carries the "you are not seeing all messages" hint. */ +async function journalctl(args: string[], signal?: AbortSignal): Promise { + try { + const { code, stdout, stderr } = await new Deno.Command("journalctl", { + args: ["--no-pager", ...args], + stdin: "null", + stdout: "piped", + stderr: "piped", + signal, + }).output(); + const dec = new TextDecoder(); + return { out: dec.decode(stdout), err: dec.decode(stderr), ok: code === 0 }; + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new NoJournal("journalctl not found"); + throw err; + } +} + +interface Load { + entries: Entry[]; + /** Only the user's own logs could be read. */ + partial: boolean; +} + +/** System (when readable) and user journals of one boot, within a priority range. */ +async function load(boot: string, priority: string, signal?: AbortSignal): Promise { + const args = ["-b", boot, "-p", priority, "-o", "json", "-n", String(LIMIT), `--output-fields=${FIELDS.join(",")}`]; + const [system, user] = await Promise.all([ + journalctl(args, signal), + journalctl(["--user", ...args], signal).catch((err) => { + if (err instanceof NoJournal) throw err; + return { out: "", err: "", ok: false }; + }), + ]); + const partial = /not seeing messages from other users and the system|insufficient permissions/i.test(system.err); + if (!system.ok && !user.ok && !system.out && /no such boot|specifies boot id|not found/i.test(system.err)) { + return { entries: [], partial }; + } + return { entries: mergeEntries(parseJournal(system.out), parseJournal(user.out)), partial }; +} + +// --- Views --------------------------------------------------------------------- + +function priorityTag(p: number): { text: string; color: Color } { + return { + text: PRIORITIES[p] ?? `Priority ${p}`, + color: p <= 2 ? "red" : p === 3 ? "red" : p === 4 ? "orange" : "gray", + }; +} + +function unitLogs(e: Entry, boot: string, prefs: Prefs) { + const src = sourceArgs(e); + const name = e.transport === "kernel" ? "Kernel" : e.unit ?? e.identifier ?? e.comm ?? "Logs"; + return detail({ + title: `${name} — last 200 lines`, + async markdown({ signal }) { + const run = await journalctl([...src, "-b", boot, "-n", "200", "-o", "short-iso"], signal); + const text = run.out.trim() || run.err.trim() || "No lines."; + return codeBlock(text); + }, + metadata: [ + meta.text("Source", name, { icon: Icons.server }), + meta.text("Boot", boot === "0" ? "Current" : boot === "-1" ? "Previous" : boot), + meta.text("Command", `journalctl ${shellLine([...src, "-b", boot, "-n", "200"])}`, { icon: Icons.terminal }), + ], + actions: [ + follow(e, prefs), + copy(`journalctl ${shellLine([...src, "-b", boot])}`, "Copy journalctl Command", { section: "Copy" }), + ], + }); +} + +function follow(e: Entry, prefs: Prefs): Action { + const argv = ["journalctl", "-f", "-n", "50", ...sourceArgs(e)]; + return action("Follow in Terminal", () => { + const term = runInTerminal(argv, prefs.terminal || undefined); + return close(`Following in ${term}`); + }, { icon: Icons.terminal, shortcut: "t" }); +} + +function groupItem(g: Group, boot: string, prefs: Prefs): Item { + const e = g.latest; + const tag = priorityTag(g.priority); + const name = shortUnit(g.source); + const metadata: Metadata[] = [ + meta.tags("Priority", [tag]), + meta.text("Unit", e.unit ?? "–", { icon: Icons.server }), + ...(e.identifier && e.identifier !== shortUnit(e.unit ?? "") ? [meta.text("Identifier", e.identifier)] : []), + ...(e.userUnit ? [meta.text("Journal", "Your user session", { icon: Icons.person })] : []), + meta.separator(), + meta.text("Time", fullTime(e.time), { icon: Icons.clock }), + ...(g.count > 1 + ? [ + meta.text("Occurrences", `${g.count}×`, { icon: Icons.repeat }), + meta.text("First", fullTime(g.first)), + ] + : []), + ...(g.pids.length ? [meta.text(g.pids.length > 1 ? "PIDs" : "PID", g.pids.slice(0, 6).join(", "))] : []), + ...(e.exe ? [meta.text("Executable", e.exe, { icon: Icons.terminal })] : []), + ...(e.cmdline && e.cmdline !== e.exe ? [meta.text("Command line", e.cmdline)] : []), + ...(e.uid !== undefined ? [meta.text("User ID", e.uid)] : []), + ...(e.hostname ? [meta.text("Host", e.hostname)] : []), + ]; + return { + id: g.key, + title: firstLine(e.message) || "(empty message)", + icon: { source: g.priority <= 2 ? Icons.urgent : Icons.alert, tint: tag.color }, + section: name, + keywords: [g.source, e.identifier ?? "", e.comm ?? "", tag.text, String(e.pid ?? "")], + accessories: [ + ...(g.count > 1 ? [{ tag: `×${g.count}`, color: tag.color, tooltip: `${g.count} times this boot` }] : []), + { text: shortTime(e.time), tooltip: fullTime(e.time) }, + ], + detail: { markdown: codeBlock(e.message), metadata }, + actions: [ + action(`Show ${name} Logs`, () => unitLogs(e, boot, prefs), { icon: Icons.text }), + follow(e, prefs), + copy(e.message, "Copy Message", { shortcut: "c", section: "Copy" }), + copy(logLine(e), "Copy Log Line", { section: "Copy" }), + copy(`journalctl ${shellLine([...sourceArgs(e), "-b", boot])}`, "Copy journalctl Command", { + icon: Icons.terminal, + section: "Copy", + }), + ], + }; +} + +const permissionItem: Item = { + id: "permission", + title: "Only your own logs are shown", + subtitle: "Join the systemd-journal group to see the system's", + icon: { source: Icons.lock, tint: "yellow" }, + section: "Note", + detail: { + markdown: [ + "Your account can't read the system journal, so services, the kernel and other users are missing.", + "", + "Add yourself to the `systemd-journal` group (or `adm` on Debian and Ubuntu), then log out and back in:", + "", + codeBlock("sudo usermod -aG systemd-journal $USER"), + ].join("\n"), + }, + actions: [copy("sudo usermod -aG systemd-journal $USER", "Copy Command")], +}; + +const noJournalItem: Item = { + id: "no-journal", + title: "journalctl isn't available", + subtitle: "This system doesn't log with systemd's journal", + icon: { source: Icons.info, tint: "gray" }, + detail: { + markdown: [ + "System Logs reads systemd's journal with `journalctl`.", + "", + "Distributions without systemd (Alpine, Void, Gentoo with OpenRC, Devuan…) log to text files in `/var/log` instead.", + ].join("\n"), + }, +}; + +interface Counts { + errors: number; + warnings: number; +} +let counts: Counts = { errors: 0, warnings: 0 }; + +async function problems( + boot: string, + priority: string, + prefs: Prefs, + signal: AbortSignal, + subtitle: boolean, +): Promise { + let loaded: Load; + try { + loaded = await load(boot, priority, signal); + } catch (err) { + if (err instanceof NoJournal) return [noJournalItem]; + throw err; + } + const { entries, partial } = loaded; + counts = { + errors: entries.filter((e) => e.priority <= 3).length, + warnings: entries.filter((e) => e.priority === 4).length, + }; + if (subtitle) { + updateCommandMetadata({ + subtitle: counts.errors ? `${plural(counts.errors, "error")} this boot` : "No errors this boot", + }).catch(() => {}); + } + const groups = orderGroups(groupEntries(entries)).slice(0, MAX_ROWS); + return [...(partial ? [permissionItem] : []), ...groups.map((g) => groupItem(g, boot, prefs))]; +} + +function bootList(b: Boot, prefs: Prefs): ListSpec { + const boot = String(b.index); + return list({ + title: `Problems of ${bootName(b)}`, + placeholder: "Filter by message, unit, program…", + empty: { title: "No errors or warnings", icon: Icons.done }, + status: () => `${plural(counts.errors, "error")} · ${plural(counts.warnings, "warning")}`, + items: ({ signal }) => problems(b.id || boot, "0..4", prefs, signal, false), + }); +} + +function bootName(b: Boot): string { + if (b.index === 0) return "Current boot"; + if (b.index === -1) return "Previous boot"; + return `Boot of ${fullTime(b.first).slice(0, 16)}`; +} + +async function boots(prefs: Prefs, signal: AbortSignal): Promise { + let run: Run; + try { + run = await journalctl(["--list-boots", "-o", "json"], signal); + if (!run.ok || !run.out.trim().startsWith("[")) run = await journalctl(["--list-boots"], signal); + } catch (err) { + if (err instanceof NoJournal) return [noJournalItem]; + throw err; + } + return parseBoots(run.out).map((b) => ({ + id: `boot:${b.id}`, + title: bootName(b), + subtitle: `${fullTime(b.first).slice(0, 16)} → ${ + b.index === 0 + ? "now" + : fullTime(b.last).slice(0, 10) === fullTime(b.first).slice(0, 10) + ? fullTime(b.last).slice(11, 16) + : fullTime(b.last).slice(0, 16) + }`, + icon: { source: b.index === 0 ? Icons.power : Icons.history, tint: b.index === 0 ? "green" : "gray" }, + section: "Boots", + keywords: [b.id, String(b.index)], + accessories: [{ text: duration((b.index === 0 ? Date.now() : b.last) - b.first), tooltip: "Uptime" }, { + tag: String(b.index), + color: "gray", + }], + detail: { + metadata: [ + meta.text("Boot", bootName(b), { icon: Icons.power }), + meta.text("Started", fullTime(b.first), { icon: Icons.clock }), + meta.text(b.index === 0 ? "Last entry" : "Ended", fullTime(b.last)), + meta.text("Lasted", duration(b.last - b.first), { icon: Icons.timer }), + meta.separator(), + meta.text("Index", b.index), + meta.text("Boot ID", b.id), + ], + }, + actions: [ + action("Show Errors and Warnings", () => bootList(b, prefs), { icon: Icons.alert }), + action("Open Full Log in Terminal", () => { + const term = runInTerminal(["journalctl", "-b", b.id, "-e"], prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: Icons.terminal }), + copy(b.id, "Copy Boot ID", { section: "Copy" }), + copy(`journalctl -b ${b.id} -p 0..4`, "Copy journalctl Command", { section: "Copy" }), + ], + })); +} + +export default (ctx: Context, Prefs>) => + list({ + placeholder: "Filter by message, unit, program…", + filter: { + options: [ + { value: "errors", title: "Errors" }, + { value: "warnings", title: "Warnings" }, + { value: "all", title: "All" }, + { value: "previous", title: "Previous Boot" }, + { value: "boots", title: "Boots" }, + ], + default: "errors", + }, + empty: { title: "Nothing to worry about", description: "No problems logged in this boot", icon: Icons.done }, + status: (items) => { + if (items.some((i) => i.section === "Boots")) return plural(items.length, "boot"); + const parts = []; + if (counts.errors) parts.push(plural(counts.errors, "error")); + if (counts.warnings) parts.push(plural(counts.warnings, "warning")); + return parts.join(" · ") || "No problems"; + }, + items: ({ filter, signal }) => { + const prefs = ctx.preferences; + switch (filter) { + case "warnings": + return problems("0", "4..4", prefs, signal, false); + case "all": + return problems("0", "0..4", prefs, signal, true); + case "previous": + return problems("-1", "0..4", prefs, signal, false); + case "boots": + return boots(prefs, signal); + default: + return problems("0", "0..3", prefs, signal, true); + } + }, + }); diff --git a/extensions/system-logs/lib.ts b/extensions/system-logs/lib.ts new file mode 100644 index 0000000..6796322 --- /dev/null +++ b/extensions/system-logs/lib.ts @@ -0,0 +1,284 @@ +// Pure helpers for System Logs: journalctl JSON parsing, grouping and formatting. + +/** journald priorities (syslog levels). */ +export const PRIORITIES = ["Emergency", "Alert", "Critical", "Error", "Warning", "Notice", "Info", "Debug"]; + +/** The fields asked from journalctl (`--output-fields`); cursor and timestamps always come along. */ +export const FIELDS = [ + "MESSAGE", + "PRIORITY", + "_SYSTEMD_UNIT", + "_SYSTEMD_USER_UNIT", + "UNIT", + "USER_UNIT", + "SYSLOG_IDENTIFIER", + "_PID", + "_EXE", + "_COMM", + "_CMDLINE", + "_TRANSPORT", + "_HOSTNAME", + "_BOOT_ID", + "_UID", + "CODE_FILE", + "CODE_FUNC", +]; + +export interface Entry { + cursor: string; + /** Milliseconds since the epoch. */ + time: number; + priority: number; + message: string; + /** systemd unit, user unit, or the identifier for processes outside units. */ + unit?: string; + userUnit: boolean; + identifier?: string; + pid?: number; + exe?: string; + comm?: string; + cmdline?: string; + transport?: string; + hostname?: string; + bootId?: string; + uid?: number; +} + +type Field = string | number[] | null | undefined | (string | number[])[]; + +/** journald gives binary or non-UTF-8 values as byte arrays, and repeated fields as arrays. */ +export function fieldText(v: Field): string | undefined { + if (v === null || v === undefined) return undefined; + if (typeof v === "string") return v; + if (Array.isArray(v) && v.every((x) => typeof x === "number")) { + return new TextDecoder().decode(new Uint8Array(v as number[])); + } + if (Array.isArray(v)) return fieldText((v as (string | number[])[])[0]); + return String(v); +} + +/** One `journalctl -o json` line. */ +export function parseEntry(line: string): Entry | undefined { + let raw: Record; + try { + raw = JSON.parse(line); + } catch { + return undefined; + } + if (!raw || typeof raw !== "object") return undefined; + const text = (k: string) => fieldText(raw[k])?.trim() || undefined; + const num = (k: string) => { + const n = Number(text(k)); + return text(k) !== undefined && Number.isFinite(n) ? n : undefined; + }; + const userUnit = text("_SYSTEMD_USER_UNIT"); + const sysUnit = text("_SYSTEMD_UNIT"); + const transport = text("_TRANSPORT"); + // A user's units all run under user@UID.service: the user unit says more. + let unit = userUnit ?? (sysUnit && !/^user@\d+\.service$/.test(sysUnit) ? sysUnit : undefined); + // systemd's own messages about a unit ("Failed to start nginx.service") belong to that unit. + const about = sysUnit === "init.scope" ? text("UNIT") : userUnit === "init.scope" ? text("USER_UNIT") : undefined; + if (about) unit = about; + return { + cursor: text("__CURSOR") ?? `${text("__REALTIME_TIMESTAMP")}:${text("MESSAGE")}`, + time: Math.floor(Number(text("__REALTIME_TIMESTAMP") ?? 0) / 1000), + priority: num("PRIORITY") ?? 6, + message: fieldText(raw.MESSAGE)?.replace(/\s+$/, "") ?? "", + unit, + userUnit: about ? userUnit === "init.scope" : !!userUnit, + identifier: text("SYSLOG_IDENTIFIER") ?? (transport === "kernel" ? "kernel" : undefined), + pid: num("_PID"), + exe: text("_EXE"), + comm: text("_COMM"), + cmdline: text("_CMDLINE"), + transport, + hostname: text("_HOSTNAME"), + bootId: text("_BOOT_ID"), + uid: num("_UID"), + }; +} + +export function parseJournal(out: string): Entry[] { + return out.split("\n").filter((l) => l.startsWith("{")).map(parseEntry).filter((e): e is Entry => !!e); +} + +/** Entries of several journalctl runs (system and --user), each once, newest first. */ +export function mergeEntries(...runs: Entry[][]): Entry[] { + const seen = new Map(); + for (const run of runs) for (const e of run) if (!seen.has(e.cursor)) seen.set(e.cursor, e); + return [...seen.values()].sort((a, b) => b.time - a.time); +} + +/** Where an entry comes from, as a short name: the unit, else the program. */ +export function source(e: Entry): string { + if (e.transport === "kernel") return "Kernel"; + return e.unit ?? e.identifier ?? e.comm ?? "Unknown"; +} + +/** A unit name without its `.service` suffix. */ +export function shortUnit(name: string): string { + return name.replace(/\.service$/, ""); +} + +/** Repeated messages of a source, shown once with a count. */ +export interface Group { + key: string; + source: string; + /** The newest entry. */ + latest: Entry; + count: number; + first: number; + pids: number[]; + /** Highest severity among them (lowest number). */ + priority: number; +} + +/** Numbers that change from one repetition to the next (pids, addresses, counters) don't split a group. */ +export function messageKey(message: string): string { + return message.replace(/0x[0-9a-f]+/gi, "#").replace(/\d+/g, "#").slice(0, 300); +} + +export function groupEntries(entries: Entry[]): Group[] { + const groups = new Map(); + for (const e of entries) { + const src = source(e); + const key = `${src}\u0001${messageKey(e.message)}`; + const g = groups.get(key); + if (!g) { + groups.set(key, { + key, + source: src, + latest: e, + count: 1, + first: e.time, + pids: e.pid ? [e.pid] : [], + priority: e.priority, + }); + continue; + } + g.count++; + if (e.time > g.latest.time) g.latest = e; + g.first = Math.min(g.first, e.time); + g.priority = Math.min(g.priority, e.priority); + if (e.pid && !g.pids.includes(e.pid)) g.pids.push(e.pid); + } + return [...groups.values()]; +} + +/** Groups ordered by source, the sources with the most errors first, then newest first inside each. */ +export function orderGroups(groups: Group[]): Group[] { + const score = new Map(); + for (const g of groups) { + const s = score.get(g.source) ?? { errors: 0, total: 0, newest: 0 }; + if (g.priority <= 3) s.errors += g.count; + s.total += g.count; + s.newest = Math.max(s.newest, g.latest.time); + score.set(g.source, s); + } + return [...groups].sort((a, b) => { + const sa = score.get(a.source)!; + const sb = score.get(b.source)!; + return sb.errors - sa.errors || sb.total - sa.total || sb.newest - sa.newest || + a.source.localeCompare(b.source) || b.latest.time - a.latest.time; + }); +} + +export interface Boot { + index: number; + id: string; + /** Milliseconds since the epoch. */ + first: number; + last: number; +} + +/** `journalctl --list-boots -o json` (systemd 251+), newest first. */ +export function parseBoots(out: string): Boot[] { + let raw: unknown; + try { + raw = JSON.parse(out); + } catch { + return parseBootsText(out); + } + if (!Array.isArray(raw)) return []; + return raw.map((b) => ({ + index: Number(b.index), + id: String(b.boot_id ?? ""), + first: Math.floor(Number(b.first_entry) / 1000), + last: Math.floor(Number(b.last_entry) / 1000), + })).filter((b) => b.id && Number.isFinite(b.index)).sort((a, b) => b.index - a.index); +} + +/** The plain `--list-boots` table of older systemd: `-1 3a5f… Tue 2026-10-27 08:01:12 CET—Tue 2026-10-27 23:40:02 CET`. */ +function parseBootsText(out: string): Boot[] { + const boots: Boot[] = []; + for (const line of out.split("\n")) { + const m = line.trim().match(/^(-?\d+)\s+([0-9a-f]{32})\s/); + const times = line.match(/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/g); + if (!m || !times || times.length < 2) continue; + const at = (t: string) => Date.parse(t.replace(" ", "T")); + boots.push({ index: Number(m[1]), id: m[2], first: at(times[0]), last: at(times[1]) }); + } + return boots.sort((a, b) => b.index - a.index); +} + +/** journalctl's arguments to show the logs of an entry's source. */ +export function sourceArgs(e: Entry): string[] { + if (e.transport === "kernel") return ["-k"]; + if (e.unit) return e.userUnit ? ["--user", "-u", e.unit] : ["-u", e.unit]; + if (e.identifier) return ["-t", e.identifier]; + if (e.comm) return [`_COMM=${e.comm}`]; + return []; +} + +// --------------------------------------------------------------------------- +// Formatting + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** "14:03" today, "Mon 14:03" this week, "12 Oct 14:03" before. */ +export function shortTime(ms: number, now = Date.now()): string { + const d = new Date(ms); + const n = new Date(now); + const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (d.toDateString() === n.toDateString()) return hm; + if (now - ms < 6 * 86400_000) return `${d.toLocaleDateString("en-GB", { weekday: "short" })} ${hm}`; + return `${d.getDate()} ${d.toLocaleDateString("en-GB", { month: "short" })} ${hm}`; +} + +export function fullTime(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${ + pad(d.getSeconds()) + }`; +} + +/** "3 h 12 min", "45 s". */ +export function duration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} h${m % 60 ? ` ${m % 60} min` : ""}`; + return `${Math.floor(h / 24)} days`; +} + +/** The first line, shortened for a list row. */ +export function firstLine(message: string, max = 140): string { + const line = message.split("\n").find((l) => l.trim()) ?? ""; + return line.length > max ? line.slice(0, max - 1) + "…" : line; +} + +/** "Oct 28 14:03:12 nginx.service[812]: message", like journalctl's short output. */ +export function logLine(e: Entry): string { + return `${fullTime(e.time)} ${source(e)}${e.pid ? `[${e.pid}]` : ""}: ${e.message}`; +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** Text safe inside a markdown code block. */ +export function codeBlock(text: string): string { + return "```\n" + text.replace(/```/g, "ˋˋˋ") + "\n```"; +} diff --git a/extensions/system-logs/lib_test.ts b/extensions/system-logs/lib_test.ts new file mode 100644 index 0000000..90e3734 --- /dev/null +++ b/extensions/system-logs/lib_test.ts @@ -0,0 +1,157 @@ +import { assert, assertEquals } from "@std/assert"; +import { + codeBlock, + duration, + fieldText, + firstLine, + groupEntries, + logLine, + mergeEntries, + messageKey, + orderGroups, + parseBoots, + parseEntry, + parseJournal, + source, + sourceArgs, +} from "./lib.ts"; + +const T = 1761645600000000; // µs +const line = (o: Record) => JSON.stringify(o); + +// journalctl -o json from systemd 255, trimmed to the asked fields. +const JOURNAL = [ + line({ + __CURSOR: "s=1;i=10", + __REALTIME_TIMESTAMP: String(T), + PRIORITY: "3", + MESSAGE: "nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)", + _SYSTEMD_UNIT: "nginx.service", + SYSLOG_IDENTIFIER: "nginx", + _PID: "2213", + _EXE: "/usr/sbin/nginx", + _COMM: "nginx", + }), + line({ + __CURSOR: "s=1;i=11", + __REALTIME_TIMESTAMP: String(T - 60_000_000), + PRIORITY: "3", + MESSAGE: "nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)", + _SYSTEMD_UNIT: "nginx.service", + SYSLOG_IDENTIFIER: "nginx", + _PID: "2201", + }), + line({ + __CURSOR: "s=1;i=12", + __REALTIME_TIMESTAMP: String(T - 30_000_000), + PRIORITY: "3", + MESSAGE: "Failed to start nginx.service - A high performance web server.", + _SYSTEMD_UNIT: "init.scope", + UNIT: "nginx.service", + SYSLOG_IDENTIFIER: "systemd", + _PID: "1", + }), + line({ + __CURSOR: "s=1;i=13", + __REALTIME_TIMESTAMP: String(T - 120_000_000), + PRIORITY: "4", + MESSAGE: [87, 105, 70, 105, 32, 255], + _TRANSPORT: "kernel", + }), + line({ + __CURSOR: "s=1;i=14", + __REALTIME_TIMESTAMP: String(T - 10_000_000), + PRIORITY: "3", + MESSAGE: + "JS ERROR: TypeError: this._notification is null\n_updateState@resource:///org/gnome/shell/ui/messageTray.js", + _SYSTEMD_UNIT: "user@1000.service", + _SYSTEMD_USER_UNIT: "org.gnome.Shell@wayland.service", + SYSLOG_IDENTIFIER: "gnome-shell", + _PID: "1650", + }), + "Hint: not json", + "{broken", +].join("\n"); + +Deno.test("parseEntry reads fields, timestamps and byte-array messages", () => { + const entries = parseJournal(JOURNAL); + assertEquals(entries.length, 5); + const [nginx, , systemd, kernel, shell] = entries; + assertEquals(nginx.time, T / 1000); + assertEquals([nginx.priority, nginx.unit, nginx.pid, nginx.exe], [3, "nginx.service", 2213, "/usr/sbin/nginx"]); + assertEquals(systemd.unit, "nginx.service"); + assertEquals(kernel.message, "WiFi �"); + assertEquals(source(kernel), "Kernel"); + assertEquals([shell.unit, shell.userUnit], ["org.gnome.Shell@wayland.service", true]); + assertEquals(parseEntry("[]")?.message, ""); +}); + +Deno.test("fieldText handles strings, bytes, repeated fields and nulls", () => { + assertEquals(fieldText("x"), "x"); + assertEquals(fieldText([104, 105]), "hi"); + assertEquals(fieldText(["a", "b"]), "a"); + assertEquals(fieldText(null), undefined); +}); + +Deno.test("mergeEntries drops duplicates seen by both journals and sorts newest first", () => { + const sys = parseJournal(JOURNAL); + const merged = mergeEntries(sys, sys.slice(0, 2)); + assertEquals(merged.length, 5); + assertEquals(merged.map((e) => e.cursor), ["s=1;i=10", "s=1;i=14", "s=1;i=12", "s=1;i=11", "s=1;i=13"]); +}); + +Deno.test("groupEntries folds repeats; orderGroups puts noisy units first", () => { + const groups = orderGroups(groupEntries(mergeEntries(parseJournal(JOURNAL)))); + assertEquals(groups.map((g) => [g.source, g.count]), [ + ["nginx.service", 2], + ["nginx.service", 1], + ["org.gnome.Shell@wayland.service", 1], + ["Kernel", 1], + ]); + assertEquals(groups[0].pids, [2213, 2201]); + assertEquals(groups[0].latest.pid, 2213); + assertEquals(groups[0].first, T / 1000 - 60_000); + assertEquals(messageKey("Killed process 4242 at 0xffff12"), "Killed process # at #"); +}); + +Deno.test("sourceArgs picks the right journalctl filter", () => { + const [nginx, , , kernel, shell] = parseJournal(JOURNAL); + assertEquals(sourceArgs(nginx), ["-u", "nginx.service"]); + assertEquals(sourceArgs(kernel), ["-k"]); + assertEquals(sourceArgs(shell), ["--user", "-u", "org.gnome.Shell@wayland.service"]); + assertEquals(sourceArgs(parseEntry(line({ __CURSOR: "c", SYSLOG_IDENTIFIER: "cron" }))!), ["-t", "cron"]); +}); + +Deno.test("parseBoots reads JSON and the older text table", () => { + const json = parseBoots(JSON.stringify([ + { index: -1, boot_id: "aaaabbbbccccddddeeeeffff00001111", first_entry: T - 86400e6, last_entry: T - 50000e6 }, + { index: 0, boot_id: "3a5f0c2d9e8b4f7a8c1d2e3f4a5b6c7d", first_entry: T - 7200e6, last_entry: T }, + ])); + assertEquals(json.map((b) => b.index), [0, -1]); + assertEquals(json[0].first, T / 1000 - 7200e3); + const text = parseBoots( + "IDX BOOT ID FIRST ENTRY LAST ENTRY\n" + + " -1 aaaabbbbccccddddeeeeffff00001111 Mon 2026-10-27 08:01:12 CET Mon 2026-10-27 23:40:02 CET\n" + + " 0 3a5f0c2d9e8b4f7a8c1d2e3f4a5b6c7d Tue 2026-10-28 08:00:59 CET Tue 2026-10-28 10:20:00 CET\n", + ); + assertEquals(text.map((b) => [b.index, b.id]), [[0, "3a5f0c2d9e8b4f7a8c1d2e3f4a5b6c7d"], [ + -1, + "aaaabbbbccccddddeeeeffff00001111", + ]]); + assert(Number.isFinite(text[0].first)); + assertEquals(parseBoots("No journal files were found."), []); +}); + +Deno.test("formatting helpers", () => { + assertEquals(duration(45_000), "45 s"); + assertEquals(duration(3 * 3600_000 + 12 * 60_000), "3 h 12 min"); + assertEquals(duration(5 * 86400_000), "5 days"); + assertEquals(firstLine("\n \nfirst\nsecond"), "first"); + assertEquals(firstLine("x".repeat(200), 10), "xxxxxxxxx…"); + assert( + logLine(parseJournal(JOURNAL)[0]).endsWith( + "nginx.service[2213]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)", + ), + ); + assertEquals(codeBlock("a ``` b"), "```\na ˋˋˋ b\n```"); +}); diff --git a/extensions/system-logs/terminal.ts b/extensions/system-logs/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/system-logs/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; + } +} From 1e58bdcc3386101c5d36218141914f77dcbd604e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:35:59 +0000 Subject: [PATCH 02/10] Window Manager, Keyboard Layouts, Flatpak Apps, Disk Usage, Trash and Downloads Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/disk-usage/backslash.toml | 87 +++ extensions/disk-usage/commands/disk-usage.ts | 605 ++++++++++++++++ extensions/disk-usage/lib.ts | 241 +++++++ extensions/disk-usage/lib_test.ts | 121 ++++ extensions/disk-usage/system.ts | 151 ++++ extensions/disk-usage/terminal.ts | 77 ++ extensions/downloads/backslash.toml | 37 + extensions/downloads/commands/downloads.ts | 398 +++++++++++ extensions/downloads/lib.ts | 235 ++++++ extensions/downloads/lib_test.ts | 99 +++ extensions/flatpak/backslash.toml | 45 ++ extensions/flatpak/commands/flatpak.ts | 340 +++++++++ extensions/flatpak/lib.ts | 239 +++++++ extensions/flatpak/lib_test.ts | 133 ++++ extensions/keyboard-layouts/backslash.toml | 61 ++ .../keyboard-layouts/commands/layouts.ts | 156 ++++ .../keyboard-layouts/commands/next-layout.ts | 15 + extensions/keyboard-layouts/lib.ts | 382 ++++++++++ extensions/keyboard-layouts/lib_test.ts | 198 ++++++ extensions/keyboard-layouts/system.ts | 261 +++++++ extensions/trash/backslash.toml | 38 + extensions/trash/commands/trash.ts | 295 ++++++++ extensions/trash/lib.ts | 132 ++++ extensions/trash/lib_test.ts | 67 ++ extensions/window-manager/backslash.toml | 183 +++++ .../window-manager/commands/bottom-half.ts | 4 + .../window-manager/commands/bottom-left.ts | 4 + .../window-manager/commands/bottom-right.ts | 4 + extensions/window-manager/commands/center.ts | 4 + .../window-manager/commands/close-window.ts | 4 + .../window-manager/commands/fullscreen.ts | 4 + .../window-manager/commands/left-half.ts | 4 + .../window-manager/commands/maximize.ts | 4 + .../window-manager/commands/minimize.ts | 4 + .../commands/move-to-workspace.ts | 4 + .../window-manager/commands/right-half.ts | 4 + .../commands/switch-workspace.ts | 4 + .../commands/toggle-floating.ts | 4 + .../window-manager/commands/top-half.ts | 4 + .../window-manager/commands/top-left.ts | 4 + .../window-manager/commands/top-right.ts | 4 + extensions/window-manager/commands/windows.ts | 249 +++++++ extensions/window-manager/lib.ts | 672 ++++++++++++++++++ extensions/window-manager/lib_test.ts | 243 +++++++ extensions/window-manager/perform.ts | 45 ++ extensions/window-manager/system.ts | 357 ++++++++++ 46 files changed, 6226 insertions(+) create mode 100644 extensions/disk-usage/backslash.toml create mode 100644 extensions/disk-usage/commands/disk-usage.ts create mode 100644 extensions/disk-usage/lib.ts create mode 100644 extensions/disk-usage/lib_test.ts create mode 100644 extensions/disk-usage/system.ts create mode 100644 extensions/disk-usage/terminal.ts create mode 100644 extensions/downloads/backslash.toml create mode 100644 extensions/downloads/commands/downloads.ts create mode 100644 extensions/downloads/lib.ts create mode 100644 extensions/downloads/lib_test.ts create mode 100644 extensions/flatpak/backslash.toml create mode 100644 extensions/flatpak/commands/flatpak.ts create mode 100644 extensions/flatpak/lib.ts create mode 100644 extensions/flatpak/lib_test.ts create mode 100644 extensions/keyboard-layouts/backslash.toml create mode 100644 extensions/keyboard-layouts/commands/layouts.ts create mode 100644 extensions/keyboard-layouts/commands/next-layout.ts create mode 100644 extensions/keyboard-layouts/lib.ts create mode 100644 extensions/keyboard-layouts/lib_test.ts create mode 100644 extensions/keyboard-layouts/system.ts create mode 100644 extensions/trash/backslash.toml create mode 100644 extensions/trash/commands/trash.ts create mode 100644 extensions/trash/lib.ts create mode 100644 extensions/trash/lib_test.ts create mode 100644 extensions/window-manager/backslash.toml create mode 100644 extensions/window-manager/commands/bottom-half.ts create mode 100644 extensions/window-manager/commands/bottom-left.ts create mode 100644 extensions/window-manager/commands/bottom-right.ts create mode 100644 extensions/window-manager/commands/center.ts create mode 100644 extensions/window-manager/commands/close-window.ts create mode 100644 extensions/window-manager/commands/fullscreen.ts create mode 100644 extensions/window-manager/commands/left-half.ts create mode 100644 extensions/window-manager/commands/maximize.ts create mode 100644 extensions/window-manager/commands/minimize.ts create mode 100644 extensions/window-manager/commands/move-to-workspace.ts create mode 100644 extensions/window-manager/commands/right-half.ts create mode 100644 extensions/window-manager/commands/switch-workspace.ts create mode 100644 extensions/window-manager/commands/toggle-floating.ts create mode 100644 extensions/window-manager/commands/top-half.ts create mode 100644 extensions/window-manager/commands/top-left.ts create mode 100644 extensions/window-manager/commands/top-right.ts create mode 100644 extensions/window-manager/commands/windows.ts create mode 100644 extensions/window-manager/lib.ts create mode 100644 extensions/window-manager/lib_test.ts create mode 100644 extensions/window-manager/perform.ts create mode 100644 extensions/window-manager/system.ts diff --git a/extensions/disk-usage/backslash.toml b/extensions/disk-usage/backslash.toml new file mode 100644 index 0000000..8426d8c --- /dev/null +++ b/extensions/disk-usage/backslash.toml @@ -0,0 +1,87 @@ +[extension] +id = "disk-usage" +title = "Disk Usage" +description = "Find what fills your disk: biggest folders and files, caches, trash, logs and containers, with cleanups" +version = "1.0.0" +author = "Backslash" +icon = "bs-drive" +license = "MIT" + +# du, find and df measure (the files are never opened); journalctl, docker and +# podman report their own usage; gio empties the trash and moves files to it; +# flatpak is only probed; gdbus shows a file in the file manager. Cleanups that +# need sudo (journal, package cache) or ask before deleting (docker prune, +# flatpak --unused) run in a terminal, the ones listed last. +[permissions] +run = [ + "du", + "find", + "df", + "journalctl", + "docker", + "podman", + "gio", + "flatpak", + "gdbus", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] +# The home folder is read only to tell files from folders while browsing by size +# (Deno.stat), and ~/.cache to list the cache folders to empty. +read = ["~", "/etc/os-release"] +# Emptying a cache folder deletes what is inside it. +write = ["~/.cache"] +env = ["HOME"] + +[[commands]] +name = "disk-usage" +title = "Disk Usage" +description = "Biggest folders and files in your home, caches, trash, journal and containers" +icon = "bs-drive" +keywords = [ + "disk", + "disque", + "space", + "espace", + "storage", + "stockage", + "du", + "ncdu", + "baobab", + "free space", + "espace libre", + "clean", + "nettoyer", + "cache", + "big files", + "gros fichiers", + "taille", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For cleanups that ask for your password. Leave empty to use the first one installed (kitty, alacritty, GNOME Console, Konsole…)" + +[[preferences]] +name = "timeout" +title = "Scan time limit (seconds)" +description = "The scan stops after this and shows what it found" +type = "number" +default = 60 + +[[preferences]] +name = "minFileMb" +title = "Big files from (MB)" +type = "number" +default = 100 diff --git a/extensions/disk-usage/commands/disk-usage.ts b/extensions/disk-usage/commands/disk-usage.ts new file mode 100644 index 0000000..c77b81a --- /dev/null +++ b/extensions/disk-usage/commands/disk-usage.ts @@ -0,0 +1,605 @@ +import { + type Action, + action, + type Context, + copy, + exec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + storage, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + ago, + bar, + basename, + type BigFile, + biggest, + cacheOwner, + childrenOf, + type ContainerUsage, + type Disk, + type Entry, + formatBytes, + packageCache, + parseDuLine, + rootSize, + safeCache, + shareColor, + tilde, + topFolders, +} from "../lib.ts"; +import { + bigFiles, + containers, + disk, + du, + emptyFolder, + family, + HOME, + installed, + inTerminal, + journalSize, + showInFolder, + sizeOf, + stream as streamLines, + trash, +} from "../system.ts"; + +interface Prefs { + terminal?: string; + timeout?: number; + minFileMb?: number; +} + +interface Extras { + trash?: number; + journal?: number; + docker?: ContainerUsage[]; + podman?: ContainerUsage[]; + pkg?: { name: string; path: string; clean: string[]; size?: number }; + flatpak?: number; +} + +interface Saved { + at: number; + entries: [string, number][]; + files: [string, number, number][]; +} + +const CACHE = `${HOME}/.cache`; +const t = (p: string) => tilde(p, HOME); +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`; + +function shareMeta(size: number, d?: Disk): Metadata[] { + return d + ? [meta.progress("Of the disk", size / d.size, `${((size / d.size) * 100).toFixed(1)} % of ${formatBytes(d.size)}`)] + : []; +} + +function terminalAction(title: string, argv: string[], prefs: Prefs, o: { icon?: string; confirm?: string } = {}) { + return action(title, async () => { + const term = inTerminal(argv, prefs.terminal); + await toast(`Opened in ${term}`, { message: argv.join(" ") }); + }, { icon: o.icon ?? Icons.terminal, confirm: o.confirm, section: "Clean Up" }); +} + +function fileActions(path: string, isDir: boolean, prefs: Prefs): Action[] { + return [ + open(path, isDir ? "Open Folder" : "Open", { icon: isDir ? Icons.folder : Icons.external }), + ...(isDir + ? [ + action("Show Contents by Size", () => folderView(path, prefs), { + icon: Icons.chartDown, + shortcut: "Return", + }), + ] + : []), + action("Show in Folder", async () => { + await showInFolder(path); + }, { icon: Icons.folder, shortcut: "f" }), + copy(path, "Copy Path", { section: "Copy", shortcut: "c" }), + action("Move to Trash", async () => { + await trash(path); + await toast(`${basename(path)} moved to the trash`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Move ${t(path)} to the trash?`, + }), + ]; +} + +// --------------------------------------------------------------------------- +// Drilling into a folder + +function folderView(root: string, prefs: Prefs): ListSpec { + let status = "Scanning…"; + return list({ + title: t(root), + placeholder: `Filter ${t(root)}…`, + status: () => status, + empty: { title: "Empty folder", icon: Icons.folder }, + async *items({ signal }) { + const d = await disk(root); + const found: Entry[] = []; + const stop = AbortSignal.any([signal, AbortSignal.timeout((prefs.timeout || 60) * 1000)]); + let finished = false; + const scan = stream(root, stop, (e) => found.push(e)).then(() => (finished = true)); + const render = async () => { + const total = rootSize(found, root); + const kids = found.filter((e) => e.path !== root).sort((a, b) => b.size - a.size).slice(0, 80); + status = `${finished ? "" : "Scanning… "}${formatBytes(total)} in ${plural(kids.length, "item")}${ + finished && stop.aborted && !signal.aborted ? " · stopped at the time limit" : "" + }`; + return await Promise.all(kids.map(async (e) => { + const isDir = (await Deno.stat(e.path).catch(() => undefined))?.isDirectory ?? false; + const ratio = total ? e.size / total : 0; + return { + id: e.path, + title: basename(e.path), + icon: { source: isDir ? Icons.folder : Icons.file, tint: shareColor(ratio) }, + keywords: [e.path], + accessories: [{ text: `${Math.round(ratio * 100)} %`, color: "gray" as const }, { + text: formatBytes(e.size), + }], + detail: { + stats: [{ label: "Size", value: formatBytes(e.size), color: shareColor(ratio) }, { + label: `Of ${basename(root)}`, + value: `${Math.round(ratio * 100)} %`, + }], + metadata: [ + meta.progress(`Of ${basename(root)}`, ratio), + ...shareMeta(e.size, d), + meta.text("Path", t(e.path), { icon: isDir ? Icons.folder : Icons.file }), + ], + }, + actions: fileActions(e.path, isDir, prefs), + } satisfies Item; + })); + }; + while (!finished) { + await Promise.race([scan, new Promise((r) => setTimeout(r, 700))]); + yield await render(); + } + }, + }); +} + +/** `du -a -d 1`: the files and folders right inside. */ +function stream(root: string, signal: AbortSignal, onEntry: (e: Entry) => void) { + return streamLines("du", ["-x", "-a", "-d", "1", "-b", root], signal, (l) => { + const e = parseDuLine(l); + if (e) onEntry(e); + }); +} + +// --------------------------------------------------------------------------- +// Overview + +function diskItem(d: Disk | undefined, home: number, scanning: boolean, savedAt?: number): Item { + const ratio = d ? d.used / d.size : 0; + return { + id: "disk", + title: d ? `${formatBytes(d.available)} free` : "Home folder", + subtitle: d ? `of ${formatBytes(d.size)} on ${d.mount}` : undefined, + icon: { source: Icons.drive, tint: ratio > 0.9 ? "red" : ratio > 0.75 ? "orange" : "green" }, + section: "Disk", + keywords: ["disk", "disque", "free", "libre", "space", "espace"], + accessories: [{ text: `${Math.round(ratio * 100)} % used`, color: ratio > 0.9 ? "red" : undefined }], + detail: { + stats: d + ? [ + { + label: "Used", + value: formatBytes(d.used), + color: ratio > 0.9 ? "red" : ratio > 0.75 ? "orange" : undefined, + }, + { label: "Free", value: formatBytes(d.available), color: "green" }, + { label: "Total", value: formatBytes(d.size) }, + ] + : [], + metadata: [ + ...(d ? [meta.progress("Disk used", ratio, `${formatBytes(d.used)} · ${Math.round(ratio * 100)} %`)] : []), + ...(home ? [meta.progress("Home folder", d ? home / d.size : 0, formatBytes(home))] : []), + meta.separator(), + meta.text("Mount point", d?.mount ?? "?", { icon: Icons.drive }), + meta.text("Home", t(HOME) || HOME, { icon: Icons.home }), + meta.text("Scan", scanning ? "In progress…" : savedAt ? `Saved ${ago(savedAt)}` : "Done", { + icon: scanning ? Icons.hourglass : Icons.check, + }), + ], + }, + actions: [ + action("Rescan", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + open(HOME, "Open Home Folder", { icon: Icons.home }), + ], + }; +} + +function folderItem(e: Entry, entries: Entry[], d: Disk | undefined, home: number, prefs: Prefs): Item { + const ratio = d ? e.size / d.size : 0; + const kids = childrenOf(entries, e.path); + const md = kids.length + ? `**Biggest inside**\n\n${ + kids.map((k) => `\`${bar(e.size ? k.size / e.size : 0, 12)}\` ${formatBytes(k.size)} · ${basename(k.path)}`).join( + "\n\n", + ) + }` + : undefined; + return { + id: `folder:${e.path}`, + title: basename(e.path), + subtitle: t(e.path), + icon: { source: Icons.folder, tint: shareColor(ratio) }, + section: "Biggest Folders", + keywords: [e.path, "folder", "dossier"], + accessories: [ + { text: `${home ? Math.round((e.size / home) * 100) : 0} %`, color: "gray", tooltip: "Of your home folder" }, + { text: formatBytes(e.size) }, + ], + detail: { + stats: [ + { label: "Size", value: formatBytes(e.size), color: shareColor(ratio) }, + { label: "Of home", value: `${home ? Math.round((e.size / home) * 100) : 0} %` }, + ], + markdown: md, + metadata: [ + ...shareMeta(e.size, d), + ...(home ? [meta.progress("Of home", e.size / home)] : []), + meta.text("Path", t(e.path), { icon: Icons.folder }), + ], + }, + actions: fileActions(e.path, true, prefs).filter((a) => a.title !== "Move to Trash"), + }; +} + +function fileItem(f: BigFile, d: Disk | undefined, prefs: Prefs): Item { + const ratio = d ? f.size / d.size : 0; + return { + id: `file:${f.path}`, + title: basename(f.path), + subtitle: t(f.path.split("/").slice(0, -1).join("/")), + icon: { source: Icons.file, tint: shareColor(ratio) }, + section: "Biggest Files", + keywords: [f.path, "file", "fichier"], + accessories: [{ text: ago(f.modified), color: "gray" }, { text: formatBytes(f.size) }], + detail: { + stats: [{ label: "Size", value: formatBytes(f.size), color: shareColor(ratio) }, { + label: "Modified", + value: ago(f.modified), + }], + metadata: [ + ...shareMeta(f.size, d), + meta.text("Path", t(f.path), { icon: Icons.file }), + meta.text("Modified", new Date(f.modified).toLocaleString(), { icon: Icons.clock }), + ], + }, + actions: fileActions(f.path, false, prefs), + }; +} + +function cacheItem(e: Entry, d: Disk | undefined, prefs: Prefs): Item { + const name = basename(e.path); + const owner = cacheOwner(name); + const safe = safeCache(name); + return { + id: `cache:${e.path}`, + title: name, + subtitle: owner, + icon: { source: Icons.archive, tint: safe ? "teal" : "orange" }, + section: "Caches", + keywords: ["cache", e.path, owner ?? ""], + accessories: [ + ...(safe ? [] : [{ tag: "Re-download", color: "orange" as const }]), + { text: formatBytes(e.size) }, + ], + detail: { + stats: [{ label: "Size", value: formatBytes(e.size) }], + markdown: safe + ? "Apps rebuild their cache as needed. Emptying it frees the space now; the app may be slower the first time." + : "This folder holds downloads (models, browsers) that take long to get back.", + metadata: [ + ...shareMeta(e.size, d), + meta.text("Path", t(e.path), { icon: Icons.folder }), + ...(owner ? [meta.text("Used by", owner, { icon: Icons.apps })] : []), + ], + }, + actions: [ + action(`Empty ${name}`, async () => { + await emptyFolder(e.path); + await toast(`Freed about ${formatBytes(e.size)}`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete everything in ${t(e.path)} (${formatBytes(e.size)})? Close ${owner ?? "the app"} first.`, + }), + action("Show Contents by Size", () => folderView(e.path, prefs), { + icon: Icons.chartDown, + shortcut: "Return", + }), + open(e.path, "Open Folder", { icon: Icons.folder }), + copy(e.path, "Copy Path", { section: "Copy", shortcut: "c" }), + ], + }; +} + +function cleanupItems(x: Extras, caches: Entry[], d: Disk | undefined, prefs: Prefs): Item[] { + const out: Item[] = []; + const cleanup = ( + id: string, + title: string, + size: number | undefined, + icon: string, + md: string, + actions: Action[], + extra: Metadata[] = [], + ): Item => ({ + id: `cleanup:${id}`, + title, + subtitle: size === undefined ? "Measuring…" : undefined, + icon: { source: icon, tint: size && size > 1024 ** 3 ? "orange" : "teal" }, + section: "Clean Up", + keywords: ["clean", "nettoyer", "free", "libérer", id], + accessories: size === undefined ? [] : [{ text: formatBytes(size) }], + detail: { + stats: size === undefined ? [] : [{ label: "Size", value: formatBytes(size) }], + markdown: md, + metadata: [...(size ? shareMeta(size, d) : []), ...extra], + }, + actions, + }); + + out.push(cleanup( + "trash", + "Trash", + x.trash, + Icons.trash, + "Files you deleted are kept here until you empty it.", + [ + action("Empty Trash", async () => { + await exec("gio", ["trash", "--empty"]); + await toast("Trash emptied", { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete everything in the trash for good${x.trash ? ` (${formatBytes(x.trash)})` : ""}?`, + }), + open("trash:///", "Open Trash", { icon: Icons.folder }), + ], + )); + + const cacheTotal = caches.reduce((s, c) => s + c.size, 0); + const safe = caches.filter((c) => safeCache(basename(c.path))); + out.push(cleanup( + "cache", + "App Caches", + caches.length ? cacheTotal : undefined, + Icons.archive, + `**${plural(caches.length, "folder")}** in \`~/.cache\`. Emptying them is safe: apps rebuild what they need.`, + [ + action("Empty All Caches", async () => { + for (const c of safe) await emptyFolder(c.path); + await toast(`Freed about ${formatBytes(safe.reduce((s, c) => s + c.size, 0))}`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Empty ${plural(safe.length, "cache folder")} (${ + formatBytes(safe.reduce((s, c) => s + c.size, 0)) + })? Close your apps first.`, + }), + open(CACHE, "Open ~/.cache", { icon: Icons.folder }), + ], + [meta.tags("Biggest", caches.slice(0, 5).map((c) => basename(c.path)))], + )); + + if (x.journal !== undefined) { + out.push(cleanup( + "journal", + "System Journal", + x.journal, + Icons.book, + "Logs kept by systemd. Vacuuming removes the oldest ones; it asks for your password in a terminal.", + [ + terminalAction("Vacuum to 200 MB", ["sudo", "journalctl", "--vacuum-size=200M"], prefs), + submenu( + "Vacuum…", + [ + terminalAction("Keep 100 MB", ["sudo", "journalctl", "--vacuum-size=100M"], prefs), + terminalAction("Keep 500 MB", ["sudo", "journalctl", "--vacuum-size=500M"], prefs), + terminalAction("Keep 2 Weeks", ["sudo", "journalctl", "--vacuum-time=2weeks"], prefs), + terminalAction("Keep 1 Month", ["sudo", "journalctl", "--vacuum-time=1month"], prefs), + ], + { icon: Icons.terminal, section: "Clean Up" }, + ), + ], + )); + } + + for (const tool of ["docker", "podman"] as const) { + const usage = x[tool]; + if (!usage) continue; + const size = usage.reduce((s, u) => s + u.size, 0); + const reclaim = usage.reduce((s, u) => s + u.reclaimable, 0); + out.push(cleanup( + tool, + tool === "docker" ? "Docker" : "Podman", + size, + Icons.box, + `**${ + formatBytes(reclaim) + }** can be reclaimed: stopped containers, unused networks, dangling images and build cache.`, + [ + terminalAction("Prune Unused Data", [tool, "system", "prune"], prefs, { icon: Icons.trash }), + terminalAction("Prune Everything Unused, Images Included", [tool, "system", "prune", "-a"], prefs, { + icon: Icons.trash, + }), + ], + usage.map((u) => + meta.text( + u.type, + `${formatBytes(u.size)} · ${u.total} (${u.active} active) · ${formatBytes(u.reclaimable)} reclaimable`, + { + icon: Icons.layers, + }, + ) + ), + )); + } + + if (x.pkg) { + out.push(cleanup( + "packages", + x.pkg.name, + x.pkg.size, + Icons.package, + `Downloaded packages kept in \`${x.pkg.path}\` after installing. Cleaning asks for your password in a terminal.`, + [terminalAction(`Clean ${x.pkg.name}`, x.pkg.clean, prefs, { icon: Icons.trash })], + [meta.text("Command", x.pkg.clean.join(" "), { icon: Icons.terminal })], + )); + } + + if (x.flatpak !== undefined) { + out.push(cleanup( + "flatpak", + "Unused Flatpak Runtimes", + x.flatpak, + Icons.layers, + "Flatpak keeps runtimes after the apps that needed them are gone. The terminal lists them before removing anything.", + [terminalAction("Remove Unused Runtimes", ["flatpak", "uninstall", "--unused"], prefs, { icon: Icons.trash })], + [meta.text("Size", "All of Flatpak, apps included", { icon: Icons.info })], + )); + } + return out; +} + +let status = ""; + +export default (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + return list({ + placeholder: "Filter folders, files and caches…", + filter: { + options: [ + { value: "all", title: "Overview" }, + { value: "folders", title: "Folders" }, + { value: "files", title: "Files" }, + { value: "cleanup", title: "Clean Up" }, + ], + }, + empty: { title: "Nothing here yet", description: "Still scanning, or nothing big enough", icon: Icons.drive }, + status: () => status, + async *items({ signal, filter }) { + const f = filter ?? "all"; + const fam = await family(); + const [d, saved] = await Promise.all([ + disk(HOME), + (storage.get("scan") as Promise).then((v) => v as Saved | undefined).catch(() => undefined), + ]); + const entries = new Map(); + const files: BigFile[] = []; + const x: Extras = {}; + let scanning = true; + let savedAt: number | undefined; + + if (saved && saved.entries?.length) { + for (const [p, s] of saved.entries) entries.set(p, s); + for (const [p, s, m] of saved.files ?? []) files.push({ path: p, size: s, modified: m }); + savedAt = saved.at; + } + + const render = (): Item[] => { + const list = [...entries].map(([path, size]) => ({ path, size })); + const home = rootSize(list, HOME); + const caches = childrenOf(list, CACHE, 40).filter((c) => c.size >= 1024); + const out: Item[] = []; + if (f === "all") out.push(diskItem(d, home, scanning, savedAt)); + if (f === "all" || f === "cleanup") out.push(...cleanupItems(x, caches, d, prefs)); + if (f === "all" || f === "folders") { + out.push(...topFolders(list, HOME, f === "all" ? 12 : 40).map((e) => folderItem(e, list, d, home, prefs))); + } + if (f === "all" || f === "files") { + out.push(...biggest(files, f === "all" ? 12 : 50).map((b) => fileItem(b, d, prefs))); + } + if (f === "cleanup" || f === "folders") out.push(...caches.map((c) => cacheItem(c, d, prefs))); + status = `${scanning ? (savedAt ? `Last scan ${ago(savedAt)}, rescanning… ` : "Scanning… ") : ""}${ + home ? `Home ${formatBytes(home)}` : "" + }${d ? ` · ${formatBytes(d.available)} free of ${formatBytes(d.size)}` : ""}`; + return out; + }; + yield render(); + + // The scan replaces what was saved. + const stop = AbortSignal.any([signal, AbortSignal.timeout((prefs.timeout || 60) * 1000)]); + const fresh = new Map(); + const freshFiles: BigFile[] = []; + let first = true; + const swap = () => { + // Keep the saved results on screen until the scan has something to show. + if (first && !fresh.size && !freshFiles.length) return; + first = false; + savedAt = undefined; + entries.clear(); + for (const [p, s] of fresh) entries.set(p, s); + files.splice(0, files.length, ...freshFiles); + }; + const pkg = packageCache(fam); + const jobs = [ + du(HOME, 2, stop, (e) => fresh.set(e.path, e.size)), + bigFiles(HOME, prefs.minFileMb || 100, stop, (b) => freshFiles.push(b)), + sizeOf(`${HOME}/.local/share/Trash`).then((s) => (x.trash = s ?? 0)), + journalSize().then((s) => (x.journal = s)), + containers("docker").then((u) => (x.docker = u)), + containers("podman").then((u) => (x.podman = u)), + ...(pkg ? [sizeOf(pkg.path).then((s) => (x.pkg = s === undefined ? undefined : { ...pkg, size: s }))] : []), + installed("flatpak").then(async (yes) => { + if (!yes) return; + const [sys, user] = await Promise.all([sizeOf("/var/lib/flatpak"), sizeOf(`${HOME}/.local/share/flatpak`)]); + x.flatpak = (sys ?? 0) + (user ?? 0); + }), + ].map((p) => p.catch(() => {})); + const all = Promise.all(jobs); + let done = false; + all.then(() => (done = true)); + while (!done && !signal.aborted) { + await Promise.race([all, new Promise((r) => setTimeout(r, 800))]); + swap(); + if (!done) yield render(); + } + if (signal.aborted) return; + scanning = false; + swap(); + const final = render(); + const partial = stop.aborted; + if (partial) status += " · stopped at the time limit"; + yield final; + const homeSize = fresh.get(HOME); + if (homeSize !== undefined || fresh.size) { + updateCommandMetadata({ + subtitle: d ? `${formatBytes(d.available)} free` : homeSize ? `Home ${formatBytes(homeSize)}` : null, + }).catch(() => {}); + } + // Saved for next time: the top-level folders, their children, and the big files. + const keep = [...fresh].sort((a, b) => b[1] - a[1]).slice(0, 400); + await storage.set("scan", { + at: Date.now(), + entries: keep, + files: biggest(freshFiles, 60).map((b) => [b.path, b.size, b.modified]), + }).catch(() => {}); + }, + }); +}; diff --git a/extensions/disk-usage/lib.ts b/extensions/disk-usage/lib.ts new file mode 100644 index 0000000..92e5c60 --- /dev/null +++ b/extensions/disk-usage/lib.ts @@ -0,0 +1,241 @@ +// Parsing du, df, find, journalctl and docker output, and choosing what to +// show. Pure, tested in lib_test.ts. + +export interface Entry { + path: string; + size: number; +} + +/** One `du -b` line: `123456\t/home/ada/.cache`. */ +export function parseDuLine(line: string): Entry | undefined { + const m = line.match(/^(\d+)\t(.+)$/); + return m ? { size: Number(m[1]), path: m[2] } : undefined; +} + +export function parseDu(text: string): Entry[] { + return text.split("\n").map(parseDuLine).filter((e): e is Entry => !!e); +} + +export interface Disk { + size: number; + used: number; + available: number; + mount: string; +} + +/** `df -B1 --output=size,used,avail,target `. */ +export function parseDf(text: string): Disk | undefined { + const line = text.trim().split("\n").slice(1).find((l) => l.trim()); + const m = line?.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/); + return m ? { size: +m[1], used: +m[2], available: +m[3], mount: m[4] } : undefined; +} + +/** `journalctl --disk-usage`: "Archived and active journals take up 1.2G in the file system." */ +export function parseJournalUsage(text: string): number | undefined { + const m = text.match(/take up ([\d.]+)\s*([KMGTP]?)i?B?\b/i); + if (!m) return undefined; + const pow = m[2] ? "KMGTP".indexOf(m[2].toUpperCase()) + 1 : 0; + return Math.round(parseFloat(m[1]) * 1024 ** pow); +} + +/** "2.5GB", "512.3MB", "0B", "1.03kB" (docker, decimal units) → bytes. */ +export function parseHumanSize(text: string): number { + const m = text.trim().match(/^([\d.]+)\s*([kKMGTP]?)i?B?/); + if (!m) return 0; + const pow = m[2] ? "KMGTP".indexOf(m[2].toUpperCase()) + 1 : 0; + return Math.round(parseFloat(m[1]) * 1000 ** pow); +} + +export interface ContainerUsage { + type: string; + total: number; + active: number; + size: number; + reclaimable: number; +} + +/** `docker system df --format '{{json .}}'` (podman prints the same fields). */ +export function parseSystemDf(text: string): ContainerUsage[] { + return text.split("\n").filter((l) => l.trim().startsWith("{")).map((l) => { + const o = JSON.parse(l) as Record; + return { + type: String(o.Type ?? ""), + total: Number(o.TotalCount ?? o.Total ?? 0), + active: Number(o.Active ?? 0), + size: typeof o.Size === "number" ? o.Size : parseHumanSize(String(o.Size ?? "0")), + reclaimable: typeof o.Reclaimable === "number" + ? o.Reclaimable + : parseHumanSize(String(o.Reclaimable ?? "0").replace(/\s*\(.*\)/, "")), + }; + }); +} + +export interface BigFile extends Entry { + modified: number; +} + +/** One `find -printf '%s\t%T@\t%p\n'` line. */ +export function parseFindLine(line: string): BigFile | undefined { + const m = line.match(/^(\d+)\t([\d.]+)\t(.+)$/); + return m ? { size: +m[1], modified: Math.round(parseFloat(m[2]) * 1000), path: m[3] } : undefined; +} + +// --------------------------------------------------------------------------- +// Choosing + +const depth = (path: string, root: string) => + path === root ? 0 : path.slice(root.length).split("/").filter(Boolean).length; + +/** The folders right under `root`, biggest first. */ +export function topFolders(entries: Entry[], root: string, limit = 20): Entry[] { + return entries.filter((e) => e.path.startsWith(`${root}/`) && depth(e.path, root) === 1) + .sort((a, b) => b.size - a.size).slice(0, limit); +} + +/** The biggest subfolders of `parent` among the entries. */ +export function childrenOf(entries: Entry[], parent: string, limit = 6): Entry[] { + return entries.filter((e) => e.path.startsWith(`${parent}/`) && !e.path.slice(parent.length + 1).includes("/")) + .sort((a, b) => b.size - a.size).slice(0, limit); +} + +/** Total of the root from du's last line, or the sum of its children while scanning. */ +export function rootSize(entries: Entry[], root: string): number { + const own = entries.find((e) => e.path === root); + return own?.size ?? topFolders(entries, root, Infinity).reduce((s, e) => s + e.size, 0); +} + +/** Keeps the biggest files, without duplicates. */ +export function biggest(files: T[], limit = 25): T[] { + const seen = new Set(); + return [...files].sort((a, b) => b.size - a.size).filter((f) => !seen.has(f.path) && seen.add(f.path)).slice( + 0, + limit, + ); +} + +/** What a cache folder belongs to, for a friendlier subtitle. */ +export function cacheOwner(name: string): string | undefined { + const known: Record = { + "mozilla": "Firefox", + "google-chrome": "Google Chrome", + "chromium": "Chromium", + "BraveSoftware": "Brave", + "thumbnails": "Thumbnails", + "pip": "Python pip", + "pypoetry": "Poetry", + "uv": "uv (Python)", + "yarn": "Yarn", + "pnpm": "pnpm", + "deno": "Deno", + "go-build": "Go build cache", + "JetBrains": "JetBrains IDEs", + "spotify": "Spotify", + "tracker3": "GNOME search index", + "fontconfig": "Fonts", + "mesa_shader_cache": "GPU shaders", + "nvidia": "NVIDIA shaders", + "yay": "yay (AUR)", + "paru": "paru (AUR)", + "flatpak": "Flatpak", + "electron": "Electron", + "ms-playwright": "Playwright browsers", + "huggingface": "Hugging Face models", + "torch": "PyTorch", + "bazel": "Bazel", + "ccache": "ccache", + "sccache": "sccache", + "cargo": "Cargo", + "npm": "npm", + "node-gyp": "node-gyp", + "gstreamer-1.0": "GStreamer", + "babl": "GIMP", + "gimp": "GIMP", + "steam": "Steam", + "vscode-cpptools": "VS Code C++", + "typescript": "TypeScript", + }; + return known[name]; +} + +/** A cache folder that is safe to empty: everything under ~/.cache is, by definition, except a few. */ +export function safeCache(name: string): boolean { + // Some apps keep things there that aren't recreated without a download or a long rebuild. + return !["ms-playwright", "huggingface", "torch"].includes(name); +} + +// --------------------------------------------------------------------------- +// Package caches + +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; +} + +/** Where the distribution keeps downloaded packages, and how to clean it. */ +export function packageCache(f?: Family): { name: string; path: string; clean: string[] } | undefined { + switch (f) { + case "arch": + return { + name: "pacman cache", + path: "/var/cache/pacman/pkg", + clean: ["sudo", "pacman", "-Sc"], + }; + case "debian": + return { name: "APT cache", path: "/var/cache/apt/archives", clean: ["sudo", "apt-get", "clean"] }; + case "fedora": + return { name: "DNF cache", path: "/var/cache/libdnf5", clean: ["sudo", "dnf", "clean", "all"] }; + case "suse": + return { name: "Zypper cache", path: "/var/cache/zypp/packages", clean: ["sudo", "zypper", "clean", "--all"] }; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Formatting + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + 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]}`; +} + +export const tilde = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p); +export const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() ?? p; +export const dirname = (p: string) => p.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "/"; + +/** "3 days ago", "2 months ago". */ +export function ago(ms: number, now = Date.now()): string { + const d = Math.floor((now - ms) / 86400000); + if (d < 1) return "today"; + if (d === 1) return "yesterday"; + if (d < 31) return `${d} days ago`; + const m = Math.floor(d / 30.4); + if (m < 12) return `${m} month${m === 1 ? "" : "s"} ago`; + const y = Math.floor(d / 365); + return `${y} year${y === 1 ? "" : "s"} ago`; +} + +/** Color of a share of the disk. */ +export function shareColor(ratio: number): "red" | "orange" | "yellow" | "blue" { + return ratio >= 0.25 ? "red" : ratio >= 0.1 ? "orange" : ratio >= 0.03 ? "yellow" : "blue"; +} + +/** A tiny text bar for the detail panel: `▓▓▓▓░░░░░░`. */ +export function bar(ratio: number, width = 20): string { + const n = Math.max(0, Math.min(width, Math.round(ratio * width))); + return "▓".repeat(n) + "░".repeat(width - n); +} diff --git a/extensions/disk-usage/lib_test.ts b/extensions/disk-usage/lib_test.ts new file mode 100644 index 0000000..8dc19a6 --- /dev/null +++ b/extensions/disk-usage/lib_test.ts @@ -0,0 +1,121 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + bar, + biggest, + cacheOwner, + childrenOf, + formatBytes, + packageCache, + parseDf, + parseDu, + parseFindLine, + parseHumanSize, + parseJournalUsage, + parseSystemDf, + rootSize, + safeCache, + shareColor, + tilde, + topFolders, +} from "./lib.ts"; +import { shellLine, terminalArgs } from "./terminal.ts"; + +// `du -x -d 2 -b /home/ada`, as du prints it: children before parents. +const DU = `4096\t/home/ada/.cache/fontconfig +1288490188\t/home/ada/.cache/mozilla +524288000\t/home/ada/.cache/pip +1813512284\t/home/ada/.cache +8589934592\t/home/ada/.local/share +120000\t/home/ada/.local/state +8590054592\t/home/ada/.local +21474836480\t/home/ada/Videos/Holidays +22548578304\t/home/ada/Videos +3221225472\t/home/ada/Downloads +36173373992\t/home/ada +`; + +Deno.test("du output", () => { + const entries = parseDu(DU); + assertEquals(entries.length, 11); + assertEquals(topFolders(entries, "/home/ada").map((e) => e.path), [ + "/home/ada/Videos", + "/home/ada/.local", + "/home/ada/Downloads", + "/home/ada/.cache", + ]); + assertEquals(childrenOf(entries, "/home/ada/.cache").map((e) => e.path), [ + "/home/ada/.cache/mozilla", + "/home/ada/.cache/pip", + "/home/ada/.cache/fontconfig", + ]); + assertEquals(rootSize(entries, "/home/ada"), 36173373992); + // While scanning, before du prints the root: the sum of what is known. + assertEquals(rootSize(entries.slice(0, 4), "/home/ada"), 1813512284); + // A tab in a file name stays in the path. + assertEquals(parseDu("12\t/home/ada/a\tb\n")[0].path, "/home/ada/a\tb"); +}); + +Deno.test("df, journal and container usage", () => { + assertEquals( + parseDf(" 1B-blocks Used Avail Mounted on\n510770802688 312345067520 172421853184 /home\n"), + { size: 510770802688, used: 312345067520, available: 172421853184, mount: "/home" }, + ); + assertEquals(parseDf(""), undefined); + assertEquals( + parseJournalUsage("Archived and active journals take up 1.2G in the file system."), + Math.round(1.2 * 1024 ** 3), + ); + assertEquals(parseJournalUsage("Archived and active journals take up 24.0M in the file system."), 24 * 1024 ** 2); + assertEquals(parseJournalUsage("No journal files were found."), undefined); + assertEquals(parseHumanSize("2.5GB"), 2_500_000_000); + assertEquals(parseHumanSize("0B"), 0); + const df = parseSystemDf( + [ + '{"Active":"3","Reclaimable":"4.1GB (61%)","Size":"6.72GB","TotalCount":"12","Type":"Images"}', + '{"Active":"2","Reclaimable":"0B (0%)","Size":"12.3MB","TotalCount":"4","Type":"Containers"}', + '{"Active":"1","Reclaimable":"55MB","Size":"310MB","TotalCount":"3","Type":"Local Volumes"}', + '{"Active":"0","Reclaimable":"1.2GB","Size":"1.2GB","TotalCount":"40","Type":"Build Cache"}', + ].join("\n"), + ); + assertEquals(df[0], { type: "Images", total: 12, active: 3, size: 6_720_000_000, reclaimable: 4_100_000_000 }); + assertEquals(df.reduce((s, u) => s + u.reclaimable, 0), 5_355_000_000); +}); + +Deno.test("big files from find", () => { + const a = parseFindLine("4294967296\t1729000000.1234567890\t/home/ada/VMs/win11.qcow2")!; + assertEquals(a, { size: 4294967296, modified: 1729000000123, path: "/home/ada/VMs/win11.qcow2" }); + assertEquals(parseFindLine("garbage"), undefined); + const files = [a, { ...a, size: 1, path: "/x" }, a, { ...a, size: 9e9, path: "/y" }]; + assertEquals(biggest(files).map((f) => f.path), ["/y", "/home/ada/VMs/win11.qcow2", "/x"]); +}); + +Deno.test("caches and package caches", () => { + assertEquals(cacheOwner("mozilla"), "Firefox"); + assertEquals(cacheOwner("something"), undefined); + assertEquals(safeCache("pip"), true); + assertEquals(safeCache("huggingface"), false); + assertEquals(packageCache("arch")?.path, "/var/cache/pacman/pkg"); + assertEquals(packageCache("debian")?.clean, ["sudo", "apt-get", "clean"]); + assertEquals(packageCache(undefined), undefined); +}); + +Deno.test("formatting", () => { + assertEquals(formatBytes(1813512284), "1.7 GB"); + assertEquals(formatBytes(512), "512 B"); + assertEquals(tilde("/home/ada/Videos", "/home/ada"), "~/Videos"); + assertEquals(bar(0.25, 8), "▓▓░░░░░░"); + assertEquals(shareColor(0.3), "red"); + assertEquals(shareColor(0.01), "blue"); + const now = Date.UTC(2026, 9, 28); + assertEquals(ago(now - 3 * 86400000, now), "3 days ago"); + assertEquals(ago(now - 70 * 86400000, now), "2 months ago"); + assertEquals(ago(now - 800 * 86400000, now), "2 years ago"); +}); + +Deno.test("terminal command lines", () => { + assertEquals(terminalArgs("kitty", ["sh", "-c", "x"]), ["sh", "-c", "x"]); + assertEquals(terminalArgs("gnome-terminal", ["sh"]), ["--", "sh"]); + assertEquals(shellLine(["sudo", "journalctl", "--vacuum-size=200M"]), "sudo journalctl --vacuum-size=200M"); + assertEquals(shellLine(["echo", "it's"]), `echo 'it'\\''s'`); +}); diff --git a/extensions/disk-usage/system.ts b/extensions/disk-usage/system.ts new file mode 100644 index 0000000..8544432 --- /dev/null +++ b/extensions/disk-usage/system.ts @@ -0,0 +1,151 @@ +// Running the scanners and the cleanups. + +import { exec, openExternal } from "@backslash/api"; +import { + type BigFile, + type ContainerUsage, + type Disk, + type Entry, + osFamily, + parseDf, + parseDu, + parseDuLine, + parseFindLine, + parseJournalUsage, + parseSystemDf, +} from "./lib.ts"; +import { runInTerminal, shellLine } from "./terminal.ts"; + +export const HOME = (() => { + try { + return Deno.env.get("HOME") ?? ""; + } catch { + return ""; + } +})(); + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +/** Runs a program and calls `onLine` for each line of its output as it comes. Stops at `signal`. */ +export async function stream( + program: string, + args: string[], + signal: AbortSignal, + onLine: (line: string) => void, +): Promise { + const child = new Deno.Command(program, { args, stdin: "null", stdout: "piped", stderr: "null", signal }).spawn(); + let rest = ""; + try { + for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { + const lines = (rest + chunk).split("\n"); + rest = lines.pop() ?? ""; + for (const l of lines) onLine(l); + } + if (rest) onLine(rest); + } catch { + // Aborted: keep what was read. + } + await child.status.catch(() => {}); +} + +/** `du -x -d -b `, entries streamed as du finishes each folder. */ +export function du(path: string, depth: number, signal: AbortSignal, onEntry: (e: Entry) => void) { + return stream("du", ["-x", "-d", String(depth), "-b", path], signal, (l) => { + const e = parseDuLine(l); + if (e) onEntry(e); + }); +} + +/** Files over `minMb` MB on the same file system, as `find` meets them. */ +export function bigFiles(path: string, minMb: number, signal: AbortSignal, onFile: (f: BigFile) => void) { + return stream( + "find", + [path, "-xdev", "-type", "f", "-size", `+${minMb}M`, "-printf", "%s\\t%T@\\t%p\\n"], + signal, + (l) => { + const f = parseFindLine(l); + if (f) onFile(f); + }, + ); +} + +const quiet = (p: Promise) => p.catch(() => undefined); + +export const disk = async (path: string): Promise => + parseDf((await quiet(exec("df", ["-B1", "--output=size,used,avail,target", path]))) ?? ""); + +/** Size of one folder (or 0 when it doesn't exist), within `ms`. */ +export async function sizeOf(path: string, ms = 15000): Promise { + const out = await quiet(exec("du", ["-s", "-x", "-b", path], { signal: AbortSignal.timeout(ms) })); + return out === undefined ? undefined : parseDu(out)[0]?.size ?? 0; +} + +export const journalSize = async () => + parseJournalUsage((await quiet(exec("journalctl", ["--disk-usage"], { signal: AbortSignal.timeout(5000) }))) ?? ""); + +export async function containers(tool: "docker" | "podman"): Promise { + const out = await quiet( + exec(tool, ["system", "df", "--format", "{{json .}}"], { signal: AbortSignal.timeout(8000) }), + ); + if (out === undefined) return undefined; + try { + return parseSystemDf(out); + } catch { + return undefined; + } +} + +export async function installed(program: string, args = ["--version"]): Promise { + try { + await exec(program, args, { signal: AbortSignal.timeout(3000) }); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.NotCapable || + e instanceof Deno.errors.PermissionDenied); + } +} + +/** Deletes what is inside a folder, keeping the folder. Returns the bytes freed (as du counted them). */ +export async function emptyFolder(path: string): Promise { + for await (const e of Deno.readDir(path)) { + await Deno.remove(`${path}/${e.name}`, { recursive: true }).catch(() => {}); + } +} + +/** Runs a command in a terminal that stays open on the result (sudo asks there). */ +export function inTerminal(argv: string[], terminal?: string): string { + const script = `${shellLine(argv)}; echo; printf 'Done. Press Enter to close.'; read _`; + return runInTerminal(["sh", "-c", script], terminal || undefined); +} + +/** Opens the folder with the file selected (FileManager1), or 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(path.split("/").slice(0, -1).join("/") || "/"); + } +} + +export async function trash(path: string): Promise { + try { + await exec("gio", ["trash", path]); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("gio (GLib) is needed to move files to the trash"); + throw e; + } +} diff --git a/extensions/disk-usage/terminal.ts b/extensions/disk-usage/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/disk-usage/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/downloads/backslash.toml b/extensions/downloads/backslash.toml new file mode 100644 index 0000000..9525785 --- /dev/null +++ b/extensions/downloads/backslash.toml @@ -0,0 +1,37 @@ +[extension] +id = "downloads" +title = "Downloads" +description = "Recent downloads with previews and where they came from; open, copy, move, rename or trash them" +version = "1.0.0" +author = "Backslash" +icon = "bs-download" +license = "MIT" + +# getfattr reads where a file was downloaded from (user.xdg.origin.url, set by +# browsers). mv and mkdir move and rename without overwriting, gio moves to the +# trash, gdbus shows a file in the file manager, wl-copy and xclip copy a file +# as a file. +[permissions] +run = ["getfattr", "gio", "mv", "mkdir", "gdbus", "wl-copy", "xclip"] +# The download folder and the other XDG folders (to move files into them) can be +# anywhere in the home folder, under localized names (Téléchargements, Descargas…). +read = ["~"] +env = ["HOME", "WAYLAND_DISPLAY"] + +[[commands]] +name = "downloads" +title = "Downloads" +description = "Your recent downloads by day, with picture previews" +icon = "bs-download" +keywords = [ + "downloads", + "download", + "téléchargements", + "téléchargement", + "fichiers récents", + "recent files", + "files", + "fichiers", + "installer", + "archive", +] diff --git a/extensions/downloads/commands/downloads.ts b/extensions/downloads/commands/downloads.ts new file mode 100644 index 0000000..a32fecd --- /dev/null +++ b/extensions/downloads/commands/downloads.ts @@ -0,0 +1,398 @@ +import { + type Action, + action, + clipboard, + type Color, + copy, + exec, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + openExternal, + pop, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + checkName, + extTag, + fileUri, + formatBytes, + freeName, + hostOf, + type Kind, + KIND_TITLES, + kindOf, + matchesFilter, + parseGetfattr, + parseUserDirs, + previewable, + section, + tilde, + when, + XDG_TITLES, + type XdgDir, +} from "../lib.ts"; + +const env = (k: string) => { + try { + return Deno.env.get(k) || undefined; + } catch { + return undefined; + } +}; +const HOME = env("HOME") ?? ""; +const t = (p: string) => tilde(p, HOME); + +interface Download { + name: string; + path: string; + size: number; + modified: Date; + kind: Kind; + isDir: boolean; + url?: string; + referrer?: string; +} + +const ICONS: Record = { + image: { icon: Icons.image, tint: "purple" }, + video: { icon: Icons.film, tint: "pink" }, + audio: { icon: Icons.music, tint: "pink" }, + document: { icon: Icons.file, tint: "orange" }, + archive: { icon: Icons.archive, tint: "teal" }, + installer: { icon: Icons.package, tint: "green" }, + code: { icon: Icons.code, tint: "blue" }, + folder: { icon: Icons.folder, tint: "blue" }, + partial: { icon: Icons.download, tint: "yellow" }, + other: { icon: Icons.file, tint: "gray" }, +}; + +/** Things that changed the folder from here: the list reloads at once. */ +const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +/** The XDG folders; the usual English names where user-dirs.dirs says nothing and they exist. */ +async function folders(): Promise>> { + const dirs = parseUserDirs(await Deno.readTextFile(`${HOME}/.config/user-dirs.dirs`).catch(() => ""), HOME); + for (const key of Object.keys(XDG_TITLES) as XdgDir[]) { + const fallback = `${HOME}/${XDG_TITLES[key]}`; + if (!dirs[key] && (key === "DOWNLOAD" || await exists(fallback))) dirs[key] = fallback; + } + return dirs; +} + +let getfattrMissing = false; + +/** Where files came from, as browsers record it (`user.xdg.origin.url`), in one getfattr call. */ +async function origins(paths: string[]): Promise>> { + if (!paths.length || getfattrMissing) return new Map(); + try { + const out = await exec("getfattr", ["--absolute-names", "-d", "-m", "^user\\.xdg\\.", ...paths], { + signal: AbortSignal.timeout(5000), + }).catch((e) => { + if (e instanceof Deno.errors.NotFound) throw e; + // Non-zero when one file has no attribute support; the rest is still printed. + return String(e?.message ?? ""); + }); + return parseGetfattr(out); + } catch (e) { + if (e instanceof Deno.errors.NotFound) getfattrMissing = true; + return new Map(); + } +} + +async function load(dir: string): Promise { + const out: Download[] = []; + try { + for await (const e of Deno.readDir(dir)) { + if (e.name.startsWith(".")) continue; + const path = `${dir}/${e.name}`; + const st = await Deno.stat(path).catch(() => undefined); + if (!st) continue; + out.push({ + name: e.name, + path, + size: st.size, + modified: st.mtime ?? new Date(0), + kind: kindOf(e.name, st.isDirectory), + isDir: st.isDirectory, + }); + } + } catch (e) { + if (e instanceof Deno.errors.NotFound) return []; + throw e; + } + out.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + const recent = out.slice(0, 250); + const attrs = await origins(recent.filter((d) => !d.isDir).slice(0, 120).map((d) => d.path)); + for (const d of recent) { + const a = attrs.get(d.path); + d.url = a?.["user.xdg.origin.url"]; + d.referrer = a?.["user.xdg.referrer.url"]; + } + return recent; +} + +async function pipe(program: string, args: string[], input: string): Promise { + try { + const child = new Deno.Command(program, { args, stdin: "piped", stdout: "null", stderr: "null" }).spawn(); + const w = child.stdin.getWriter(); + await w.write(new TextEncoder().encode(input)); + await w.close(); + // wl-copy and xclip stay in the background to serve the clipboard. + const done = await Promise.race([ + child.status.then((s) => s.success), + new Promise((r) => setTimeout(r, 400, true)), + ]); + return Boolean(done); + } catch { + return false; + } +} + +/** Puts the file on the clipboard as a file (text/uri-list), so a file manager or chat app pastes the file. */ +async function copyFile(path: string): Promise { + const uri = `${fileUri(path)}\r\n`; + const wayland = !!env("WAYLAND_DISPLAY"); + const wl = () => pipe("wl-copy", ["--type", "text/uri-list"], uri); + const x = () => pipe("xclip", ["-selection", "clipboard", "-t", "text/uri-list"], uri); + return wayland ? (await wl()) || (await x()) : (await x()) || (await wl()); +} + +async function showInFolder(path: string): Promise { + try { + await exec("gdbus", [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + `['${fileUri(path).replace(/'/g, "%27")}']`, + "", + ]); + } catch { + await openExternal(path.split("/").slice(0, -1).join("/") || "/"); + } +} + +async function exists(p: string) { + try { + await Deno.lstat(p); + return true; + } catch { + return false; + } +} + +/** Moves without overwriting: mv -n, then checks it really moved. */ +async function move(from: string, to: string) { + if (await exists(to)) throw new Error(`${t(to)} already exists`); + await exec("mv", ["-n", "-T", from, to]); + if (await exists(from)) throw new Error(`Couldn't move ${t(from)}`); +} + +async function moveInto(d: Download, folder: string): Promise { + const names = new Set(); + try { + for await (const e of Deno.readDir(folder)) names.add(e.name); + } catch { + await exec("mkdir", ["-p", folder]); + } + const dest = `${folder}/${freeName(d.name, (n) => names.has(n))}`; + await move(d.path, dest); + return dest; +} + +function renameForm(d: Download) { + return form({ + title: `Rename ${d.name}`, + submitTitle: "Rename", + fields: [field.text("name", "Name", { value: d.name, required: true })], + async submit(values) { + const name = String(values.name ?? "").trim(); + const problem = checkName(name); + if (problem) throw new Error(problem); + if (name === d.name) return pop(); + const dest = `${d.path.split("/").slice(0, -1).join("/")}/${name}`; + await move(d.path, dest); + changed(); + await toast(`Renamed to ${name}`, { style: "success" }); + return pop(); + }, + }); +} + +function moveForm(d: Download) { + return form({ + title: `Move ${d.name}`, + submitTitle: "Move", + fields: [field.text("folder", "Folder", { placeholder: "~/Projects/invoices", required: true })], + async submit(values) { + const raw = String(values.folder ?? "").trim().replace(/\/+$/, ""); + if (!raw) throw new Error("Type a folder"); + const folder = raw.startsWith("~") ? `${HOME}${raw.slice(1)}` : raw.startsWith("/") ? raw : `${HOME}/${raw}`; + const dest = await moveInto(d, folder); + changed(); + await toast(`Moved to ${t(dest)}`, { style: "success" }); + return pop(); + }, + }); +} + +function actionsFor(d: Download, dirs: Partial>): Action[] { + const targets = (Object.keys(XDG_TITLES) as XdgDir[]).filter((k) => k !== "DOWNLOAD" && dirs[k]); + return [ + open(d.path, d.isDir ? "Open Folder" : "Open", { icon: Icons.external }), + action("Show in Folder", async () => { + await showInFolder(d.path); + }, { icon: Icons.folder, shortcut: "f" }), + action("Copy File", async () => { + if (await copyFile(d.path)) { + await toast(`${d.name} copied: paste it in a file manager or a chat`, { style: "success" }); + } else { + await clipboard.write(fileUri(d.path)); + await toast("Copied as a link (install wl-clipboard or xclip to copy the file itself)"); + } + }, { icon: Icons.copy, shortcut: "c", section: "Copy" }), + copy(d.path, "Copy Path", { section: "Copy", shortcut: "c" }), + copy(d.name, "Copy Name", { section: "Copy" }), + ...(d.url ? [copy(d.url, "Copy Download Link", { section: "Copy", icon: Icons.link })] : []), + ...(d.referrer ? [open(d.referrer, "Open Source Page", { icon: Icons.globe, section: "Copy" })] : []), + submenu( + "Move to…", + targets.map((k) => + action(XDG_TITLES[k], async () => { + const dest = await moveInto(d, dirs[k]!); + changed(); + await toast(`Moved to ${t(dest)}`, { style: "success" }); + return refresh(); + }, { icon: Icons.folder }) + ).concat([action("Other Folder…", () => moveForm(d), { icon: Icons.plus })]), + { icon: Icons.arrowRight, shortcut: "m", section: "Organize" }, + ), + action("Rename…", () => renameForm(d), { icon: Icons.text, shortcut: "F2", section: "Organize" }), + action("Move to Trash", async () => { + try { + await exec("gio", ["trash", d.path]); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("gio (GLib) is needed to move files to the trash"); + throw e; + } + changed(); + await toast(`${d.name} moved to the trash`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Move ${d.name} to the trash?`, + }), + ]; +} + +function item(d: Download, dirs: Partial>): Item { + const { icon, tint } = ICONS[d.kind]; + const host = hostOf(d.url) ?? hostOf(d.referrer); + const ext = d.isDir ? undefined : extTag(d.name); + const metadata: Metadata[] = [ + meta.text("Downloaded", d.modified.toLocaleString("en-GB"), { icon: Icons.clock }), + meta.tags("Type", [ + { text: KIND_TITLES[d.kind], color: tint }, + ...(ext ? [{ text: ext }] : []), + ]), + ...(d.url ? [meta.link("From", hostOf(d.url) ?? d.url, d.url)] : []), + ...(d.referrer && d.referrer !== d.url ? [meta.link("Page", hostOf(d.referrer) ?? d.referrer, d.referrer)] : []), + ...(!d.url && getfattrMissing + ? [meta.text("Source", "Install getfattr (attr) to see it", { icon: Icons.info, color: "gray" })] + : []), + meta.separator(), + meta.text("Path", t(d.path), { icon: Icons.folder }), + ]; + return { + id: d.path, + title: d.name, + subtitle: host, + icon: { source: icon, tint }, + section: section(d.modified), + keywords: [d.kind, KIND_TITLES[d.kind], host ?? "", ext ?? ""], + accessories: [ + ...(d.kind === "partial" ? [{ tag: "Downloading", color: "yellow" as const }] : []), + ...(d.isDir ? [] : [{ text: formatBytes(d.size) }]), + { text: when(d.modified), color: "gray" as const }, + ], + detail: { + image: previewable(d.name) ? d.path : undefined, + stats: d.isDir ? [{ label: "Kind", value: "Folder" }] : [ + { label: "Size", value: formatBytes(d.size) }, + { label: "Kind", value: ext ?? KIND_TITLES[d.kind], color: tint }, + ], + metadata, + }, + actions: actionsFor(d, dirs), + }; +} + +const sleepUntil = (ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + +let status = ""; + +export default async () => { + const dirs = await folders(); + const dir = dirs.DOWNLOAD ?? `${HOME}/Downloads`; + return list({ + placeholder: "Filter recent downloads…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "images", title: "Images" }, + { value: "documents", title: "Documents" }, + { value: "archives", title: "Archives" }, + { value: "installers", title: "Installers" }, + ], + }, + empty: { title: "Nothing downloaded here", description: "Files you download show up here", icon: Icons.download }, + status: () => status, + // New downloads appear by themselves; changes made here show at once. + async *items({ filter, signal }) { + let last = ""; + while (!signal.aborted) { + const all = await load(dir); + const key = all.map((d) => `${d.name}:${d.size}:${d.modified.getTime()}`).join("|"); + if (key !== last) { + last = key; + const total = all.reduce((s, d) => s + (d.isDir ? 0 : d.size), 0); + const today = all.filter((d) => section(d.modified) === "Today").length; + status = `${all.length} item${all.length === 1 ? "" : "s"} · ${formatBytes(total)} · ${t(dir)}`; + updateCommandMetadata({ subtitle: today ? `${today} today` : all[0] ? `Latest: ${all[0].name}` : null }) + .catch(() => {}); + yield all.filter((d) => matchesFilter(d.kind, filter ?? "all")).map((d) => item(d, dirs)); + } + await sleepUntil(3000, signal); + } + }, + actions: [open(dir, "Open Downloads Folder", { icon: Icons.folder, shortcut: "o" })], + }); +}; diff --git a/extensions/downloads/lib.ts b/extensions/downloads/lib.ts new file mode 100644 index 0000000..2b07e67 --- /dev/null +++ b/extensions/downloads/lib.ts @@ -0,0 +1,235 @@ +// XDG folders, file kinds and dates for the Downloads list. Pure, tested in lib_test.ts. + +export type XdgDir = + | "DESKTOP" + | "DOWNLOAD" + | "TEMPLATES" + | "PUBLICSHARE" + | "DOCUMENTS" + | "MUSIC" + | "PICTURES" + | "VIDEOS"; + +export const XDG_TITLES: Record = { + DESKTOP: "Desktop", + DOWNLOAD: "Downloads", + TEMPLATES: "Templates", + PUBLICSHARE: "Public", + DOCUMENTS: "Documents", + MUSIC: "Music", + PICTURES: "Pictures", + VIDEOS: "Videos", +}; + +/** `~/.config/user-dirs.dirs` → absolute folders. `$HOME/` alone means the folder is disabled. */ +export function parseUserDirs(text: string, home: string): Partial> { + const out: Partial> = {}; + for (const m of text.matchAll(/^\s*XDG_([A-Z]+)_DIR="([^"]*)"/gm)) { + const key = m[1] as XdgDir; + if (!(key in XDG_TITLES)) continue; + const p = m[2].replace(/^\$HOME/, home).replace(/\/+$/, ""); + if (p && p !== home) out[key] = p; + } + return out; +} + +export type Kind = + | "image" + | "video" + | "audio" + | "document" + | "archive" + | "installer" + | "code" + | "folder" + | "partial" + | "other"; + +const KINDS: [Kind, RegExp][] = [ + ["partial", /\.(crdownload|part|download|partial)$/i], + ["installer", /\.(deb|rpm|appimage|flatpakref|flatpak|snap|run|pkg\.tar\.zst|exe|msi|dmg)$/i], + ["image", /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|tiff?|ico)$/i], + ["video", /\.(mp4|mkv|webm|mov|avi|m4v)$/i], + ["audio", /\.(mp3|flac|ogg|opus|wav|m4a|aac)$/i], + ["archive", /\.(zip|tar|gz|tgz|xz|zst|bz2|7z|rar|iso)$/i], + ["document", /\.(pdf|docx?|odt|xlsx?|ods|pptx?|odp|rtf|epub|txt|md|csv)$/i], + ["code", /\.(json|toml|ya?ml|sh|py|js|ts|html|css|xml|sql)$/i], +]; + +export function kindOf(name: string, isDir = false): Kind { + if (isDir) return "folder"; + for (const [k, re] of KINDS) if (re.test(name)) return k; + return "other"; +} + +export const KIND_TITLES: Record = { + image: "Picture", + video: "Video", + audio: "Audio", + document: "Document", + archive: "Archive", + installer: "Installer", + code: "Code", + folder: "Folder", + partial: "Downloading", + other: "File", +}; + +/** Which filter chip shows a kind. */ +export function matchesFilter(kind: Kind, filter: string): boolean { + switch (filter) { + case "images": + return kind === "image"; + case "documents": + return kind === "document"; + case "archives": + return kind === "archive"; + case "installers": + return kind === "installer"; + default: + return true; + } +} + +/** Pictures the launcher can show in the side panel. */ +export const previewable = (name: string) => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name); + +/** The extension in capitals, for the tag: "PDF", "TAR.GZ". */ +export function extTag(name: string): string | undefined { + const m = name.match(/\.(tar\.(?:gz|xz|zst|bz2)|[a-z0-9]{1,8})$/i); + return m ? m[1].toUpperCase() : undefined; +} + +// --------------------------------------------------------------------------- +// Dates + +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + +export function section(date: Date, now = new Date()): "Today" | "Yesterday" | "This Week" | "Older" { + const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return "This Week"; + return "Older"; +} + +/** "14:03", "Yesterday 09:12", "Mon 26 Oct", "3 Mar 2025". */ +export function when(date: Date, now = new Date()): string { + const time = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + const s = section(date, now); + if (s === "Today") return time; + if (s === "Yesterday") return `Yesterday ${time}`; + if (s === "This Week") return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }); + return date.toLocaleDateString( + "en-GB", + date.getFullYear() === now.getFullYear() + ? { day: "numeric", month: "short" } + : { day: "numeric", month: "short", year: "numeric" }, + ); +} + +// --------------------------------------------------------------------------- +// Files + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + 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]}`; +} + +/** `file:///home/ada/My%20File.pdf` */ +export const fileUri = (path: string) => `file://${path.split("/").map(encodeURIComponent).join("/")}`; + +export const tilde = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p); + +/** A new file name, or why it can't be one. */ +export function checkName(name: string): string | undefined { + const n = name.trim(); + if (!n) return "Type a name"; + if (n.includes("/")) return "A name can't contain “/”"; + if (n === "." || n === "..") return "Not a valid name"; + if (new TextEncoder().encode(n).length > 255) return "That name is too long"; + return undefined; +} + +/** The host of the page a file came from, for the subtitle. */ +export function hostOf(url?: string): string | undefined { + if (!url) return undefined; + try { + const u = new URL(url); + return u.protocol.startsWith("http") ? u.hostname.replace(/^www\./, "") : undefined; + } catch { + return undefined; + } +} + +/** A free name in a folder: `report.pdf` → `report (2).pdf`. */ +export function freeName(name: string, taken: (n: string) => boolean): string { + if (!taken(name)) return name; + const m = name.match(/^(.*?)(\.(?:tar\.(?:gz|xz|zst|bz2)|[^.]+))?$/)!; + const [stem, ext] = [m[1] || name, m[2] ?? ""]; + for (let i = 2; i < 1000; i++) { + const n = `${stem} (${i})${ext}`; + if (!taken(n)) return n; + } + return `${stem} (${Date.now()})${ext}`; +} + +/** + * `getfattr --absolute-names -d -m '^user\.xdg\.' `: attributes per + * file. Text values are quoted, with octal escapes (`\012`); `0x…`/`0s…` + * values are hex or base64. + */ +export function parseGetfattr(text: string): Map> { + const out = new Map>(); + let current: Record | undefined; + for (const line of text.split("\n")) { + const file = line.match(/^# file: (.*)$/); + if (file) { + current = {}; + out.set(unescapeOctal(file[1]), current); + continue; + } + const kv = line.match(/^([\w.]+)=(.*)$/); + if (!kv || !current) continue; + current[kv[1]] = decodeValue(kv[2]); + } + return out; +} + +function unescapeOctal(s: string): string { + const bytes: number[] = []; + for (let i = 0; i < s.length; i++) { + const m = s.slice(i).match(/^\\([0-7]{3})/); + if (m) { + bytes.push(parseInt(m[1], 8)); + i += 3; + } else if (s[i] === "\\" && s[i + 1] === "\\") { + bytes.push(0x5c); + i++; + } else bytes.push(...new TextEncoder().encode(s[i])); + } + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +function decodeValue(v: string): string { + if (v.startsWith('"') && v.endsWith('"')) return unescapeOctal(v.slice(1, -1).replace(/\\"/g, '"')); + if (v.startsWith("0x")) { + const hex = v.slice(2).match(/../g) ?? []; + return new TextDecoder().decode(new Uint8Array(hex.map((h) => parseInt(h, 16)))); + } + if (v.startsWith("0s")) { + try { + return new TextDecoder().decode(Uint8Array.from(atob(v.slice(2)), (c) => c.charCodeAt(0))); + } catch { + return ""; + } + } + return v; +} diff --git a/extensions/downloads/lib_test.ts b/extensions/downloads/lib_test.ts new file mode 100644 index 0000000..7a42e32 --- /dev/null +++ b/extensions/downloads/lib_test.ts @@ -0,0 +1,99 @@ +import { assertEquals } from "@std/assert"; +import { + checkName, + extTag, + fileUri, + freeName, + hostOf, + kindOf, + matchesFilter, + parseGetfattr, + parseUserDirs, + previewable, + section, + when, +} from "./lib.ts"; + +Deno.test("user-dirs.dirs", () => { + const dirs = parseUserDirs( + `# This file is written by xdg-user-dirs-update +XDG_DESKTOP_DIR="$HOME/Bureau" +XDG_DOWNLOAD_DIR="$HOME/Téléchargements" +XDG_TEMPLATES_DIR="$HOME/" +XDG_DOCUMENTS_DIR="/data/Documents" +XDG_PICTURES_DIR="$HOME/Images/" +`, + "/home/ada", + ); + assertEquals(dirs, { + DESKTOP: "/home/ada/Bureau", + DOWNLOAD: "/home/ada/Téléchargements", + DOCUMENTS: "/data/Documents", + PICTURES: "/home/ada/Images", + }); +}); + +Deno.test("kinds and filters", () => { + assertEquals(kindOf("photo.JPEG"), "image"); + assertEquals(kindOf("Invoice-2026.pdf"), "document"); + assertEquals(kindOf("backup.tar.zst"), "archive"); + assertEquals(kindOf("code_1.95_amd64.deb"), "installer"); + assertEquals(kindOf("Obsidian-1.7.4.AppImage"), "installer"); + assertEquals(kindOf("yay-12.4.2-1-x86_64.pkg.tar.zst"), "installer"); + assertEquals(kindOf("movie.mkv.crdownload"), "partial"); + assertEquals(kindOf("thing"), "other"); + assertEquals(kindOf("photos", true), "folder"); + assertEquals(matchesFilter("installer", "installers"), true); + assertEquals(matchesFilter("image", "documents"), false); + assertEquals(matchesFilter("other", "all"), true); + assertEquals(previewable("a.png"), true); + assertEquals(extTag("backup.tar.gz"), "TAR.GZ"); + assertEquals(extTag("report.PDF"), "PDF"); + assertEquals(extTag("README"), undefined); +}); + +Deno.test("sections and dates", () => { + const now = new Date(2026, 9, 28, 15, 0); // Wednesday + assertEquals(section(new Date(2026, 9, 28, 1), now), "Today"); + assertEquals(section(new Date(2026, 9, 27, 23), now), "Yesterday"); + assertEquals(section(new Date(2026, 9, 23), now), "This Week"); + assertEquals(section(new Date(2026, 9, 21), now), "Older"); + assertEquals(when(new Date(2026, 9, 28, 9, 5), now), "09:05"); + assertEquals(when(new Date(2026, 9, 27, 18, 30), now), "Yesterday 18:30"); + assertEquals(when(new Date(2026, 9, 26, 8), now), "Mon 26 Oct"); + assertEquals(when(new Date(2025, 2, 3), now), "3 Mar 2025"); +}); + +Deno.test("getfattr output", () => { + const out = `# file: /home/ada/Downloads/report final.pdf +user.xdg.origin.url="https://example.com/files/report%20final.pdf" +user.xdg.referrer.url="https://example.com/reports?id=3" + +# file: /home/ada/Downloads/caf\\303\\251.png +user.xdg.origin.url="https://cdn.example.org/caf\\303\\251.png" + +# file: /home/ada/Downloads/bin.dat +user.xdg.origin.url=0x68747470733a2f2f612e6f7267 +`; + const attrs = parseGetfattr(out); + assertEquals(attrs.get("/home/ada/Downloads/report final.pdf"), { + "user.xdg.origin.url": "https://example.com/files/report%20final.pdf", + "user.xdg.referrer.url": "https://example.com/reports?id=3", + }); + assertEquals(attrs.get("/home/ada/Downloads/café.png")?.["user.xdg.origin.url"], "https://cdn.example.org/café.png"); + assertEquals(attrs.get("/home/ada/Downloads/bin.dat")?.["user.xdg.origin.url"], "https://a.org"); +}); + +Deno.test("names, links and uris", () => { + assertEquals(checkName(""), "Type a name"); + assertEquals(checkName("a/b"), "A name can't contain “/”"); + assertEquals(checkName("ok.txt"), undefined); + assertEquals(hostOf("https://www.github.com/x"), "github.com"); + assertEquals(hostOf("data:abc"), undefined); + assertEquals(hostOf(undefined), undefined); + assertEquals(fileUri("/home/ada/My File#1.pdf"), "file:///home/ada/My%20File%231.pdf"); + const taken = new Set(["report.pdf", "report (2).pdf", "a.tar.gz"]); + assertEquals(freeName("report.pdf", (n) => taken.has(n)), "report (3).pdf"); + assertEquals(freeName("a.tar.gz", (n) => taken.has(n)), "a (2).tar.gz"); + assertEquals(freeName("new.txt", (n) => taken.has(n)), "new.txt"); +}); diff --git a/extensions/flatpak/backslash.toml b/extensions/flatpak/backslash.toml new file mode 100644 index 0000000..cd50734 --- /dev/null +++ b/extensions/flatpak/backslash.toml @@ -0,0 +1,45 @@ +[extension] +id = "flatpak" +title = "Flatpak Apps" +description = "Browse installed Flatpak apps and runtimes, see their size and permissions, run or uninstall them" +version = "1.0.0" +author = "Backslash" +icon = "bs-package" +license = "MIT" + +# flatpak lists, runs, uninstalls and resets overrides (system uninstalls ask +# through polkit). The exported icon folders give each app its own icon, and +# ~/.var/app tells whether an app keeps data. +[permissions] +run = ["flatpak"] +read = [ + "/var/lib/flatpak/exports/share/icons/hicolor", + "~/.local/share/flatpak/exports/share/icons/hicolor", + "~/.var/app", + "/etc/os-release", +] +env = ["HOME"] + +[[commands]] +name = "flatpak" +title = "Flatpak Apps" +description = "Installed apps and runtimes with their size, origin and permissions" +icon = "bs-package" +keywords = [ + "flatpak", + "flathub", + "app", + "apps", + "applications", + "logiciels", + "runtime", + "uninstall", + "désinstaller", + "supprimer", + "permissions", + "sandbox", + "bac à sable", + "flatseal", + "unused", + "inutilisé", +] diff --git a/extensions/flatpak/commands/flatpak.ts b/extensions/flatpak/commands/flatpak.ts new file mode 100644 index 0000000..7d92b12 --- /dev/null +++ b/extensions/flatpak/commands/flatpak.ts @@ -0,0 +1,340 @@ +import { + type Action, + action, + close, + type Color, + copy, + exec, + Icons, + type Item, + list, + meta, + type Metadata, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + COLUMNS, + flathubUrl, + formatBytes, + type Grant, + installMarkdown, + type Level, + osFamily, + parseList, + parsePermissions, + type Ref, + sandboxScore, + splitRuntime, + summarize, + uninstallArgs, + unusedRuntimes, +} from "../lib.ts"; + +const HOME = (() => { + try { + return Deno.env.get("HOME") ?? ""; + } catch { + return ""; + } +})(); + +const LEVEL_COLOR: Record = { high: "red", medium: "orange", low: "gray" }; +const LEVEL_TEXT: Record = { + high: "Wide access", + medium: "Some access", + low: "Well sandboxed", +}; + +async function flatpak(args: string[], signal?: AbortSignal): Promise { + try { + return await exec("flatpak", args, { signal }); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new NotInstalled(); + throw e; + } +} + +class NotInstalled extends Error {} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } +} + +/** The app's own icon from the exported icon theme, if any. */ +async function iconOf(r: Ref): Promise { + const roots = r.installation === "user" + ? [`${HOME}/.local/share/flatpak/exports/share/icons/hicolor`] + : ["/var/lib/flatpak/exports/share/icons/hicolor"]; + for (const root of roots) { + for (const p of ["128x128/apps", "scalable/apps", "256x256/apps", "64x64/apps", "512x512/apps"]) { + for (const ext of p.startsWith("scalable") ? ["svg"] : ["png"]) { + const file = `${root}/${p}/${r.id}.${ext}`; + if (await exists(file)) return file; + } + } + } + return undefined; +} + +/** Runs `fn` over the items, a few at a time. */ +async function pool(items: T[], n: number, fn: (t: T) => Promise) { + const queue = [...items]; + await Promise.all(Array.from({ length: n }, async () => { + for (let t = queue.shift(); t !== undefined; t = queue.shift()) await fn(t); + })); +} + +async function uninstall(r: Ref, deleteData = false) { + await toast(`Uninstalling ${r.name}…`); + await flatpak(uninstallArgs(r, deleteData)); + await toast(`${r.name} uninstalled`, { style: "success" }); + return refresh(); +} + +const removeUnused = action("Remove Unused Runtimes", async () => { + await toast("Removing unused runtimes…"); + const out = await flatpak(["uninstall", "--unused", "--noninteractive", "-y"]); + const nothing = /Nothing unused/i.test(out); + await toast(nothing ? "Nothing unused to remove" : "Unused runtimes removed", { style: "success" }); + return refresh(); +}, { + icon: Icons.trash, + destructive: true, + confirm: "Remove every runtime no installed app needs?", + section: "Danger", + shortcut: "u", +}); + +function grantTags(grants: Grant[]) { + return grants.slice(0, 10).map((g) => ({ text: g.text, color: LEVEL_COLOR[g.level] })); +} + +function appItem(r: Ref, icon: string | undefined, grants: Grant[] | undefined, hasData: boolean): Item { + const url = flathubUrl(r); + const level = grants ? sandboxScore(grants) : undefined; + const runtime = splitRuntime(r.runtime); + const dataDir = `${HOME}/.var/app/${r.id}`; + const metadata: Metadata[] = [ + meta.text("App ID", r.id, { icon: Icons.hash }), + ...(r.version ? [meta.text("Version", r.version, { icon: Icons.tag })] : []), + meta.tags("Source", [ + { text: r.origin || "unknown", color: /flathub/i.test(r.origin) ? "blue" : "gray" }, + { text: r.branch, color: r.branch === "stable" ? "green" : "orange" }, + { text: r.installation === "user" ? "User" : r.installation === "system" ? "System" : r.installation }, + ]), + meta.text("Runtime", `${runtime.id} ${runtime.branch ?? ""}`.trim(), { icon: Icons.layers }), + meta.text("Size", formatBytes(r.size), { icon: Icons.drive }), + ...(hasData ? [meta.text("Data", `~/.var/app/${r.id}`, { icon: Icons.folder })] : []), + meta.separator(), + ...(grants + ? [ + meta.text("Sandbox", LEVEL_TEXT[level!], { + icon: Icons.shield, + color: LEVEL_COLOR[level!] === "gray" ? "green" : LEVEL_COLOR[level!], + }), + ...(grants.length ? [meta.tags("Permissions", grantTags(grants))] : [meta.text("Permissions", "None")]), + ] + : [meta.text("Permissions", "Reading…", { icon: Icons.shield })]), + ]; + const actions: Action[] = [ + action(`Run ${r.name}`, () => { + try { + new Deno.Command("flatpak", { args: ["run", r.id], stdin: "null", stdout: "null", stderr: "null" }).spawn() + .unref(); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("flatpak was not found"); + throw e; + } + return close(); + }, { icon: Icons.play }), + ...(url ? [open(url, "Open in Flathub", { icon: Icons.globe, shortcut: "o" })] : []), + ...(hasData ? [open(dataDir, "Show Data Folder", { icon: Icons.folder, shortcut: "f" })] : []), + copy(r.id, "Copy App ID", { section: "Copy", shortcut: "c" }), + copy(`flatpak run ${r.id}`, "Copy Run Command", { section: "Copy" }), + ...(url ? [copy(url, "Copy Flathub Link", { section: "Copy" })] : []), + action("Reset Permissions", async () => { + await flatpak(["override", "--user", "--reset", r.id]); + await toast(`Permissions of ${r.name} reset to the defaults`, { style: "success" }); + return refresh(); + }, { + icon: Icons.shield, + section: "Danger", + confirm: `Forget every permission you changed for ${r.name} (Flatseal, flatpak override --user)?`, + }), + action("Uninstall", () => uninstall(r), { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Uninstall ${r.name}? Its data in ~/.var/app is kept.`, + }), + action("Uninstall and Delete Data", () => uninstall(r, true), { + icon: Icons.trash, + destructive: true, + section: "Danger", + confirm: `Uninstall ${r.name} and delete its data and settings?`, + }), + removeUnused, + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ]; + return { + id: r.ref, + title: r.name, + subtitle: r.description || r.id, + icon: icon ?? { source: Icons.package, tint: "blue" }, + section: "Apps", + keywords: [r.id, r.origin, r.branch, "app", "application", "appli"], + accessories: [ + ...(level === "high" ? [{ icon: { source: Icons.shield, tint: "red" as const }, tooltip: "Wide access" }] : []), + ...(r.branch !== "stable" ? [{ tag: r.branch, color: "orange" as const }] : []), + ...(r.installation === "user" ? [{ tag: "User", color: "gray" as const }] : []), + { text: formatBytes(r.size), tooltip: "Installed size" }, + ], + detail: { + image: icon, + stats: [ + { label: "Size", value: formatBytes(r.size) }, + ...(r.version ? [{ label: "Version", value: r.version }] : []), + ...(level ? [{ label: "Sandbox", value: LEVEL_TEXT[level], color: LEVEL_COLOR[level] }] : []), + ], + metadata, + }, + actions, + }; +} + +function runtimeItem(r: Ref, unused: boolean, usedBy: Ref[]): Item { + return { + id: r.ref, + title: r.name === r.id ? r.id : r.name, + subtitle: `${r.id} · ${r.branch}`, + icon: { source: Icons.layers, tint: unused ? "orange" : "gray" }, + section: unused ? "Unused Runtimes" : "Runtimes", + keywords: [r.id, r.branch, r.origin, "runtime", "platform", unused ? "unused inutilisé" : ""], + accessories: [ + ...(unused ? [{ tag: "Unused", color: "orange" as const }] : []), + { text: formatBytes(r.size), tooltip: "Installed size" }, + ], + detail: { + stats: [ + { label: "Size", value: formatBytes(r.size) }, + { label: "Used by", value: usedBy.length ? `${usedBy.length} app${usedBy.length === 1 ? "" : "s"}` : "—" }, + ], + metadata: [ + meta.text("ID", r.id, { icon: Icons.hash }), + meta.text("Branch", r.branch, { icon: Icons.branch }), + meta.text("Ref", r.ref, { icon: Icons.code }), + meta.tags("Source", [ + { text: r.origin || "unknown", color: /flathub/i.test(r.origin) ? "blue" : "gray" }, + { text: r.installation === "user" ? "User" : r.installation === "system" ? "System" : r.installation }, + ]), + meta.text("State", unused ? "Probably unused" : "In use", { + icon: unused ? Icons.alert : Icons.check, + color: unused ? "orange" : "green", + }), + ...(usedBy.length ? [meta.tags("Apps", usedBy.slice(0, 8).map((a) => a.name))] : []), + ], + }, + actions: [ + ...(unused ? [removeUnused] : []), + copy(r.ref, "Copy Ref", { section: "Copy", shortcut: "c" }), + action("Uninstall", () => uninstall(r), { + icon: Icons.trash, + destructive: true, + section: "Danger", + confirm: usedBy.length + ? `${usedBy.length} app${usedBy.length === 1 ? " needs" : "s need"} ${r.id}. Uninstall it anyway?` + : `Uninstall ${r.id} ${r.branch}?`, + }), + ...(unused ? [] : [removeUnused]), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter Flatpak apps and runtimes…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "apps", title: "Apps" }, + { value: "runtimes", title: "Runtimes" }, + { value: "unused", title: "Unused" }, + ], + }, + empty: { title: "Nothing here", description: "No Flatpak matches this view", icon: Icons.package }, + status: () => summary, + actions: [removeUnused, open("https://flathub.org", "Browse Flathub", { icon: Icons.globe })], + async *items({ filter, signal }) { + let apps: Ref[]; + let runtimes: Ref[]; + try { + [apps, runtimes] = await Promise.all([ + flatpak(["list", "--app", `--columns=${COLUMNS}`], signal).then((t) => parseList(t, "app")), + flatpak(["list", "--runtime", `--columns=${COLUMNS}`], signal).then((t) => parseList(t, "runtime")), + ]); + } catch (e) { + if (!(e instanceof NotInstalled)) throw e; + summary = "Flatpak isn't installed"; + const fam = osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); + yield [{ + id: "install", + title: "Install Flatpak", + subtitle: "Then get apps from Flathub", + icon: { source: Icons.download, tint: "orange" }, + detail: { markdown: installMarkdown(fam) }, + actions: [ + open("https://flathub.org/setup", "Open the Flathub Setup Guide", { icon: Icons.globe }), + action("Check Again", () => refresh(), { icon: Icons.refresh }), + ], + }]; + return; + } + const unused = unusedRuntimes(apps, runtimes); + const total = [...apps, ...runtimes].reduce((s, r) => s + r.size, 0); + const unusedSize = runtimes.filter((r) => unused.has(r.ref)).reduce((s, r) => s + r.size, 0); + summary = `${apps.length} app${apps.length === 1 ? "" : "s"} · ${runtimes.length} runtimes · ${formatBytes(total)}${ + unused.size ? ` · ${unused.size} unused (${formatBytes(unusedSize)})` : "" + }`; + updateCommandMetadata({ subtitle: `${apps.length} apps · ${formatBytes(total)}` }).catch(() => {}); + + const f = filter ?? "all"; + const shownApps = f === "all" || f === "apps" ? [...apps].sort((a, b) => a.name.localeCompare(b.name)) : []; + const shownRuntimes = (f === "all" || f === "runtimes" || f === "unused" ? runtimes : []) + .filter((r) => f !== "unused" || unused.has(r.ref)) + .sort((a, b) => Number(unused.has(b.ref)) - Number(unused.has(a.ref)) || b.size - a.size); + const usedBy = (r: Ref) => + apps.filter((a) => splitRuntime(a.runtime).id === r.id && splitRuntime(a.runtime).branch === r.branch); + + const icons = new Map(); + const data = new Map(); + await Promise.all(shownApps.map(async (a) => { + icons.set(a.ref, await iconOf(a)); + data.set(a.ref, await exists(`${HOME}/.var/app/${a.id}`)); + })); + const grants = new Map(); + const render = () => [ + ...shownApps.map((a) => appItem(a, icons.get(a.ref), grants.get(a.ref), data.get(a.ref) ?? false)), + ...shownRuntimes.map((r) => runtimeItem(r, unused.has(r.ref), usedBy(r))), + ]; + yield render(); + if (!shownApps.length) return; + // Permissions take one call per app: show the list first, then fill them in. + await pool(shownApps, 6, async (a) => { + if (signal.aborted) return; + const text = await flatpak(["info", "--show-permissions", a.ref], signal).catch(() => ""); + grants.set(a.ref, summarize(parsePermissions(text))); + }); + if (!signal.aborted) yield render(); + }, +}); diff --git a/extensions/flatpak/lib.ts b/extensions/flatpak/lib.ts new file mode 100644 index 0000000..f5116a8 --- /dev/null +++ b/extensions/flatpak/lib.ts @@ -0,0 +1,239 @@ +// Parsing flatpak's output and summing up what apps may do. Pure, tested in lib_test.ts. + +export const COLUMNS = "application,name,description,version,branch,arch,origin,installation,ref,runtime,size"; + +export interface Ref { + id: string; + name: string; + description: string; + version: string; + branch: string; + arch: string; + origin: string; + /** "user", "system" or a custom installation's name. */ + installation: string; + ref: string; + /** For apps: `org.gnome.Platform/x86_64/47`. */ + runtime: string; + size: number; + kind: "app" | "runtime"; +} + +/** "258.8 MB", "1.2 GB", "980 bytes", "12,5 kB" (GLib's decimal units) → bytes. */ +export function parseSize(text: string): number { + const m = text.trim().replace(",", ".").match(/^([\d.]+)\s*(bytes?|[kKMGTP]i?B)?/); + if (!m) return 0; + const n = parseFloat(m[1]); + const unit = (m[2] ?? "B").toLowerCase(); + const pow = unit.startsWith("byte") || unit === "b" ? 0 : "kmgtp".indexOf(unit[0]) + 1; + const base = unit.includes("i") ? 1024 : 1000; + return Math.round(n * base ** pow); +} + +/** `flatpak list --app|--runtime --columns=…` (tab separated). */ +export function parseList(text: string, kind: Ref["kind"]): Ref[] { + return text.split("\n").filter((l) => l.trim()).map((line) => { + const [id, name, description, version, branch, arch, origin, installation, ref, runtime, size] = line.split("\t"); + return { + id: id ?? "", + name: name || id || "", + description: description ?? "", + version: version ?? "", + branch: branch ?? "", + arch: arch ?? "", + origin: origin ?? "", + installation: installation ?? "", + ref: ref ?? "", + runtime: runtime ?? "", + size: parseSize(size ?? ""), + kind, + }; + }).filter((r) => r.id); +} + +/** `org.gnome.Platform/x86_64/47` → id and branch. */ +export function splitRuntime(runtime: string): { id: string; arch?: string; branch?: string } { + const [id, arch, branch] = runtime.split("/"); + return { id, arch, branch }; +} + +/** + * Runtimes no installed app uses, directly or as an extension of a used + * runtime (`org.freedesktop.Platform.GL.default` goes with + * `org.freedesktop.Platform`, `org.mozilla.firefox.Locale` with Firefox). + * A good guess; `flatpak uninstall --unused` has the final say. + */ +export function unusedRuntimes(apps: Ref[], runtimes: Ref[]): Set { + const used = new Set(apps.map((a) => { + const r = splitRuntime(a.runtime); + return `${r.id}/${r.branch ?? ""}`; + })); + const bases = [...apps.map((a) => a.id), ...runtimes.filter((r) => used.has(`${r.id}/${r.branch}`)).map((r) => r.id)]; + const direct = (r: Ref) => used.has(`${r.id}/${r.branch}`); + const extension = (r: Ref) => bases.some((b) => r.id.startsWith(`${b}.`)); + // An extension of a runtime branch nothing uses goes with it (org.gnome.Platform.Locale of an old branch). + const orphan = (r: Ref) => + runtimes.some((base) => r.id.startsWith(`${base.id}.`) && base.branch === r.branch && !direct(base)); + const out = new Set(); + for (const r of runtimes) { + if (direct(r)) continue; + if (extension(r) && !orphan(r)) continue; + out.add(r.ref); + } + return out; +} + +// --------------------------------------------------------------------------- +// Permissions + +export interface Permissions { + shared: string[]; + sockets: string[]; + devices: string[]; + features: string[]; + filesystems: string[]; + talk: string[]; + own: string[]; + system: string[]; +} + +/** `flatpak info --show-permissions ` (a keyfile). */ +export function parsePermissions(text: string): Permissions { + const p: Permissions = { + shared: [], + sockets: [], + devices: [], + features: [], + filesystems: [], + talk: [], + own: [], + system: [], + }; + let group = ""; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const g = line.match(/^\[(.+)\]$/); + if (g) { + group = g[1]; + continue; + } + const kv = line.match(/^([^=]+)=(.*)$/); + if (!kv) continue; + const [key, value] = [kv[1].trim(), kv[2].trim()]; + const values = value.split(";").filter(Boolean); + if (group === "Context" && key in p) (p as unknown as Record)[key].push(...values); + if (group === "Session Bus Policy") (value === "own" ? p.own : p.talk).push(key); + if (group === "System Bus Policy") p.system.push(key); + } + return p; +} + +export type Level = "high" | "medium" | "low"; +export interface Grant { + text: string; + level: Level; +} + +const FS_NAMES: Record = { + host: "All files", + "host-os": "System files", + "host-etc": "System settings (/etc)", + home: "Home folder", + "xdg-download": "Downloads", + "xdg-documents": "Documents", + "xdg-pictures": "Pictures", + "xdg-music": "Music", + "xdg-videos": "Videos", + "xdg-desktop": "Desktop", + "xdg-config": "App settings", + "xdg-run/pipewire-0": "PipeWire", +}; + +/** The permissions worth knowing about, worst first. Negated ones (`!home`) are left out. */ +export function summarize(p: Permissions): Grant[] { + const out: Grant[] = []; + const add = (text: string, level: Level) => out.push({ text, level }); + for (const f of p.filesystems) { + if (f.startsWith("!")) continue; + const [path, mode] = f.split(":"); + const name = FS_NAMES[path] ?? path.replace(/^~\//, "~/"); + const level: Level = ["host", "host-os", "home", "host-etc"].includes(path) ? "high" : "medium"; + add(`${name}${mode === "ro" ? " (read-only)" : mode === "create" ? " (create)" : ""}`, level); + } + if (p.devices.includes("all")) add("All devices", "high"); + else if (p.devices.length) add(`Devices: ${p.devices.join(", ")}`, p.devices.includes("dri") ? "low" : "medium"); + if (p.sockets.includes("x11") && !p.sockets.includes("fallback-x11")) add("X11 (can see other windows)", "medium"); + if (p.sockets.includes("system-bus")) add("Whole system bus", "high"); + if (p.sockets.includes("session-bus")) add("Whole session bus", "high"); + if (p.sockets.includes("ssh-auth")) add("SSH agent", "medium"); + if (p.sockets.includes("pulseaudio")) add("Sound", "low"); + if (p.sockets.includes("cups")) add("Printing", "low"); + if (p.sockets.includes("pcsc")) add("Smart cards", "medium"); + if (p.shared.includes("network")) add("Network", "low"); + if (p.features.includes("devel")) add("Debugging (ptrace)", "medium"); + if (p.talk.includes("org.freedesktop.Flatpak")) add("Run commands outside the sandbox", "high"); + if (p.talk.some((n) => n === "org.freedesktop.secrets")) add("Passwords (Secret Service)", "medium"); + if (p.talk.includes("org.freedesktop.Notifications")) add("Notifications", "low"); + const rank: Record = { high: 0, medium: 1, low: 2 }; + return out.sort((a, b) => rank[a.level] - rank[b.level]); +} + +export const sandboxScore = (grants: Grant[]): Level => + grants.some((g) => g.level === "high") ? "high" : grants.some((g) => g.level === "medium") ? "medium" : "low"; + +// --------------------------------------------------------------------------- +// Formatting + +export function formatBytes(n: number): string { + if (n < 1000) return `${n} B`; + const units = ["kB", "MB", "GB", "TB"]; + let v = n / 1000; + let i = 0; + while (v >= 1000 && i < units.length - 1) { + v /= 1000; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** The Flathub page for apps that came from Flathub. */ +export const flathubUrl = (r: Ref) => /flathub/i.test(r.origin) ? `https://flathub.org/apps/${r.id}` : undefined; + +/** `flatpak uninstall` arguments for one ref in its own installation. */ +export function uninstallArgs(r: Ref, deleteData = false): string[] { + const where = r.installation === "user" ? ["--user"] : r.installation === "system" ? ["--system"] : [ + `--installation=${r.installation}`, + ]; + return ["uninstall", ...where, "--noninteractive", "-y", ...(deleteData ? ["--delete-data"] : []), r.ref]; +} + +// --------------------------------------------------------------------------- +// 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: ["Arch", "sudo pacman -S flatpak"], + debian: ["Debian, Ubuntu", "sudo apt install flatpak"], + fedora: ["Fedora", "sudo dnf install flatpak"], + suse: ["openSUSE", "sudo zypper install flatpak"], +}; + +export function installMarkdown(family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + return [ + "Flatpak isn't installed. Install it, then add Flathub:", + ...families.map((f) => `**${MANAGERS[f][0]}**\n\n\`${MANAGERS[f][1]}\``), + "`flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo`", + ].join("\n\n"); +} diff --git a/extensions/flatpak/lib_test.ts b/extensions/flatpak/lib_test.ts new file mode 100644 index 0000000..233defd --- /dev/null +++ b/extensions/flatpak/lib_test.ts @@ -0,0 +1,133 @@ +import { assertEquals } from "@std/assert"; +import { + flathubUrl, + formatBytes, + parseList, + parsePermissions, + parseSize, + sandboxScore, + splitRuntime, + summarize, + uninstallArgs, + unusedRuntimes, +} from "./lib.ts"; + +// `flatpak list --app --columns=application,name,description,version,branch,arch,origin,installation,ref,runtime,size` (flatpak 1.14). +export const APPS = [ + "org.mozilla.firefox\tFirefox\tFast, Private & Safe Web Browser\t131.0.3\tstable\tx86_64\tflathub\tsystem\tapp/org.mozilla.firefox/x86_64/stable\torg.freedesktop.Platform/x86_64/24.08\t258.8 MB", + "com.github.tchx84.Flatseal\tFlatseal\tManage Flatpak permissions\t2.2.0\tstable\tx86_64\tflathub\tuser\tapp/com.github.tchx84.Flatseal/x86_64/stable\torg.gnome.Platform/x86_64/47\t1.9 MB", + "org.gnome.Builder\tBuilder\tAn IDE for GNOME\t47.0\tbeta\tx86_64\tgnome-nightly\tsystem\tapp/org.gnome.Builder/x86_64/beta\torg.gnome.Sdk/x86_64/47\t60,1 MB", +].join("\n"); + +export const RUNTIMES = [ + "org.freedesktop.Platform\tFreedesktop Platform\t\t24.08.5\t24.08\tx86_64\tflathub\tsystem\truntime/org.freedesktop.Platform/x86_64/24.08\t\t607.5 MB", + "org.freedesktop.Platform.GL.default\tMesa\t\t24.2.4\t24.08\tx86_64\tflathub\tsystem\truntime/org.freedesktop.Platform.GL.default/x86_64/24.08\t\t420.3 MB", + "org.freedesktop.Platform.openh264\topenh264\t\t2.4.1\t2.4.1\tx86_64\tflathub\tsystem\truntime/org.freedesktop.Platform.openh264/x86_64/2.4.1\t\t1.0 MB", + "org.gnome.Platform\tGNOME Application Platform version 47\t\t\t47\tx86_64\tflathub\tsystem\truntime/org.gnome.Platform/x86_64/47\t\t1.1 GB", + "org.gnome.Platform\tGNOME Application Platform version 45\t\t\t45\tx86_64\tflathub\tsystem\truntime/org.gnome.Platform/x86_64/45\t\t981.2 MB", + "org.gnome.Platform.Locale\tTranslations\t\t\t45\tx86_64\tflathub\tsystem\truntime/org.gnome.Platform.Locale/x86_64/45\t\t18.0 kB", + "org.gnome.Sdk\tGNOME Software Development Kit version 47\t\t\t47\tx86_64\tflathub\tsystem\truntime/org.gnome.Sdk/x86_64/47\t\t1.6 GB", + "org.mozilla.firefox.Locale\tTranslations\t\t\tstable\tx86_64\tflathub\tsystem\truntime/org.mozilla.firefox.Locale/x86_64/stable\t\t38.2 MB", + "org.kde.Platform\tKDE Application Platform\t\t\t5.15-23.08\tx86_64\tflathub\tuser\truntime/org.kde.Platform/x86_64/5.15-23.08\t\t351.0 MB", +].join("\n"); + +Deno.test("sizes as flatpak prints them", () => { + assertEquals(parseSize("258.8 MB"), 258_800_000); + assertEquals(parseSize("1.1 GB"), 1_100_000_000); + assertEquals(parseSize("60,1 MB"), 60_100_000); + assertEquals(parseSize("980 bytes"), 980); + assertEquals(parseSize("18.0 kB"), 18_000); + assertEquals(parseSize("2.0 GiB"), 2 * 1024 ** 3); + assertEquals(parseSize(""), 0); + assertEquals(formatBytes(258_800_000), "259 MB"); + assertEquals(formatBytes(1_900_000), "1.9 MB"); +}); + +Deno.test("list columns", () => { + const apps = parseList(APPS, "app"); + assertEquals(apps.length, 3); + assertEquals(apps[1], { + id: "com.github.tchx84.Flatseal", + name: "Flatseal", + description: "Manage Flatpak permissions", + version: "2.2.0", + branch: "stable", + arch: "x86_64", + origin: "flathub", + installation: "user", + ref: "app/com.github.tchx84.Flatseal/x86_64/stable", + runtime: "org.gnome.Platform/x86_64/47", + size: 1_900_000, + kind: "app", + }); + assertEquals(splitRuntime(apps[0].runtime), { id: "org.freedesktop.Platform", arch: "x86_64", branch: "24.08" }); + assertEquals(parseList("\n", "app"), []); +}); + +Deno.test("unused runtimes", () => { + const unused = unusedRuntimes(parseList(APPS, "app"), parseList(RUNTIMES, "runtime")); + assertEquals([...unused].sort(), [ + "runtime/org.gnome.Platform.Locale/x86_64/45", + "runtime/org.gnome.Platform/x86_64/45", + "runtime/org.kde.Platform/x86_64/5.15-23.08", + ]); +}); + +const FIREFOX_PERMISSIONS = `[Context] +shared=network;ipc; +sockets=x11;wayland;pulseaudio;pcsc;cups; +devices=all; +filesystems=xdg-download;/run/.heim_org.h5l.kcm-socket;xdg-run/speech-dispatcher:ro;!home; + +[Session Bus Policy] +org.freedesktop.FileManager1=talk +org.freedesktop.Notifications=talk +org.mpris.MediaPlayer2.firefox.*=own +org.freedesktop.ScreenSaver=talk + +[System Bus Policy] +org.freedesktop.NetworkManager=talk + +[Environment] +GTK_PATH=/app/lib/gtkmodules +`; + +Deno.test("permissions summary, worst first", () => { + const p = parsePermissions(FIREFOX_PERMISSIONS); + assertEquals(p.shared, ["network", "ipc"]); + assertEquals(p.own, ["org.mpris.MediaPlayer2.firefox.*"]); + assertEquals(p.system, ["org.freedesktop.NetworkManager"]); + const grants = summarize(p); + assertEquals(grants.map((g) => g.text), [ + "All devices", + "Downloads", + "/run/.heim_org.h5l.kcm-socket", + "xdg-run/speech-dispatcher (read-only)", + "X11 (can see other windows)", + "Smart cards", + "Sound", + "Printing", + "Network", + "Notifications", + ]); + assertEquals(sandboxScore(grants), "high"); + const calc = summarize(parsePermissions("[Context]\nsockets=wayland;fallback-x11;\ndevices=dri;\n")); + assertEquals(calc, [{ text: "Devices: dri", level: "low" }]); + assertEquals(sandboxScore(calc), "low"); + assertEquals(sandboxScore(summarize(parsePermissions("[Context]\nfilesystems=home;\n"))), "high"); +}); + +Deno.test("links and uninstall arguments", () => { + const [firefox, flatseal, builder] = parseList(APPS, "app"); + assertEquals(flathubUrl(firefox), "https://flathub.org/apps/org.mozilla.firefox"); + assertEquals(flathubUrl(builder), undefined); + assertEquals(uninstallArgs(flatseal), [ + "uninstall", + "--user", + "--noninteractive", + "-y", + "app/com.github.tchx84.Flatseal/x86_64/stable", + ]); + assertEquals(uninstallArgs(firefox, true).slice(1, 2), ["--system"]); + assertEquals(uninstallArgs(firefox, true).includes("--delete-data"), true); +}); diff --git a/extensions/keyboard-layouts/backslash.toml b/extensions/keyboard-layouts/backslash.toml new file mode 100644 index 0000000..5bcb793 --- /dev/null +++ b/extensions/keyboard-layouts/backslash.toml @@ -0,0 +1,61 @@ +[extension] +id = "keyboard-layouts" +title = "Keyboard Layouts" +description = "See the current keyboard layout and switch between layouts and input methods" +version = "1.0.0" +author = "Backslash" +icon = "bs-keyboard" +license = "MIT" + +# The desktop's own tools, the first that answers: gsettings (GNOME, plus gdbus to +# GNOME Shell), gdbus to org.kde.keyboard (KDE Plasma), hyprctl (Hyprland), +# swaymsg (Sway), setxkbmap and xkb-switch (X11). ibus and fcitx5-remote switch +# input methods. The xkb rules files give layout names; the fcitx5 profile lists +# its input methods. +[permissions] +run = ["gsettings", "gdbus", "hyprctl", "swaymsg", "setxkbmap", "xkb-switch", "ibus", "fcitx5-remote"] +read = [ + "/usr/share/X11/xkb/rules/evdev.lst", + "/usr/share/X11/xkb/rules/base.lst", + "~/.config/fcitx5/profile", + "/etc/os-release", +] +env = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "HOME", +] + +[[commands]] +name = "layouts" +title = "Keyboard Layouts" +description = "Your layouts and input methods, the current one marked" +icon = "bs-keyboard" +keywords = [ + "keyboard", + "clavier", + "layout", + "disposition", + "disposition du clavier", + "azerty", + "qwerty", + "language", + "langue", + "input method", + "méthode de saisie", + "ibus", + "fcitx", + "xkb", +] + +[[commands]] +name = "next-layout" +title = "Next Keyboard Layout" +description = "Switch to the next layout" +icon = "bs-keyboard" +mode = "no-view" +keywords = ["keyboard", "clavier", "layout", "disposition", "switch", "changer", "suivant", "next", "cycle"] diff --git a/extensions/keyboard-layouts/commands/layouts.ts b/extensions/keyboard-layouts/commands/layouts.ts new file mode 100644 index 0000000..d13c11e --- /dev/null +++ b/extensions/keyboard-layouts/commands/layouts.ts @@ -0,0 +1,156 @@ +import { + type Action, + action, + close, + copy, + Icons, + type Item, + list, + meta, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { BACKEND_NAMES, displayTag, type Family, installMarkdown, type Layout } from "../lib.ts"; +import { family, load, type State, switchTo } from "../system.ts"; + +const KIND_NAMES = { xkb: "Keyboard layout", ibus: "IBus input method", fcitx5: "Fcitx 5 input method" }; + +function layoutItem(l: Layout, s: State, section: string, position: number, count: number): Item { + const isMethod = l.kind !== "xkb" && !(l.kind === "fcitx5" && /^keyboard-/.test(l.key)); + const actions: Action[] = []; + if (!l.active) { + actions.push(action(`Switch to ${l.name}`, async () => { + await switchTo(s, l); + updateCommandMetadata({ subtitle: l.name }).catch(() => {}); + return close(`Keyboard: ${l.name}`); + }, { icon: Icons.keyboard })); + actions.push(action("Switch and Stay", async () => { + await switchTo(s, l); + await toast(`Switched to ${l.name}`, { style: "success" }); + return refresh(); + }, { icon: Icons.refresh, shortcut: "s" })); + } + actions.push( + copy(l.variant ? `${l.code}(${l.variant})` : l.code, "Copy Layout Code", { + section: "Copy", + shortcut: "c", + }), + copy(l.name, "Copy Name", { section: "Copy" }), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ); + return { + id: `${l.kind}:${l.key}:${l.code}:${l.variant ?? ""}`, + title: l.name, + subtitle: l.active ? "Current" : undefined, + icon: { source: isMethod ? Icons.font : Icons.keyboard, tint: l.active ? "green" : isMethod ? "purple" : "blue" }, + section, + keywords: [l.code, l.variant ?? "", l.key, "layout", "disposition", "clavier", "keyboard"], + accessories: [ + ...(l.active ? [{ icon: Icons.check, color: "green" as const, tooltip: "Current layout" }] : []), + { tag: displayTag(l), color: l.active ? "green" : "gray" }, + ], + detail: { + stats: [ + { label: "Code", value: displayTag(l), color: l.active ? "green" : undefined }, + { label: "Position", value: `${position} of ${count}` }, + ], + metadata: [ + meta.text("Name", l.name, { icon: isMethod ? Icons.font : Icons.keyboard }), + meta.text("State", l.active ? "Current" : "Available", { + icon: l.active ? Icons.check : Icons.dottedCircle, + color: l.active ? "green" : "gray", + }), + meta.separator(), + meta.text("Type", KIND_NAMES[l.kind]), + meta.text(isMethod ? "Engine" : "Layout", l.kind === "xkb" || !isMethod ? l.code : l.key, { icon: Icons.code }), + ...(l.variant ? [meta.text("Variant", l.variant)] : []), + ...(s.backend ? [meta.text("Managed by", BACKEND_NAMES[s.backend], { icon: Icons.settings })] : []), + ], + }, + actions, + }; +} + +function setupItems(s: State, fam?: Family): Item[] { + if (s.backend) { + return [{ + id: "single", + title: "Only one layout is set up", + subtitle: s.backend ? BACKEND_NAMES[s.backend] : undefined, + icon: { source: Icons.info, tint: "blue" }, + detail: { + markdown: { + gnome: "Add layouts in **Settings → Keyboard → Input Sources**.", + kde: "Add layouts in **System Settings → Keyboard → Layouts**.", + hyprland: + "List several layouts in your Hyprland config:\n\n```\ninput {\n kb_layout = us,fr\n kb_variant = ,azerty\n}\n```", + sway: "List several layouts in your Sway config:\n\n```\ninput type:keyboard {\n xkb_layout us,fr\n}\n```", + x11: "Set several layouts, for example:\n\n`setxkbmap -layout us,fr -option grp:alt_shift_toggle`", + }[s.backend], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + const x11 = s.wanted.includes("x11"); + if (s.error) { + return [{ + id: "error", + title: "Couldn't read the keyboard layouts", + subtitle: s.wanted.map((b) => BACKEND_NAMES[b]).join(", "), + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: "The desktop's keyboard tool answered with an error:\n\n```\n" + s.error + "\n```" }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh }), copy(s.error, "Copy Error")], + }]; + } + return [{ + id: "setup", + title: x11 ? "Install setxkbmap" : "No keyboard layout backend", + subtitle: "GNOME, KDE Plasma, Hyprland, Sway or X11", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: x11 + ? installMarkdown( + "On X11, Backslash reads layouts with **setxkbmap**, and switches with **xkb-switch** when it is installed.", + ["setxkbmap", "xkb-switch"], + fam, + ) + : s.wanted.includes("kde") + ? installMarkdown("On KDE Plasma, Backslash talks to the keyboard daemon with **gdbus**.", ["gdbus"], fam) + : "Backslash couldn't reach this desktop's keyboard settings. Supported: GNOME (gsettings), KDE Plasma (D-Bus), Hyprland (hyprctl), Sway (swaymsg) and X11 (setxkbmap).", + metadata: [meta.tags("Supported", ["GNOME", "KDE Plasma", "Hyprland", "Sway", "X11", "IBus", "Fcitx 5"])], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; +} + +let summary = ""; + +export default list({ + placeholder: "Filter keyboard layouts and input methods…", + empty: { title: "No layout matches", icon: Icons.keyboard }, + status: () => summary, + async items() { + const s = await load(); + const all = [...s.layouts, ...s.methods]; + const current = s.methods.find((m) => m.active && s.framework === "fcitx5") ?? s.layouts.find((l) => l.active) ?? + s.methods.find((m) => m.active); + updateCommandMetadata({ subtitle: current ? current.name : null }).catch(() => {}); + summary = [ + current ? `Current: ${current.name}` : undefined, + `${s.layouts.length} layout${s.layouts.length === 1 ? "" : "s"}`, + s.methods.length ? `${s.methods.length} in ${s.framework === "fcitx5" ? "Fcitx 5" : "IBus"}` : undefined, + s.backend ? BACKEND_NAMES[s.backend] : undefined, + ].filter(Boolean).join(" · "); + const items = [ + ...s.layouts.map((l, i) => + layoutItem(l, s, l.kind === "ibus" ? "Input Methods" : "Layouts", i + 1, s.layouts.length) + ), + ...s.methods.map((m, i) => + layoutItem(m, s, s.framework === "fcitx5" ? "Fcitx 5" : "IBus", i + 1, s.methods.length) + ), + ]; + if (all.length > 1) return items; + return [...items, ...setupItems(s, await family())]; + }, +}); diff --git a/extensions/keyboard-layouts/commands/next-layout.ts b/extensions/keyboard-layouts/commands/next-layout.ts new file mode 100644 index 0000000..04a0c36 --- /dev/null +++ b/extensions/keyboard-layouts/commands/next-layout.ts @@ -0,0 +1,15 @@ +import { run, updateCommandMetadata } from "@backslash/api"; +import { nextLayout } from "../lib.ts"; +import { load, switchTo } from "../system.ts"; + +// Cycles through the desktop's layouts; with Fcitx 5 running, through its input methods. +export default run(async () => { + const s = await load(); + const pool = s.framework === "fcitx5" && s.methods.length > 1 ? s.methods : s.layouts; + if (!s.backend && !pool.length) throw new Error("No keyboard layout backend found: open Keyboard Layouts"); + const next = nextLayout(pool); + if (!next) throw new Error("Only one keyboard layout is set up"); + await switchTo(s, next); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return `Keyboard: ${next.name}`; +}); diff --git a/extensions/keyboard-layouts/lib.ts b/extensions/keyboard-layouts/lib.ts new file mode 100644 index 0000000..28f0deb --- /dev/null +++ b/extensions/keyboard-layouts/lib.ts @@ -0,0 +1,382 @@ +// Keyboard layouts and input methods from every desktop's own tools. Pure, +// tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Session + +export type Backend = "gnome" | "kde" | "hyprland" | "sway" | "x11"; +export type Env = Partial>; + +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "HOME", +] as const; + +/** Backends worth trying here, best first; each is confirmed by asking it. */ +export function candidates(env: Env): Backend[] { + const desktop = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const out: Backend[] = []; + if (env.HYPRLAND_INSTANCE_SIGNATURE || desktop.includes("hyprland")) out.push("hyprland"); + if (env.SWAYSOCK || desktop.includes("sway")) out.push("sway"); + if (desktop.includes("kde")) out.push("kde"); + if (desktop.some((d) => ["gnome", "ubuntu", "unity", "pop", "budgie", "cinnamon"].includes(d))) out.push("gnome"); + if (env.DISPLAY && !wayland) out.push("x11"); + return out; +} + +export const BACKEND_NAMES: Record = { + gnome: "GNOME", + kde: "KDE Plasma", + hyprland: "Hyprland", + sway: "Sway", + x11: "X11", +}; + +// --------------------------------------------------------------------------- +// Layouts + +export type Kind = "xkb" | "ibus" | "fcitx5"; + +export interface Layout { + /** What the backend switches to: an index, a GNOME source, an engine name. */ + key: string; + kind: Kind; + /** XKB layout code (`fr`), or the engine name for input methods. */ + code: string; + variant?: string; + name: string; + active: boolean; +} + +/** `fr`, `fr+azerty`, `fr(azerty)` → code and variant. */ +export function splitLayout(spec: string): { code: string; variant?: string } { + const m = spec.match(/^([^+(]+)(?:\+([^)]+)|\(([^)]+)\))?$/); + if (!m) return { code: spec }; + const variant = m[2] ?? m[3]; + return variant ? { code: m[1], variant } : { code: m[1] }; +} + +/** "FR", "US", "DE·NEO": the short tag shown next to the layout. */ +export function tag(l: Pick): string { + if (l.kind !== "xkb") return l.kind === "ibus" ? "IBus" : "Fcitx"; + const base = l.code.toUpperCase(); + if (!l.variant) return base; + const v = l.variant.replace(/[-_].*/, ""); + return `${base}·${v.length > 6 ? v.slice(0, 5).toUpperCase() : v.toUpperCase()}`; +} + +export interface XkbNames { + layouts: Map; + /** Keyed by `code+variant`. */ + variants: Map; +} + +/** `/usr/share/X11/xkb/rules/evdev.lst` → layout and variant descriptions. */ +export function parseXkbList(text: string): XkbNames { + const layouts = new Map(); + const variants = new Map(); + let section = ""; + for (const line of text.split("\n")) { + const head = line.match(/^!\s*(\w+)/); + if (head) { + section = head[1]; + continue; + } + const m = line.match(/^\s+(\S+)\s+(.+)$/); + if (!m) continue; + if (section === "layout") layouts.set(m[1], m[2].trim()); + else if (section === "variant") { + const v = m[2].match(/^(\S+):\s*(.+)$/); + if (v) variants.set(`${v[1]}+${m[1]}`, v[2].trim()); + } + } + return { layouts, variants }; +} + +export function layoutName(names: XkbNames | undefined, code: string, variant?: string): string { + return (variant ? names?.variants.get(`${code}+${variant}`) : undefined) ?? names?.layouts.get(code) ?? + (variant ? `${code.toUpperCase()} (${variant})` : code.toUpperCase()); +} + +/** The layout and variant code of a description ("French (AZERTY)"), for backends that only give names. */ +export function codeOfName(names: XkbNames | undefined, name: string): { code: string; variant?: string } | undefined { + if (!names) return undefined; + for (const [key, desc] of names.variants) { + if (desc === name) { + const [code, variant] = key.split("+"); + return { code, variant }; + } + } + for (const [code, desc] of names.layouts) if (desc === name) return { code }; + return undefined; +} + +// --- GNOME ----------------------------------------------------------------- + +/** `gsettings get org.gnome.desktop.input-sources sources`: `[('xkb', 'us'), ('ibus', 'anthy')]`. */ +export function parseGnomeSources(text: string): [string, string][] { + return [...text.matchAll(/\(\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g)].map((m) => [m[1], m[2]]); +} + +export const gnomeSourcesValue = (sources: [string, string][]) => + sources.length ? `[${sources.map(([t, id]) => `('${t}', '${id}')`).join(", ")}]` : "@a(ss) []"; + +/** GNOME's input sources, the current one being the first of `mru-sources` still configured. */ +export function gnomeLayouts(sourcesText: string, mruText: string, names?: XkbNames, ibusNames?: Map) { + const sources = parseGnomeSources(sourcesText); + const mru = parseGnomeSources(mruText); + const current = mru.find(([t, id]) => sources.some(([t2, id2]) => t === t2 && id === id2)) ?? sources[0]; + return sources.map(([type, id], i): Layout => { + const active = !!current && current[0] === type && current[1] === id; + if (type === "xkb") { + const { code, variant } = splitLayout(id); + return { key: String(i), kind: "xkb", code, variant, name: layoutName(names, code, variant), active }; + } + return { key: String(i), kind: "ibus", code: id, name: ibusNames?.get(id) ?? engineName(id), active }; + }); +} + +// --- KDE ------------------------------------------------------------------- + +/** `gdbus … org.kde.KeyboardLayouts.getLayoutsList`: `([('us', '', 'English (US)'), ('fr', 'azerty', 'French')],)`. */ +export function kdeLayouts(listOut: string, currentOut: string): Layout[] { + const current = Number(currentOut.replace(/u?int\d+/g, "").match(/(\d+)/)?.[1] ?? 0); + return [...listOut.matchAll(/\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'((?:[^'\\]|\\.)*)'\s*\)/g)].map((m, i) => ({ + key: String(i), + kind: "xkb", + code: m[1], + variant: m[2] || undefined, + name: m[3].replace(/\\(.)/g, "$1") || m[1].toUpperCase(), + active: i === current, + })); +} + +// --- Hyprland -------------------------------------------------------------- + +interface HyprKeyboard { + name: string; + layout: string; + variant: string; + active_keymap: string; + main?: boolean; + active_layout_index?: number; +} + +/** `hyprctl devices -j`: the main keyboard's layouts; the active one from its keymap name. */ +export function hyprLayouts(json: string, names?: XkbNames): { layouts: Layout[]; keyboards: string[] } { + const keyboards = ((JSON.parse(json) as { keyboards?: HyprKeyboard[] }).keyboards ?? []) + .filter((k) => !/virtual|power-button|video-bus|sleep-button/i.test(k.name)); + const main = keyboards.find((k) => k.main) ?? keyboards[0]; + if (!main) return { layouts: [], keyboards: [] }; + const codes = main.layout.split(",").map((s) => s.trim()); + const variants = (main.variant ?? "").split(",").map((s) => s.trim()); + const layouts = codes.filter(Boolean).map((code, i): Layout => { + const variant = variants[i] || undefined; + return { key: String(i), kind: "xkb", code, variant, name: layoutName(names, code, variant), active: false }; + }); + const index = typeof main.active_layout_index === "number" + ? main.active_layout_index + : layouts.findIndex((l) => l.name === main.active_keymap); + const fallback = layouts.findIndex((l) => names?.layouts.get(l.code) === main.active_keymap); + const active = index >= 0 ? index : fallback >= 0 ? fallback : 0; + if (layouts[active]) layouts[active].active = true; + return { layouts, keyboards: keyboards.map((k) => k.name) }; +} + +// --- Sway ------------------------------------------------------------------ + +/** `swaymsg -t get_inputs -r`: the first keyboard's layout names and active index. */ +export function swayLayouts(json: string, names?: XkbNames): Layout[] { + const inputs = JSON.parse(json) as { + type: string; + xkb_layout_names?: string[]; + xkb_active_layout_index?: number; + }[]; + const kb = inputs.find((i) => i.type === "keyboard" && (i.xkb_layout_names?.length ?? 0) > 1) ?? + inputs.find((i) => i.type === "keyboard"); + if (!kb?.xkb_layout_names) return []; + return kb.xkb_layout_names.map((name, i) => { + const c = codeOfName(names, name); + return { + key: String(i), + kind: "xkb", + code: c?.code ?? name.slice(0, 2).toLowerCase(), + variant: c?.variant, + name, + active: i === (kb.xkb_active_layout_index ?? 0), + }; + }); +} + +// --- X11 ------------------------------------------------------------------- + +export interface XkbQuery { + layouts: string[]; + variants: string[]; + options?: string; +} + +/** `setxkbmap -query`. */ +export function parseSetxkbmap(text: string): XkbQuery { + const get = (k: string) => text.match(new RegExp(`^${k}:\\s*(.*)$`, "m"))?.[1]?.trim() ?? ""; + const layouts = get("layout").split(",").map((s) => s.trim()); + const variants = get("variant").split(",").map((s) => s.trim()); + return { layouts: layouts.filter(Boolean), variants, options: get("options") || undefined }; +} + +/** X11 layouts; `current` is `xkb-switch -p` (`fr(azerty)`), else the first group is active. */ +export function x11Layouts(q: XkbQuery, current?: string, names?: XkbNames): Layout[] { + const cur = current ? splitLayout(current.trim()) : undefined; + let found = false; + const out = q.layouts.map((code, i): Layout => { + const variant = q.variants[i] || undefined; + const active = !found && !!cur && cur.code === code && (cur.variant ?? "") === (variant ?? ""); + if (active) found = true; + return { key: String(i), kind: "xkb", code, variant, name: layoutName(names, code, variant), active }; + }); + if (!found && out[0]) out[0].active = true; + return out; +} + +/** `setxkbmap` arguments putting layout `i` first, so it becomes the active group. */ +export function setxkbmapArgs(q: XkbQuery, i: number): string[] { + const order = [i, ...q.layouts.map((_, j) => j).filter((j) => j !== i)]; + const args = ["-layout", order.map((j) => q.layouts[j]).join(",")]; + if (q.variants.some(Boolean)) args.push("-variant", order.map((j) => q.variants[j] ?? "").join(",")); + return args; +} + +/** The `xkb-switch -s` argument for a layout. */ +export const xkbSwitchName = (l: Layout) => l.variant ? `${l.code}(${l.variant})` : l.code; + +// --------------------------------------------------------------------------- +// Input methods + +/** "mozc-jp" → "Mozc", "libpinyin" → "Pinyin": a readable name when the engine gives none. */ +export function engineName(id: string): string { + const known: Record = { + "mozc-jp": "Mozc (Japanese)", + anthy: "Anthy (Japanese)", + libpinyin: "Intelligent Pinyin", + pinyin: "Pinyin", + rime: "Rime", + hangul: "Hangul (Korean)", + chewing: "Chewing (Chinese)", + "m17n:hi:inscript": "Hindi (InScript)", + "unikey": "Unikey (Vietnamese)", + }; + if (known[id]) return known[id]; + const base = id.replace(/^keyboard-/, "").replace(/^xkb:/, "").replace(/::.*$/, ""); + return base.charAt(0).toUpperCase() + base.slice(1); +} + +/** `ibus list-engine`: ` anthy - Anthy` lines → names. */ +export function parseIbusEngines(text: string): Map { + const out = new Map(); + for (const m of text.matchAll(/^\s+(\S+)\s+-\s+(.+)$/gm)) out.set(m[1], m[2].trim()); + return out; +} + +/** `gsettings get org.freedesktop.ibus.general preload-engines`: `['xkb:us::eng', 'anthy']`. */ +export function parseStringList(text: string): string[] { + return [...text.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => m[1]); +} + +/** `~/.config/fcitx5/profile`: the input methods of the first group, in order. */ +export function parseFcitxProfile(text: string): { name: string; layout?: string }[] { + const out: { name: string; layout?: string }[] = []; + let group = ""; + let item: { name?: string; layout?: string } | undefined; + const flush = () => { + if (item?.name) out.push({ name: item.name, layout: item.layout || undefined }); + item = undefined; + }; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const sec = line.match(/^\[(.+)\]$/); + if (sec) { + flush(); + group = sec[1]; + if (/^Groups\/0\/Items\/\d+$/.test(group)) item = {}; + continue; + } + if (!item) continue; + const kv = line.match(/^(\w+)=(.*)$/); + if (kv?.[1] === "Name") item.name = kv[2]; + if (kv?.[1] === "Layout") item.layout = kv[2]; + } + flush(); + return out; +} + +/** Input methods as layouts: `keyboard-fr-azerty` is an XKB layout under fcitx5. */ +export function fcitxLayouts(items: { name: string }[], current: string, names?: XkbNames): Layout[] { + return items.map((it) => { + const kb = it.name.match(/^keyboard-([a-z]+)(?:-(.+))?$/); + if (kb) { + return { + key: it.name, + kind: "fcitx5", + code: kb[1], + variant: kb[2], + name: layoutName(names, kb[1], kb[2]), + active: it.name === current, + }; + } + return { key: it.name, kind: "fcitx5", code: it.name, name: engineName(it.name), active: it.name === current }; + }); +} + +/** Tag for an fcitx5 keyboard layout: it is still an XKB layout to the user. */ +export const displayTag = (l: Layout) => + l.kind === "fcitx5" && /^keyboard-/.test(l.key) ? tag({ ...l, kind: "xkb" }) : tag(l); + +/** The layout after the active one, cycling. */ +export function nextLayout(layouts: T[]): T | undefined { + if (layouts.length < 2) return undefined; + const i = layouts.findIndex((l) => l.active); + return layouts[(i + 1) % layouts.length]; +} + +// --------------------------------------------------------------------------- +// 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: ["Arch", "sudo pacman -S"], + debian: ["Debian, Ubuntu", "sudo apt install"], + fedora: ["Fedora", "sudo dnf install"], + suse: ["openSUSE", "sudo zypper install"], +}; + +const PACKAGES: Record>> = { + setxkbmap: { debian: "x11-xkb-utils", fedora: "setxkbmap", arch: "xorg-setxkbmap", suse: "setxkbmap" }, + "xkb-switch": { debian: "xkb-switch", arch: "xkb-switch", fedora: "xkb-switch", suse: "xkb-switch" }, + gdbus: { debian: "libglib2.0-bin", arch: "glib2", fedora: "glib2", suse: "glib2-tools" }, +}; + +export function installMarkdown(what: string, programs: string[], family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + const lines = families.map((f) => + `**${MANAGERS[f][0]}**\n\n\`${MANAGERS[f][1]} ${programs.map((p) => PACKAGES[p]?.[f] ?? p).join(" ")}\`` + ); + return `${what}\n\n${lines.join("\n\n")}`; +} diff --git a/extensions/keyboard-layouts/lib_test.ts b/extensions/keyboard-layouts/lib_test.ts new file mode 100644 index 0000000..45d0086 --- /dev/null +++ b/extensions/keyboard-layouts/lib_test.ts @@ -0,0 +1,198 @@ +import { assertEquals } from "@std/assert"; +import { + candidates, + codeOfName, + displayTag, + engineName, + fcitxLayouts, + gnomeLayouts, + gnomeSourcesValue, + hyprLayouts, + kdeLayouts, + layoutName, + nextLayout, + parseFcitxProfile, + parseGnomeSources, + parseIbusEngines, + parseSetxkbmap, + parseStringList, + parseXkbList, + setxkbmapArgs, + splitLayout, + swayLayouts, + tag, + x11Layouts, + xkbSwitchName, +} from "./lib.ts"; + +// An excerpt of /usr/share/X11/xkb/rules/evdev.lst (xkeyboard-config 2.41). +const LST = `! model + pc105 Generic 105-key PC + +! layout + us English (US) + fr French + de German + ru Russian + +! variant + intl us: English (US, intl., with dead keys) + azerty fr: French (AZERTY) + bepo fr: French (BEPO) + neo de: German (Neo 2) + +! option + grp Switching to another layout +`; +const NAMES = parseXkbList(LST); + +Deno.test("xkb names", () => { + assertEquals(layoutName(NAMES, "fr"), "French"); + assertEquals(layoutName(NAMES, "fr", "azerty"), "French (AZERTY)"); + assertEquals(layoutName(NAMES, "xx", "yy"), "XX (yy)"); + assertEquals(codeOfName(NAMES, "French (BEPO)"), { code: "fr", variant: "bepo" }); + assertEquals(codeOfName(NAMES, "Russian"), { code: "ru" }); + assertEquals(NAMES.layouts.has("pc105"), false); +}); + +Deno.test("layout specs and tags", () => { + assertEquals(splitLayout("fr+azerty"), { code: "fr", variant: "azerty" }); + assertEquals(splitLayout("fr(bepo)"), { code: "fr", variant: "bepo" }); + assertEquals(splitLayout("us"), { code: "us" }); + assertEquals(tag({ kind: "xkb", code: "fr" }), "FR"); + assertEquals(tag({ kind: "xkb", code: "us", variant: "intl" }), "US·INTL"); + assertEquals(tag({ kind: "xkb", code: "us", variant: "alt-intl" }), "US·ALT"); + assertEquals(tag({ kind: "ibus", code: "anthy" }), "IBus"); + assertEquals( + xkbSwitchName({ key: "1", kind: "xkb", code: "fr", variant: "azerty", name: "", active: false }), + "fr(azerty)", + ); +}); + +Deno.test("backends tried", () => { + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME", WAYLAND_DISPLAY: "wayland-0" }), ["gnome"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "KDE", XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }), ["kde", "x11"]); + assertEquals(candidates({ HYPRLAND_INSTANCE_SIGNATURE: "x", WAYLAND_DISPLAY: "w" }), ["hyprland"]); + assertEquals(candidates({ DISPLAY: ":0" }), ["x11"]); +}); + +Deno.test("gnome input sources, current from mru-sources", () => { + const sources = "[('xkb', 'us'), ('xkb', 'fr+azerty'), ('ibus', 'mozc-jp')]"; + const mru = "[('xkb', 'de'), ('xkb', 'fr+azerty'), ('xkb', 'us')]"; + const ls = gnomeLayouts(sources, mru, NAMES, new Map([["mozc-jp", "Mozc"]])); + assertEquals(ls.map((l) => [l.key, l.kind, l.code, l.variant, l.name, l.active]), [ + ["0", "xkb", "us", undefined, "English (US)", false], + ["1", "xkb", "fr", "azerty", "French (AZERTY)", true], + ["2", "ibus", "mozc-jp", undefined, "Mozc", false], + ]); + assertEquals(gnomeLayouts(sources, "@a(ss) []", NAMES)[0].active, true); + assertEquals(parseGnomeSources("@a(ss) []"), []); + assertEquals(gnomeSourcesValue([["xkb", "us"], ["ibus", "anthy"]]), "[('xkb', 'us'), ('ibus', 'anthy')]"); + assertEquals(gnomeSourcesValue([]), "@a(ss) []"); +}); + +Deno.test("kde layouts over gdbus", () => { + const ls = kdeLayouts("([('us', '', 'English (US)'), ('fr', 'azerty', 'French (AZERTY)')],)", "(uint32 1,)"); + assertEquals(ls.map((l) => [l.code, l.variant, l.name, l.active]), [ + ["us", undefined, "English (US)", false], + ["fr", "azerty", "French (AZERTY)", true], + ]); +}); + +Deno.test("hyprland main keyboard", () => { + const json = JSON.stringify({ + mice: [], + keyboards: [ + { name: "power-button", layout: "us", variant: "", active_keymap: "English (US)", main: false }, + { + name: "at-translated-set-2-keyboard", + layout: "us,fr", + variant: ",azerty", + active_keymap: "French (AZERTY)", + main: true, + }, + { name: "keychron-k2", layout: "us,fr", variant: ",azerty", active_keymap: "French (AZERTY)", main: false }, + ], + }); + const { layouts, keyboards } = hyprLayouts(json, NAMES); + assertEquals(layouts.map((l) => [l.code, l.variant, l.active]), [["us", undefined, false], ["fr", "azerty", true]]); + assertEquals(keyboards, ["at-translated-set-2-keyboard", "keychron-k2"]); + const indexed = hyprLayouts( + JSON.stringify({ + keyboards: [{ name: "kb", layout: "us,de", variant: "", active_keymap: "x", main: true, active_layout_index: 1 }], + }), + NAMES, + ); + assertEquals(indexed.layouts.map((l) => l.active), [false, true]); +}); + +Deno.test("sway inputs", () => { + const json = JSON.stringify([ + { identifier: "1:1:mouse", type: "pointer" }, + { identifier: "power", type: "keyboard", xkb_layout_names: ["English (US)"], xkb_active_layout_index: 0 }, + { + identifier: "1:1:AT_Translated_Set_2_keyboard", + type: "keyboard", + xkb_layout_names: ["English (US)", "French (BEPO)"], + xkb_active_layout_index: 1, + }, + ]); + assertEquals(swayLayouts(json, NAMES).map((l) => [l.key, l.code, l.variant, l.active]), [ + ["0", "us", undefined, false], + ["1", "fr", "bepo", true], + ]); +}); + +Deno.test("x11 setxkbmap and xkb-switch", () => { + const q = parseSetxkbmap( + "rules: evdev\nmodel: pc105\nlayout: us,fr,de\nvariant: ,azerty,\noptions: grp:alt_shift_toggle\n", + ); + assertEquals(q, { layouts: ["us", "fr", "de"], variants: ["", "azerty", ""], options: "grp:alt_shift_toggle" }); + assertEquals(x11Layouts(q, "fr(azerty)", NAMES).map((l) => l.active), [false, true, false]); + assertEquals(x11Layouts(q, undefined, NAMES).map((l) => l.active), [true, false, false]); + assertEquals(setxkbmapArgs(q, 1), ["-layout", "fr,us,de", "-variant", "azerty,,"]); + assertEquals(setxkbmapArgs(parseSetxkbmap("layout: us,ru\n"), 1), ["-layout", "ru,us"]); +}); + +Deno.test("input methods", () => { + const profile = `[Groups/0] +Name=Default +Default Layout=us +DefaultIM=mozc + +[Groups/0/Items/0] +Name=keyboard-us +Layout= + +[Groups/0/Items/1] +Name=keyboard-fr-azerty +Layout= + +[Groups/0/Items/2] +Name=mozc +Layout= + +[GroupOrder] +0=Default +`; + const items = parseFcitxProfile(profile); + assertEquals(items.map((i) => i.name), ["keyboard-us", "keyboard-fr-azerty", "mozc"]); + const ls = fcitxLayouts(items, "mozc", NAMES); + assertEquals(ls.map((l) => [l.code, l.variant, l.name, l.active]), [ + ["us", undefined, "English (US)", false], + ["fr", "azerty", "French (AZERTY)", false], + ["mozc", undefined, "Mozc", true], + ]); + assertEquals(displayTag(ls[1]), "FR·AZERTY"); + assertEquals(displayTag(ls[2]), "Fcitx"); + assertEquals(parseStringList("['xkb:us::eng', 'anthy']"), ["xkb:us::eng", "anthy"]); + assertEquals(parseIbusEngines("language: Japanese\n anthy - Anthy\n mozc-jp - Mozc\n").get("mozc-jp"), "Mozc"); + assertEquals(engineName("libpinyin"), "Intelligent Pinyin"); +}); + +Deno.test("next layout cycles", () => { + const ls = [{ active: false, n: 1 }, { active: true, n: 2 }, { active: false, n: 3 }]; + assertEquals(nextLayout(ls)?.n, 3); + assertEquals(nextLayout([{ active: false, n: 1 }, { active: true, n: 2 }])?.n, 1); + assertEquals(nextLayout([{ active: true }]), undefined); +}); diff --git a/extensions/keyboard-layouts/system.ts b/extensions/keyboard-layouts/system.ts new file mode 100644 index 0000000..b912a79 --- /dev/null +++ b/extensions/keyboard-layouts/system.ts @@ -0,0 +1,261 @@ +// Reading and switching layouts with the desktop's own tools. + +import { exec } from "@backslash/api"; +import { + type Backend, + candidates, + engineName, + type Env, + fcitxLayouts, + gnomeLayouts, + gnomeSourcesValue, + hyprLayouts, + kdeLayouts, + type Layout, + layoutName, + osFamily, + parseFcitxProfile, + parseGnomeSources, + parseIbusEngines, + parseSetxkbmap, + parseStringList, + parseXkbList, + SESSION_ENV, + setxkbmapArgs, + splitLayout, + swayLayouts, + x11Layouts, + type XkbNames, + xkbSwitchName, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key) || undefined; + } catch { + return undefined; + } +}; + +export const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +const HOME = env("HOME") ?? ""; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const quiet = (p: Promise) => p.catch(() => undefined); + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +let xkbNames: XkbNames | undefined; +async function names(): Promise { + if (xkbNames) return xkbNames; + for (const f of ["/usr/share/X11/xkb/rules/evdev.lst", "/usr/share/X11/xkb/rules/base.lst"]) { + const text = await Deno.readTextFile(f).catch(() => ""); + if (text) return (xkbNames = parseXkbList(text)); + } + return undefined; +} + +const KDE = ["--dest", "org.kde.keyboard", "--object-path", "/Layouts"]; +const kdeCall = (method: string, ...args: string[]) => + exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + ...KDE, + "--method", + `org.kde.KeyboardLayouts.${method}`, + ...args, + ]); + +const gsettings = (...args: string[]) => exec("gsettings", args); +const SCHEMA = "org.gnome.desktop.input-sources"; + +export interface State { + backend?: Backend; + wanted: Backend[]; + layouts: Layout[]; + /** fcitx5 or IBus engines, outside the desktop's own list. */ + methods: Layout[]; + /** Which input method framework is running, if any. */ + framework?: "ibus" | "fcitx5"; + /** Hyprland: keyboard names, to switch each when "all" isn't understood. */ + keyboards?: string[]; + /** X11: whether xkb-switch is there to read and set the group. */ + xkbSwitch?: boolean; + /** Why an installed backend didn't answer. */ + error?: string; +} + +let lastError: string | undefined; + +async function read(b: Backend, n?: XkbNames): Promise | undefined> { + try { + switch (b) { + case "gnome": { + const [sources, mru] = await Promise.all([ + gsettings("get", SCHEMA, "sources"), + gsettings("get", SCHEMA, "mru-sources").catch(() => "[]"), + ]); + const parsed = parseGnomeSources(sources); + if (!parsed.length) { + // No source configured: GNOME uses the system layout. + return { layouts: [] }; + } + const ibus = parsed.some(([t]) => t === "ibus") + ? parseIbusEngines(await exec("ibus", ["list-engine"], { signal: AbortSignal.timeout(3000) }).catch(() => "")) + : undefined; + return { layouts: gnomeLayouts(sources, mru, n, ibus) }; + } + case "kde": { + const [list, current] = await Promise.all([kdeCall("getLayoutsList"), kdeCall("getLayout")]); + return { layouts: kdeLayouts(list, current) }; + } + case "hyprland": { + const { layouts, keyboards } = hyprLayouts(await exec("hyprctl", ["devices", "-j"]), n); + return { layouts, keyboards }; + } + case "sway": + return { layouts: swayLayouts(await exec("swaymsg", ["-t", "get_inputs", "-r"]), n) }; + case "x11": { + const q = parseSetxkbmap(await exec("setxkbmap", ["-query"])); + const current = await quiet(exec("xkb-switch", ["-p"])); + return { layouts: x11Layouts(q, current, n), xkbSwitch: current !== undefined }; + } + } + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) lastError = e instanceof Error ? e.message : String(e); + return undefined; + } +} + +/** fcitx5 (its own list) or IBus (outside GNOME, which lists engines with the layouts). */ +async function inputMethods(backend: Backend | undefined, n?: XkbNames): Promise> { + const state = await quiet(exec("fcitx5-remote", [])); + if (state !== undefined && state !== "0") { + const current = (await quiet(exec("fcitx5-remote", ["-n"]))) ?? ""; + const profile = await Deno.readTextFile(`${HOME}/.config/fcitx5/profile`).catch(() => ""); + const items = parseFcitxProfile(profile); + return { framework: "fcitx5", methods: fcitxLayouts(items.length ? items : [{ name: current }], current, n) }; + } + if (backend === "gnome") return { methods: [] }; + const engine = await quiet(exec("ibus", ["engine"], { signal: AbortSignal.timeout(2000) })); + if (engine === undefined) return { methods: [] }; + const preload = parseStringList( + (await quiet(gsettings("get", "org.freedesktop.ibus.general", "preload-engines"))) ?? "", + ); + const engines = preload.length ? preload : [engine]; + return { + framework: "ibus", + methods: engines.map((id) => { + const xkb = id.match(/^xkb:([^:]+):([^:]*):/); + return { + key: id, + kind: "ibus" as const, + code: xkb ? xkb[1] : id, + variant: xkb?.[2] || undefined, + name: xkb ? layoutName(n, xkb[1], xkb[2] || undefined) : engineName(id), + active: id === engine, + }; + }), + }; +} + +export async function load(): Promise { + const wanted = candidates(sessionEnv()); + const n = await names(); + let found: State | undefined; + for (const b of wanted) { + const s = await read(b, n); + if (s) { + found = { backend: b, wanted, layouts: s.layouts ?? [], methods: [], ...s } as State; + break; + } + } + const state: State = found ?? { wanted, layouts: [], methods: [], error: lastError }; + Object.assign(state, await inputMethods(state.backend, n)); + return state; +} + +// --------------------------------------------------------------------------- +// Switching + +/** GNOME has no public API to pick an input source; Eval works only in unsafe mode. */ +async function gnomeSwitch(index: number) { + const ok = await quiet(exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + "--dest", + "org.gnome.Shell", + "--object-path", + "/org/gnome/Shell", + "--method", + "org.gnome.Shell.Eval", + `imports.ui.status.keyboard.getInputSourceManager().inputSources[${index}].activate(true)`, + ])); + if (ok?.startsWith("(true")) return; + // GNOME Shell keeps its most-recently-used order when the list changes, and + // activates the first one: leave only the chosen source for a moment, then + // put the list back. The chosen one stays first, so it stays active. + const text = await gsettings("get", SCHEMA, "sources"); + const sources = parseGnomeSources(text); + const chosen = sources[index]; + if (!chosen) throw new Error("This input source is gone"); + await gsettings("set", SCHEMA, "sources", gnomeSourcesValue([chosen])); + await sleep(350); + await gsettings("set", SCHEMA, "sources", text); + const mru = parseGnomeSources(await gsettings("get", SCHEMA, "mru-sources").catch(() => "[]")); + const rest = [...sources, ...mru].filter(([t, id]) => !(t === chosen[0] && id === chosen[1])); + const unique = rest.filter(([t, id], i) => rest.findIndex(([t2, id2]) => t === t2 && id === id2) === i); + await gsettings("set", SCHEMA, "mru-sources", gnomeSourcesValue([chosen, ...unique])).catch(() => {}); +} + +export async function switchTo(s: State, l: Layout): Promise { + if (l.kind === "fcitx5") { + await exec("fcitx5-remote", ["-s", l.key]); + return; + } + if (l.kind === "ibus" && s.backend !== "gnome") { + await exec("ibus", ["engine", l.key]); + return; + } + const i = Number(l.key); + switch (s.backend) { + case "gnome": + return await gnomeSwitch(i); + case "kde": { + const out = await kdeCall("setLayout", String(i)); + if (out.includes("false")) throw new Error("KDE refused to switch layout"); + return; + } + case "hyprland": + try { + await exec("hyprctl", ["switchxkblayout", "all", String(i)]); + } catch { + // Older Hyprland: one keyboard at a time. + for (const k of s.keyboards ?? []) await exec("hyprctl", ["switchxkblayout", k, String(i)]).catch(() => {}); + } + return; + case "sway": + await exec("swaymsg", ["input", "type:keyboard", "xkb_switch_layout", String(i)]); + return; + case "x11": { + if (s.xkbSwitch) { + await exec("xkb-switch", ["-s", xkbSwitchName(l)]); + return; + } + const q = parseSetxkbmap(await exec("setxkbmap", ["-query"])); + const at = q.layouts.findIndex((c, j) => c === l.code && (q.variants[j] || undefined) === l.variant); + await exec("setxkbmap", setxkbmapArgs(q, at >= 0 ? at : i)); + return; + } + default: + throw new Error("No keyboard layout backend found"); + } +} + +export { splitLayout }; diff --git a/extensions/trash/backslash.toml b/extensions/trash/backslash.toml new file mode 100644 index 0000000..1d84525 --- /dev/null +++ b/extensions/trash/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "trash" +title = "Trash" +description = "See what is in the trash, preview it, restore items or delete them for good" +version = "1.0.0" +author = "Backslash" +icon = "bs-trash" +license = "MIT" + +# The trash is read from its .trashinfo files (original path, deletion date). +# Restoring moves an item back with mv (mkdir recreates a missing folder), so +# the extension itself never writes outside the trash. du sizes folders, gio +# empties the trash on every drive. +[permissions] +run = ["gio", "du", "mv", "mkdir"] +# The home folder is read to check whether the original location still exists. +read = ["~"] +write = ["~/.local/share/Trash"] +env = ["HOME", "XDG_DATA_HOME"] + +[[commands]] +name = "trash" +title = "Trash" +description = "Items in the trash by deletion day: preview, restore, delete for good" +icon = "bs-trash" +keywords = [ + "trash", + "corbeille", + "bin", + "recycle", + "poubelle", + "deleted", + "supprimé", + "restore", + "restaurer", + "empty trash", + "vider la corbeille", +] diff --git a/extensions/trash/commands/trash.ts b/extensions/trash/commands/trash.ts new file mode 100644 index 0000000..99ad0a9 --- /dev/null +++ b/extensions/trash/commands/trash.ts @@ -0,0 +1,295 @@ +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + list, + meta, + open, + openExternal, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + basename, + daySection, + dirname, + formatBytes, + type Kind, + kindOf, + looksLikeText, + parseDirectorySizes, + parseTrashInfo, + previewable, + restoredName, + summary, + textual, + tilde, + when, +} from "../lib.ts"; + +const HOME = (() => { + try { + return Deno.env.get("HOME") ?? ""; + } catch { + return ""; + } +})(); +const DATA = (() => { + try { + return Deno.env.get("XDG_DATA_HOME") || `${HOME}/.local/share`; + } catch { + return `${HOME}/.local/share`; + } +})(); +const TRASH = `${DATA}/Trash`; +const t = (p: string) => tilde(p, HOME); + +interface Trashed { + /** The name in Trash/files. */ + name: string; + file: string; + original: string; + deleted?: Date; + isDir: boolean; + size: number; + kind: Kind; + /** Entries inside, for folders. */ + count?: number; +} + +const ICONS: Record = { + folder: { icon: Icons.folder, tint: "blue" }, + image: { icon: Icons.image, tint: "purple" }, + video: { icon: Icons.film, tint: "pink" }, + audio: { icon: Icons.music, tint: "pink" }, + document: { icon: Icons.file, tint: "orange" }, + archive: { icon: Icons.archive, tint: "teal" }, + code: { icon: Icons.code, tint: "green" }, + text: { icon: Icons.text, tint: "gray" }, + file: { icon: Icons.file, tint: "gray" }, +}; + +async function exists(p: string) { + try { + await Deno.lstat(p); + return true; + } catch { + return false; + } +} + +async function folderSize(path: string): Promise { + const out = await exec("du", ["-s", "-b", path], { signal: AbortSignal.timeout(10000) }).catch(() => ""); + return Number(out.split("\t")[0]) || 0; +} + +async function load(): Promise { + const sizes = parseDirectorySizes(await Deno.readTextFile(`${TRASH}/directorysizes`).catch(() => "")); + const out: Trashed[] = []; + try { + for await (const e of Deno.readDir(`${TRASH}/info`)) { + if (!e.name.endsWith(".trashinfo")) continue; + const name = e.name.slice(0, -".trashinfo".length); + const info = parseTrashInfo(await Deno.readTextFile(`${TRASH}/info/${e.name}`).catch(() => "")); + const file = `${TRASH}/files/${name}`; + const st = await Deno.lstat(file).catch(() => undefined); + if (!info || !st) continue; + const isDir = st.isDirectory; + out.push({ + name, + file, + original: info.path.startsWith("/") ? info.path : `${HOME}/${info.path}`, + deleted: info.deleted ?? st.mtime ?? undefined, + isDir, + size: isDir ? sizes.get(name) ?? -1 : st.size, + kind: kindOf(name, isDir), + }); + } + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + await Promise.all( + out.filter((i) => i.isDir).map(async (i) => { + if (i.size < 0) i.size = await folderSize(i.file); + let n = 0; + for await (const _ of Deno.readDir(i.file)) if (++n > 999) break; + i.count = n; + }), + ); + return out.sort((a, b) => (b.deleted?.getTime() ?? 0) - (a.deleted?.getTime() ?? 0)); +} + +async function preview(i: Trashed): Promise { + if (i.isDir) { + const names: string[] = []; + for await (const e of Deno.readDir(i.file)) { + names.push(`${e.isDirectory ? "▸" : "·"} ${e.name}${e.isDirectory ? "/" : ""}`); + if (names.length >= 12) break; + } + return names.length ? "```\n" + names.join("\n") + "\n```" : "_Empty folder_"; + } + if (!textual(i.kind) && i.kind !== "file") return undefined; + if (i.size > 5_000_000) return undefined; + const f = await Deno.open(i.file).catch(() => undefined); + if (!f) return undefined; + try { + const buf = new Uint8Array(1500); + const n = (await f.read(buf)) ?? 0; + const head = buf.subarray(0, n); + if (!looksLikeText(head)) return undefined; + const text = new TextDecoder().decode(head).replace(/```/g, "ˋˋˋ"); + return "```\n" + text + (n === buf.length ? "\n…" : "") + "\n```"; + } finally { + f.close(); + } +} + +async function restore(i: Trashed): Promise { + let dest = i.original; + if (await exists(dest)) dest = restoredName(dest); + if (await exists(dest)) throw new Error(`${t(i.original)} already exists`); + await exec("mkdir", ["-p", dirname(dest)]); + await exec("mv", ["-n", "-T", i.file, dest]); + if (await exists(i.file)) throw new Error("The item couldn't be moved back"); + await Deno.remove(`${TRASH}/info/${i.name}.trashinfo`).catch(() => {}); + return dest; +} + +async function deleteForever(i: Trashed) { + await Deno.remove(i.file, { recursive: true }); + await Deno.remove(`${TRASH}/info/${i.name}.trashinfo`).catch(() => {}); +} + +async function emptyTrash() { + try { + await exec("gio", ["trash", "--empty"]); + return; + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + // Without gio: the home trash only. + for (const dir of ["files", "info"]) { + for await (const e of Deno.readDir(`${TRASH}/${dir}`)) { + await Deno.remove(`${TRASH}/${dir}/${e.name}`, { recursive: true }).catch(() => {}); + } + } + await Deno.remove(`${TRASH}/directorysizes`).catch(() => {}); +} + +function emptyAction(count: number, bytes: number): Action { + return action("Empty Trash", async () => { + await emptyTrash(); + updateCommandMetadata({ subtitle: "Empty" }).catch(() => {}); + await toast(`Trash emptied, ${formatBytes(bytes)} freed`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Delete ${count} item${count === 1 ? "" : "s"} (${formatBytes(bytes)}) for good?`, + }); +} + +async function item(i: Trashed, count: number, total: number): Promise { + const { icon, tint } = ICONS[i.kind]; + const where = dirname(i.original); + const md = await preview(i).catch(() => undefined); + const gone = !(await exists(where)); + return { + id: i.name, + title: basename(i.original), + subtitle: t(where), + icon: { source: icon, tint }, + section: daySection(i.deleted), + keywords: [i.original, i.kind, i.name], + accessories: [ + ...(i.isDir && i.count !== undefined + ? [{ text: `${i.count} item${i.count === 1 ? "" : "s"}`, color: "gray" as const }] + : []), + { text: formatBytes(i.size) }, + { text: when(i.deleted), color: "gray" as const, tooltip: "Deleted" }, + ], + detail: { + image: previewable(i.name) ? i.file : undefined, + stats: [ + { label: "Size", value: formatBytes(i.size) }, + ...(i.isDir && i.count !== undefined ? [{ label: "Items", value: i.count > 999 ? "999+" : i.count }] : []), + ], + markdown: md, + metadata: [ + meta.text("Original location", t(where), { icon: Icons.folder }), + ...(gone + ? [meta.text("Note", "That folder is gone: restoring recreates it", { icon: Icons.info, color: "orange" })] + : []), + meta.text("Deleted", i.deleted ? i.deleted.toLocaleString("en-GB") : "Unknown", { icon: Icons.clock }), + meta.tags("Kind", [{ text: i.kind, color: tint }]), + meta.separator(), + meta.progress("Of the trash", total ? i.size / total : 0), + ], + }, + actions: [ + action("Restore", async () => { + const dest = await restore(i); + await toast(`Restored to ${t(dest)}`, { style: "success" }); + return refresh(); + }, { icon: Icons.history }), + open(i.file, i.isDir ? "Open Folder" : "Open", { icon: Icons.external, shortcut: "o" }), + action("Restore and Show", async () => { + const dest = await restore(i); + await toast(`Restored to ${t(dest)}`, { style: "success" }); + await openExternal(dirname(dest)); + return close(); + }, { icon: Icons.folder, shortcut: "Return" }), + copy(i.original, "Copy Original Path", { section: "Copy", shortcut: "c" }), + copy(i.file, "Copy Path in Trash", { section: "Copy" }), + action("Delete Forever", async () => { + await deleteForever(i); + await toast(`${basename(i.original)} deleted`, { style: "success" }); + return refresh(); + }, { + icon: Icons.cross, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Delete ${basename(i.original)} (${formatBytes(i.size)}) for good? This can't be undone.`, + }), + emptyAction(count, total), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ], + }; +} + +let status = ""; + +export default list({ + placeholder: "Filter the trash by name or original folder…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "files", title: "Files" }, + { value: "folders", title: "Folders" }, + { value: "images", title: "Pictures & Videos" }, + ], + }, + empty: { title: "The trash is empty", description: "Deleted files land here until you empty it", icon: Icons.trash }, + status: () => status, + actions: [open("trash:///", "Open Trash in File Manager", { icon: Icons.folder })], + async items({ filter }) { + const all = await load(); + const total = all.reduce((s, i) => s + Math.max(0, i.size), 0); + status = summary(all.length, total) + + (all.length ? ` · oldest ${daySection(all.at(-1)?.deleted).toLowerCase()}` : ""); + updateCommandMetadata({ subtitle: summary(all.length, total) }).catch(() => {}); + const f = filter ?? "all"; + const shown = all.filter((i) => + f === "all" || (f === "folders" ? i.isDir : f === "files" ? !i.isDir : i.kind === "image" || i.kind === "video") + ); + return await Promise.all(shown.map((i) => item(i, all.length, total))); + }, +}); diff --git a/extensions/trash/lib.ts b/extensions/trash/lib.ts new file mode 100644 index 0000000..d59a2d1 --- /dev/null +++ b/extensions/trash/lib.ts @@ -0,0 +1,132 @@ +// The freedesktop.org trash: parsing .trashinfo files and sorting items by +// deletion day. Pure, tested in lib_test.ts. + +export interface TrashInfo { + /** Where the item came from, decoded. */ + path: string; + /** Local time, as written by the file manager. */ + deleted?: Date; +} + +/** A `.trashinfo` file: `Path=` is percent-encoded, `DeletionDate=` local time `YYYY-MM-DDThh:mm:ss`. */ +export function parseTrashInfo(text: string): TrashInfo | undefined { + if (!/^\[Trash Info\]/m.test(text)) return undefined; + const path = text.match(/^Path=(.*)$/m)?.[1]?.trim(); + if (!path) return undefined; + const date = text.match(/^DeletionDate=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/m); + return { + path: decodePath(path), + deleted: date ? new Date(+date[1], +date[2] - 1, +date[3], +date[4], +date[5], +date[6]) : undefined, + }; +} + +export function decodePath(p: string): string { + try { + return decodeURIComponent(p); + } catch { + return p; + } +} + +/** `~/.local/share/Trash/directorysizes`: ` ` per line. */ +export function parseDirectorySizes(text: string): Map { + const out = new Map(); + for (const line of text.split("\n")) { + const m = line.match(/^(\d+) \d+ (.+)$/); + if (m) out.set(decodePath(m[2]), Number(m[1])); + } + return out; +} + +// --------------------------------------------------------------------------- +// Kinds + +export type Kind = "folder" | "image" | "video" | "audio" | "document" | "archive" | "code" | "text" | "file"; + +const EXT: Record, RegExp> = { + image: /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|tiff?)$/i, + video: /\.(mp4|mkv|webm|mov|avi|m4v)$/i, + audio: /\.(mp3|flac|ogg|opus|wav|m4a|aac)$/i, + document: /\.(pdf|docx?|odt|xlsx?|ods|pptx?|odp|rtf|epub)$/i, + archive: /\.(zip|tar|gz|tgz|xz|zst|bz2|7z|rar|deb|rpm|appimage|iso)$/i, + code: /\.(ts|js|py|rs|go|c|h|cpp|java|rb|sh|json|toml|ya?ml|html|css)$/i, + text: /\.(txt|md|log|csv|ini|conf)$/i, +}; + +export function kindOf(name: string, isDir: boolean): Kind { + if (isDir) return "folder"; + for (const [k, re] of Object.entries(EXT)) if (re.test(name)) return k as Kind; + return "file"; +} + +/** Pictures the launcher can show in the side panel. */ +export const previewable = (name: string) => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name); +export const textual = (k: Kind) => k === "text" || k === "code"; + +/** Whether bytes look like text: no NUL, mostly printable. */ +export function looksLikeText(bytes: Uint8Array): boolean { + if (!bytes.length) return true; + let odd = 0; + for (const b of bytes) { + if (b === 0) return false; + if (b < 9 || (b > 13 && b < 32)) odd++; + } + return odd / bytes.length < 0.05; +} + +// --------------------------------------------------------------------------- +// Days + +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + +/** "Today", "Yesterday", "Monday 26 October", "12 March 2025". */ +export function daySection(date: Date | undefined, now = new Date()): string { + if (!date) return "Unknown Date"; + const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + const opts: Intl.DateTimeFormatOptions = days < 7 + ? { weekday: "long", day: "numeric", month: "long" } + : date.getFullYear() === now.getFullYear() + ? { day: "numeric", month: "long" } + : { day: "numeric", month: "long", year: "numeric" }; + return date.toLocaleDateString("en-GB", opts); +} + +/** "14:03", or "3 Oct, 14:03" outside today. */ +export function when(date: Date | undefined, now = new Date()): string { + if (!date) return ""; + const time = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + return startOfDay(date) === startOfDay(now) + ? time + : `${date.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${time}`; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + 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]}`; +} + +export const tilde = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p); +export const dirname = (p: string) => p.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "/"; +export const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() ?? p; + +/** "N items · size" */ +export const summary = (count: number, bytes: number) => + count ? `${count} item${count === 1 ? "" : "s"} · ${formatBytes(bytes)}` : "Empty"; + +/** A name for restoring next to an existing file: `report (restored).pdf`. */ +export function restoredName(path: string): string { + const dir = dirname(path); + const name = basename(path); + const dot = name.lastIndexOf("."); + const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""]; + return `${dir === "/" ? "" : dir}/${stem} (restored)${ext}`; +} diff --git a/extensions/trash/lib_test.ts b/extensions/trash/lib_test.ts new file mode 100644 index 0000000..6e372d7 --- /dev/null +++ b/extensions/trash/lib_test.ts @@ -0,0 +1,67 @@ +import { assertEquals } from "@std/assert"; +import { + daySection, + decodePath, + formatBytes, + kindOf, + looksLikeText, + parseDirectorySizes, + parseTrashInfo, + previewable, + restoredName, + summary, + when, +} from "./lib.ts"; + +Deno.test("trashinfo files", () => { + const info = parseTrashInfo( + "[Trash Info]\nPath=/home/ada/Documents/Rapport%20final%20%C3%A9t%C3%A9.pdf\nDeletionDate=2026-10-27T14:03:11\n", + ); + assertEquals(info?.path, "/home/ada/Documents/Rapport final été.pdf"); + assertEquals(info?.deleted, new Date(2026, 9, 27, 14, 3, 11)); + // Relative paths (trash on another drive) and missing dates. + assertEquals(parseTrashInfo("[Trash Info]\nPath=photos/cat.jpg\n"), { path: "photos/cat.jpg", deleted: undefined }); + assertEquals(parseTrashInfo("Path=/x\n"), undefined); + assertEquals(decodePath("100%"), "100%"); +}); + +Deno.test("directorysizes cache", () => { + const sizes = parseDirectorySizes("4096 1729000000 old%20project\n123456789 1729000001 node_modules\nbroken line\n"); + assertEquals(sizes.get("old project"), 4096); + assertEquals(sizes.get("node_modules"), 123456789); + assertEquals(sizes.size, 2); +}); + +Deno.test("kinds and previews", () => { + assertEquals(kindOf("cat.JPG", false), "image"); + assertEquals(kindOf("notes.md", false), "text"); + assertEquals(kindOf("app.AppImage", false), "archive"); + assertEquals(kindOf("photos", true), "folder"); + assertEquals(kindOf("README", false), "file"); + assertEquals(previewable("a.webp"), true); + assertEquals(previewable("a.heic"), false); + assertEquals(looksLikeText(new TextEncoder().encode("hello\nworld\té")), true); + assertEquals(looksLikeText(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 1])), false); +}); + +Deno.test("sections by deletion day", () => { + const now = new Date(2026, 9, 28, 10, 0); // Wednesday 28 October 2026 + assertEquals(daySection(new Date(2026, 9, 28, 0, 5), now), "Today"); + assertEquals(daySection(new Date(2026, 9, 27, 23, 59), now), "Yesterday"); + assertEquals(daySection(new Date(2026, 9, 26, 9), now), "Monday 26 October"); + assertEquals(daySection(new Date(2026, 8, 3), now), "3 September"); + assertEquals(daySection(new Date(2025, 11, 31), now), "31 December 2025"); + assertEquals(daySection(undefined, now), "Unknown Date"); + assertEquals(when(new Date(2026, 9, 28, 9, 7), now), "09:07"); + assertEquals(when(new Date(2026, 9, 3, 18, 30), now), "3 Oct, 18:30"); +}); + +Deno.test("names and summary", () => { + assertEquals(restoredName("/home/ada/report.pdf"), "/home/ada/report (restored).pdf"); + assertEquals(restoredName("/home/ada/.bashrc"), "/home/ada/.bashrc (restored)"); + assertEquals(restoredName("/home/ada/photos"), "/home/ada/photos (restored)"); + assertEquals(summary(3, 5 * 1024 * 1024), "3 items · 5.0 MB"); + assertEquals(summary(1, 10), "1 item · 10 B"); + assertEquals(summary(0, 0), "Empty"); + assertEquals(formatBytes(1536), "1.5 KB"); +}); diff --git a/extensions/window-manager/backslash.toml b/extensions/window-manager/backslash.toml new file mode 100644 index 0000000..75e09e4 --- /dev/null +++ b/extensions/window-manager/backslash.toml @@ -0,0 +1,183 @@ +[extension] +id = "window-manager" +title = "Window Manager" +description = "Snap, maximize, center, close and move the focused window between workspaces" +version = "1.0.0" +author = "Backslash" +icon = "bs-window" +license = "MIT" + +# One backend per desktop, the first that answers: hyprctl (Hyprland), swaymsg (Sway), +# gdbus to KWin's global shortcuts (KDE Plasma) or to the "Window Calls" Shell +# extension (GNOME on Wayland), wmctrl + xdotool + xrandr (X11). gsettings reads +# GNOME's workspace count. Extensions aren't told which window had the focus, +# so actions run from a short systemd-run --user job once the launcher has hidden. +[permissions] +run = ["systemd-run", "hyprctl", "swaymsg", "gdbus", "gsettings", "wmctrl", "xrandr"] +read = ["/etc/os-release"] +env = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "DBUS_SESSION_BUS_ADDRESS", +] + +[[commands]] +name = "windows" +title = "Window Manager" +description = "Every window action and your workspaces in one list" +icon = "bs-window" +keywords = ["window", "fenêtre", "tile", "tiling", "snap", "workspace", "espace de travail", "bureau virtuel", "layout", "disposition"] + +[[commands]] +name = "left-half" +title = "Left Half" +description = "Snap the window to the left half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile left", "snap left", "moitié gauche", "gauche", "fenêtre gauche"] + +[[commands]] +name = "right-half" +title = "Right Half" +description = "Snap the window to the right half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile right", "snap right", "moitié droite", "droite", "fenêtre droite"] + +[[commands]] +name = "top-half" +title = "Top Half" +description = "Snap the window to the top half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile top", "snap top", "moitié haut", "haut"] + +[[commands]] +name = "bottom-half" +title = "Bottom Half" +description = "Snap the window to the bottom half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile bottom", "snap bottom", "moitié bas", "bas"] + +[[commands]] +name = "top-left" +title = "Top Left Quarter" +description = "Snap the window to the top left quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "haut gauche"] + +[[commands]] +name = "top-right" +title = "Top Right Quarter" +description = "Snap the window to the top right quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "haut droite"] + +[[commands]] +name = "bottom-left" +title = "Bottom Left Quarter" +description = "Snap the window to the bottom left quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "bas gauche"] + +[[commands]] +name = "bottom-right" +title = "Bottom Right Quarter" +description = "Snap the window to the bottom right quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "bas droite"] + +[[commands]] +name = "maximize" +title = "Maximize Window" +description = "Fill the screen with the window" +icon = "bs-monitor" +mode = "no-view" +keywords = ["window", "fenêtre", "maximise", "agrandir", "plein", "fill", "zoom"] + +[[commands]] +name = "center" +title = "Center Window" +description = "Move the window to the middle of the screen" +icon = "bs-dot" +mode = "no-view" +keywords = ["window", "fenêtre", "centrer", "middle", "milieu"] + +[[commands]] +name = "fullscreen" +title = "Toggle Fullscreen" +description = "Make the window fullscreen, or leave fullscreen" +icon = "bs-monitor" +mode = "no-view" +keywords = ["window", "fenêtre", "plein écran", "full screen", "f11"] + +[[commands]] +name = "toggle-floating" +title = "Toggle Floating" +description = "Float the window or put it back in the tiling layout (Hyprland, Sway)" +icon = "bs-layers" +mode = "no-view" +keywords = ["window", "fenêtre", "float", "flottant", "tiling"] + +[[commands]] +name = "minimize" +title = "Minimize Window" +description = "Hide the window to the taskbar" +icon = "bs-minus" +mode = "no-view" +keywords = ["window", "fenêtre", "minimise", "réduire", "hide", "cacher"] + +[[commands]] +name = "close-window" +title = "Close Window" +description = "Close the window you were in" +icon = "bs-x" +mode = "no-view" +keywords = ["window", "fenêtre", "fermer", "kill", "quit", "quitter fenêtre"] + +[[commands]] +name = "move-to-workspace" +title = "Move Window to Workspace" +description = "Send the window to workspace N and follow it" +icon = "bs-arrow-right" +mode = "no-view" +keywords = ["window", "fenêtre", "workspace", "espace de travail", "bureau", "déplacer", "move", "send"] +[[commands.arguments]] +name = "workspace" +placeholder = "Workspace" +type = "number" +min = 1 +max = 99 +required = true + +[[commands]] +name = "switch-workspace" +title = "Switch to Workspace" +description = "Show workspace N" +icon = "bs-layers" +mode = "no-view" +keywords = ["workspace", "espace de travail", "bureau", "changer", "switch", "go to", "aller"] +[[commands.arguments]] +name = "workspace" +placeholder = "Workspace" +type = "number" +min = 1 +max = 99 +required = true + +[[preferences]] +name = "delay" +title = "Delay before acting (milliseconds)" +description = "Time for the launcher to hide and the focus to return to your window; raise it if actions hit the wrong window" +type = "number" +default = 250 diff --git a/extensions/window-manager/commands/bottom-half.ts b/extensions/window-manager/commands/bottom-half.ts new file mode 100644 index 0000000..3a08092 --- /dev/null +++ b/extensions/window-manager/commands/bottom-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("bottom")); diff --git a/extensions/window-manager/commands/bottom-left.ts b/extensions/window-manager/commands/bottom-left.ts new file mode 100644 index 0000000..bd6b1bf --- /dev/null +++ b/extensions/window-manager/commands/bottom-left.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("bottom-left")); diff --git a/extensions/window-manager/commands/bottom-right.ts b/extensions/window-manager/commands/bottom-right.ts new file mode 100644 index 0000000..9b67c1e --- /dev/null +++ b/extensions/window-manager/commands/bottom-right.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("bottom-right")); diff --git a/extensions/window-manager/commands/center.ts b/extensions/window-manager/commands/center.ts new file mode 100644 index 0000000..694d814 --- /dev/null +++ b/extensions/window-manager/commands/center.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("center")); diff --git a/extensions/window-manager/commands/close-window.ts b/extensions/window-manager/commands/close-window.ts new file mode 100644 index 0000000..aa2b61b --- /dev/null +++ b/extensions/window-manager/commands/close-window.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("close")); diff --git a/extensions/window-manager/commands/fullscreen.ts b/extensions/window-manager/commands/fullscreen.ts new file mode 100644 index 0000000..42ea6f4 --- /dev/null +++ b/extensions/window-manager/commands/fullscreen.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("fullscreen")); diff --git a/extensions/window-manager/commands/left-half.ts b/extensions/window-manager/commands/left-half.ts new file mode 100644 index 0000000..d54018a --- /dev/null +++ b/extensions/window-manager/commands/left-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("left")); diff --git a/extensions/window-manager/commands/maximize.ts b/extensions/window-manager/commands/maximize.ts new file mode 100644 index 0000000..d9e7e94 --- /dev/null +++ b/extensions/window-manager/commands/maximize.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("maximize")); diff --git a/extensions/window-manager/commands/minimize.ts b/extensions/window-manager/commands/minimize.ts new file mode 100644 index 0000000..46b4ebe --- /dev/null +++ b/extensions/window-manager/commands/minimize.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("minimize")); diff --git a/extensions/window-manager/commands/move-to-workspace.ts b/extensions/window-manager/commands/move-to-workspace.ts new file mode 100644 index 0000000..c4f4f78 --- /dev/null +++ b/extensions/window-manager/commands/move-to-workspace.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { moveToWorkspace } from "../perform.ts"; + +export default run(moveToWorkspace); diff --git a/extensions/window-manager/commands/right-half.ts b/extensions/window-manager/commands/right-half.ts new file mode 100644 index 0000000..67d471d --- /dev/null +++ b/extensions/window-manager/commands/right-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("right")); diff --git a/extensions/window-manager/commands/switch-workspace.ts b/extensions/window-manager/commands/switch-workspace.ts new file mode 100644 index 0000000..81e7a68 --- /dev/null +++ b/extensions/window-manager/commands/switch-workspace.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { switchWorkspace } from "../perform.ts"; + +export default run(switchWorkspace); diff --git a/extensions/window-manager/commands/toggle-floating.ts b/extensions/window-manager/commands/toggle-floating.ts new file mode 100644 index 0000000..844930e --- /dev/null +++ b/extensions/window-manager/commands/toggle-floating.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("float")); diff --git a/extensions/window-manager/commands/top-half.ts b/extensions/window-manager/commands/top-half.ts new file mode 100644 index 0000000..a0b8cf0 --- /dev/null +++ b/extensions/window-manager/commands/top-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("top")); diff --git a/extensions/window-manager/commands/top-left.ts b/extensions/window-manager/commands/top-left.ts new file mode 100644 index 0000000..8f4dc8e --- /dev/null +++ b/extensions/window-manager/commands/top-left.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("top-left")); diff --git a/extensions/window-manager/commands/top-right.ts b/extensions/window-manager/commands/top-right.ts new file mode 100644 index 0000000..ed5a0e0 --- /dev/null +++ b/extensions/window-manager/commands/top-right.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("top-right")); diff --git a/extensions/window-manager/commands/windows.ts b/extensions/window-manager/commands/windows.ts new file mode 100644 index 0000000..b8322e9 --- /dev/null +++ b/extensions/window-manager/commands/windows.ts @@ -0,0 +1,249 @@ +import { + type Action, + action, + close, + type Context, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { + type ActionInfo, + ACTIONS, + type Backend, + BACKEND_NAMES, + type Family, + installMarkdown, + preview, + supports, + type Workspace, +} from "../lib.ts"; +import { delayOf } from "../perform.ts"; +import { act, detect, family, moveTo, type Session, switchTo, workspaces } from "../system.ts"; + +const WINDOW_CALLS = "https://extensions.gnome.org/extension/4724/window-calls/"; + +function workspaceActions(s: Session, ws: Workspace[], delay: number): Action[] { + const out: Action[] = [ + submenu( + "Move Window to Workspace", + () => + ws.map((w) => + action(label(w), async () => { + await moveTo(s, w.num, delay); + return close(); + }, { icon: Icons.arrowRight }) + ), + { icon: Icons.arrowRight, shortcut: "m", section: "Workspaces" }, + ), + ]; + if (supports(s.backend!, "switch")) { + out.push(submenu( + "Switch to Workspace", + () => + ws.map((w) => + action(label(w), async () => { + await switchTo(s, w.num); + return close(); + }, { icon: w.active ? Icons.check : Icons.square }) + ), + { icon: Icons.layers, shortcut: "w", section: "Workspaces" }, + )); + } + return out; +} + +const label = (w: Workspace) => + `${w.name && w.name !== String(w.num) ? `${w.num} · ${w.name}` : `Workspace ${w.num}`}${ + w.active ? " (current)" : "" + }`; + +function actionItem(info: ActionInfo, s: Session, ws: Workspace[], delay: number): Item { + const tint = info.section === "Snap" ? "blue" : info.id === "close" ? "red" : "purple"; + const pic = preview(info.id); + return { + id: info.id, + title: info.title, + icon: { source: info.icon, tint }, + section: info.section, + keywords: [...info.keywords, "window", "fenêtre"], + accessories: [{ text: info.command, color: "gray", tooltip: "Also a command of its own: give it a hotkey" }], + detail: { + markdown: `${pic ? `${pic}\n\n` : ""}${info.about}`, + metadata: [ + meta.text("Acts on", "The window you were in", { icon: Icons.window }), + meta.text("Backend", BACKEND_NAMES[s.backend!], { icon: Icons.monitor }), + meta.text("Command", info.command, { icon: Icons.keyboard }), + ...(s.backend === "hyprland" && (info.section === "Snap" || info.id === "center") + ? [meta.text("Note", "Makes a tiled window float", { icon: Icons.info })] + : []), + ], + }, + actions: [ + action(info.title, async () => { + await act(s, info.id, delay); + return close(); + }, { icon: info.icon, destructive: info.id === "close" }), + ...workspaceActions(s, ws, delay), + ], + }; +} + +function workspaceItem(w: Workspace, s: Session, delay: number): Item { + const canSwitch = supports(s.backend!, "switch"); + const actions: Action[] = []; + if (canSwitch) { + actions.push(action("Switch to Workspace", async () => { + await switchTo(s, w.num); + return close(); + }, { icon: Icons.layers })); + } + actions.push( + action("Move Window Here", async () => { + await moveTo(s, w.num, delay); + return close(); + }, { icon: Icons.arrowRight }), + ); + if (s.backend !== "gnome") { + actions.push(action("Send Window Here and Stay", async () => { + await moveTo(s, w.num, delay, false); + return close(); + }, { icon: Icons.upload })); + } + actions.push(action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" })); + const windows = w.windows === undefined + ? undefined + : w.windows === 0 + ? "Empty" + : `${w.windows} window${w.windows === 1 ? "" : "s"}`; + return { + id: `ws:${w.num}`, + title: label(w).replace(" (current)", ""), + subtitle: windows, + icon: { source: Icons.layers, tint: w.active ? "green" : w.windows ? "blue" : "gray" }, + section: "Workspaces", + keywords: ["workspace", "espace de travail", "bureau", "desktop", String(w.num)], + accessories: [ + ...(w.output ? [{ text: w.output, icon: Icons.monitor }] : []), + ...(w.active ? [{ tag: "Current", color: "green" as const }] : []), + ], + detail: { + stats: [ + { label: "Workspace", value: w.num }, + ...(w.windows !== undefined ? [{ label: "Windows", value: w.windows }] : []), + ], + metadata: [ + meta.text("Name", w.name || String(w.num)), + meta.text("State", w.active ? "Current" : w.windows ? "In use" : "Empty", { + color: w.active ? "green" : "gray", + icon: w.active ? Icons.check : Icons.dottedCircle, + }), + ...(w.output ? [meta.text("Monitor", w.output, { icon: Icons.monitor })] : []), + meta.separator(), + meta.text("Enter", canSwitch ? "Switch to it" : "Move the window here", { icon: Icons.keyboard }), + ], + }, + actions, + }; +} + +function setupItems(s: Session, fam?: Family): Item[] { + const need = (b: Backend): Item => { + const what: Record = { + hyprland: { + title: "hyprctl doesn't answer", + md: + "Backslash talks to Hyprland with `hyprctl`, which ships with it. Make sure the launcher runs inside your Hyprland session (`HYPRLAND_INSTANCE_SIGNATURE` set).", + actions: [], + }, + sway: { + title: "swaymsg doesn't answer", + md: + "Backslash talks to Sway with `swaymsg`, which ships with it. Make sure the launcher runs inside your Sway session (`SWAYSOCK` set).", + actions: [], + }, + kde: { + title: "KWin's shortcuts can't be reached", + md: installMarkdown( + "Backslash invokes KWin's own window shortcuts over D-Bus with **gdbus**.", + [fam === "arch" ? "glib2" : fam === "debian" ? "libglib2.0-bin" : "glib2"], + fam, + ), + actions: [], + }, + gnome: { + title: "Install the Window Calls extension", + md: + "GNOME on Wayland lets apps move windows only through a Shell extension. Install **Window Calls** (by ickyicky), log out and back in, then open this command again.", + actions: [open(WINDOW_CALLS, "Open Window Calls on extensions.gnome.org", { icon: Icons.globe })], + }, + x11: { + title: "Install wmctrl and xdotool", + md: installMarkdown( + "On X11, Backslash moves windows with **wmctrl** and finds their size with **xdotool**.", + ["wmctrl", "xdotool"], + fam, + ), + actions: [], + }, + }; + const w = what[b]; + return { + id: `setup:${b}`, + title: w.title, + subtitle: BACKEND_NAMES[b], + icon: { source: Icons.alert, tint: "orange" }, + section: "Setup", + detail: { + markdown: w.md, + metadata: [meta.tags("Supported", ["Hyprland", "Sway", "KDE Plasma", "GNOME + Window Calls", "X11"])], + }, + actions: [...w.actions, action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; + }; + if (s.wanted.length) return s.wanted.map(need); + return [{ + id: "setup:none", + title: "No supported window manager", + subtitle: "Hyprland, Sway, KDE Plasma, GNOME or X11", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: + "Backslash couldn't tell which desktop this is (`XDG_CURRENT_DESKTOP`, `WAYLAND_DISPLAY` and `DISPLAY` are empty or unknown).", + metadata: [meta.tags("Supported", ["Hyprland", "Sway", "KDE Plasma", "GNOME + Window Calls", "X11"])], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; +} + +let summary = ""; + +export default (ctx: Context) => + list({ + placeholder: "Snap, maximize, move to a workspace…", + empty: { title: "Nothing matches", icon: Icons.window }, + status: () => summary, + async items() { + const delay = delayOf(ctx); + const s = await detect(); + if (!s.backend) { + summary = "No window manager found"; + return setupItems(s, await family()); + } + const ws = await workspaces(s.backend).catch(() => [] as Workspace[]); + const current = ws.find((w) => w.active); + summary = `${BACKEND_NAMES[s.backend]}${current ? ` · workspace ${current.num}` : ""}${ + ws.length ? ` · ${ws.length} workspaces` : "" + } · acts on the window you were in`; + updateCommandMetadata({ subtitle: current ? `Workspace ${current.name || current.num}` : null }).catch(() => {}); + return [ + ...ACTIONS.filter((a) => supports(s.backend!, a.id)).map((a) => actionItem(a, s, ws, delay)), + ...ws.map((w) => workspaceItem(w, s, delay)), + ]; + }, + }); diff --git a/extensions/window-manager/lib.ts b/extensions/window-manager/lib.ts new file mode 100644 index 0000000..6c27f52 --- /dev/null +++ b/extensions/window-manager/lib.ts @@ -0,0 +1,672 @@ +// Window geometry, compositor output parsing and the command lines that act +// on the focused window. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Session + +export type Backend = "hyprland" | "sway" | "kde" | "gnome" | "x11"; + +export type Env = Partial>; + +/** The environment variables we look at, and pass on to the background job. */ +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "DBUS_SESSION_BUS_ADDRESS", +] as const; + +/** Backends worth trying in this session, best first. Each is confirmed by talking to it. */ +export function candidates(env: Env): Backend[] { + const desktop = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const out: Backend[] = []; + if (env.HYPRLAND_INSTANCE_SIGNATURE || desktop.includes("hyprland")) out.push("hyprland"); + if (env.SWAYSOCK || desktop.includes("sway")) out.push("sway"); + if (desktop.includes("kde")) out.push("kde"); + // On GNOME under X11, wmctrl does more than the Shell extension (fullscreen, workspaces). + const gnome = desktop.some((d) => ["gnome", "ubuntu", "unity", "pop"].includes(d)); + if (gnome && wayland) out.push("gnome"); + if (env.DISPLAY && !wayland) out.push("x11"); + if (gnome && !wayland) out.push("gnome"); + return out; +} + +export const BACKEND_NAMES: Record = { + hyprland: "Hyprland", + sway: "Sway", + kde: "KDE Plasma (KWin)", + gnome: "GNOME (Window Calls)", + x11: "X11 (wmctrl)", +}; + +// --------------------------------------------------------------------------- +// Actions + +export type Tile = "left" | "right" | "top" | "bottom" | "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type WindowAction = Tile | "maximize" | "center" | "fullscreen" | "float" | "minimize" | "close"; + +export interface ActionInfo { + id: WindowAction; + title: string; + /** The no-view command with the same effect, for the shortcut hint. */ + command: string; + icon: string; + section: "Snap" | "Window"; + about: string; + keywords: string[]; +} + +export const ACTIONS: ActionInfo[] = [ + { + id: "left", + title: "Left Half", + command: "left-half", + icon: "bs-window", + section: "Snap", + about: "Fills the left half of the screen.", + keywords: ["tile", "snap", "gauche", "moitié"], + }, + { + id: "right", + title: "Right Half", + command: "right-half", + icon: "bs-window", + section: "Snap", + about: "Fills the right half of the screen.", + keywords: ["tile", "snap", "droite", "moitié"], + }, + { + id: "top", + title: "Top Half", + command: "top-half", + icon: "bs-window", + section: "Snap", + about: "Fills the top half of the screen.", + keywords: ["tile", "snap", "haut", "moitié"], + }, + { + id: "bottom", + title: "Bottom Half", + command: "bottom-half", + icon: "bs-window", + section: "Snap", + about: "Fills the bottom half of the screen.", + keywords: ["tile", "snap", "bas", "moitié"], + }, + { + id: "top-left", + title: "Top Left Quarter", + command: "top-left", + icon: "bs-window", + section: "Snap", + about: "Fills the top left quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "haut gauche"], + }, + { + id: "top-right", + title: "Top Right Quarter", + command: "top-right", + icon: "bs-window", + section: "Snap", + about: "Fills the top right quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "haut droite"], + }, + { + id: "bottom-left", + title: "Bottom Left Quarter", + command: "bottom-left", + icon: "bs-window", + section: "Snap", + about: "Fills the bottom left quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "bas gauche"], + }, + { + id: "bottom-right", + title: "Bottom Right Quarter", + command: "bottom-right", + icon: "bs-window", + section: "Snap", + about: "Fills the bottom right quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "bas droite"], + }, + { + id: "maximize", + title: "Maximize", + command: "maximize", + icon: "bs-monitor", + section: "Window", + about: "Fills the screen, panels and bars excepted.", + keywords: ["maximise", "agrandir", "plein", "fill"], + }, + { + id: "center", + title: "Center", + command: "center", + icon: "bs-dot", + section: "Window", + about: "Moves the window to the middle of the screen, keeping its size.", + keywords: ["centrer", "middle", "milieu"], + }, + { + id: "fullscreen", + title: "Toggle Fullscreen", + command: "fullscreen", + icon: "bs-monitor", + section: "Window", + about: "Covers the whole screen, bars included. Run it again to leave fullscreen.", + keywords: ["plein écran", "full screen", "kiosk"], + }, + { + id: "float", + title: "Toggle Floating", + command: "toggle-floating", + icon: "bs-layers", + section: "Window", + about: "Takes the window out of the tiling layout, or puts it back.", + keywords: ["float", "flottant", "tiling", "pseudo"], + }, + { + id: "minimize", + title: "Minimize", + command: "minimize", + icon: "bs-minus", + section: "Window", + about: "Hides the window to the taskbar or dock.", + keywords: ["minimise", "réduire", "hide", "cacher"], + }, + { + id: "close", + title: "Close Window", + command: "close-window", + icon: "bs-x", + section: "Window", + about: "Asks the window to close, as its close button does.", + keywords: ["fermer", "kill", "quit", "quitter"], + }, +]; + +export const actionInfo = (id: WindowAction) => ACTIONS.find((a) => a.id === id)!; + +/** What each backend can do. */ +export function supports(backend: Backend, a: WindowAction | "switch" | "move"): boolean { + if (a === "float") return backend === "hyprland" || backend === "sway"; + if (a === "minimize") return backend !== "hyprland" && backend !== "sway"; + if (a === "fullscreen") return backend !== "gnome"; + if (a === "switch") return backend !== "gnome"; + return true; +} + +// --------------------------------------------------------------------------- +// Geometry + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** The part of the work area a tile (or maximize) covers. */ +export function region(area: Rect, a: Tile | "maximize"): Rect { + const w1 = Math.floor(area.width / 2); + const h1 = Math.floor(area.height / 2); + const left = { x: area.x, width: w1 }; + const right = { x: area.x + w1, width: area.width - w1 }; + const full = { x: area.x, width: area.width }; + const top = { y: area.y, height: h1 }; + const bottom = { y: area.y + h1, height: area.height - h1 }; + const all = { y: area.y, height: area.height }; + switch (a) { + case "left": + return { ...left, ...all }; + case "right": + return { ...right, ...all }; + case "top": + return { ...full, ...top }; + case "bottom": + return { ...full, ...bottom }; + case "top-left": + return { ...left, ...top }; + case "top-right": + return { ...right, ...top }; + case "bottom-left": + return { ...left, ...bottom }; + case "bottom-right": + return { ...right, ...bottom }; + case "maximize": + return { ...area }; + } +} + +/** A window of this size in the middle of the area. */ +export function centered(area: Rect, width: number, height: number): Rect { + const w = Math.min(width, area.width); + const h = Math.min(height, area.height); + return { + x: area.x + Math.round((area.width - w) / 2), + y: area.y + Math.round((area.height - h) / 2), + width: w, + height: h, + }; +} + +export const isTile = (a: WindowAction): a is Tile => + ["left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"].includes(a); + +/** A small picture of the screen with the covered part filled, for the detail panel. */ +export function preview(a: WindowAction): string { + const W = 24; + const H = 8; + const r = isTile(a) || a === "maximize" + ? region({ x: 0, y: 0, width: W, height: H }, a as Tile | "maximize") + : a === "center" + ? centered({ x: 0, y: 0, width: W, height: H }, 12, 4) + : a === "fullscreen" + ? { x: 0, y: 0, width: W, height: H } + : undefined; + if (!r) return ""; + const rows: string[] = []; + for (let y = 0; y < H; y++) { + let line = ""; + for (let x = 0; x < W; x++) { + line += x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height ? "█" : "░"; + } + rows.push(line); + } + return "```\n" + rows.join("\n") + "\n```"; +} + +// --------------------------------------------------------------------------- +// Workspaces + +export interface Workspace { + /** The number used to switch to it. */ + num: number; + name: string; + active: boolean; + /** Windows on it, when known. */ + windows?: number; + /** The output it is on, when known. */ + output?: string; +} + +/** Workspaces 1…n, filled with what the compositor reports; dynamic ones up to `min`. */ +export function numbered(known: Workspace[], min = 9): Workspace[] { + const byNum = new Map(known.filter((w) => w.num > 0).map((w) => [w.num, w])); + const max = Math.max(min, ...byNum.keys()); + const out: Workspace[] = []; + for (let n = 1; n <= max; n++) { + out.push(byNum.get(n) ?? { num: n, name: String(n), active: false, windows: 0 }); + } + return out; +} + +/** `hyprctl -j workspaces` + `hyprctl -j activeworkspace` (special workspaces left out). */ +export function hyprWorkspaces(workspacesJson: string, activeJson: string): Workspace[] { + const active = (JSON.parse(activeJson || "{}") as { id?: number }).id; + const list = JSON.parse(workspacesJson) as { id: number; name: string; windows?: number; monitor?: string }[]; + return list + .filter((w) => w.id > 0) + .map((w) => ({ num: w.id, name: w.name, active: w.id === active, windows: w.windows, output: w.monitor })) + .sort((a, b) => a.num - b.num); +} + +/** `swaymsg -t get_workspaces -r`. */ +export function swayWorkspaces(json: string): Workspace[] { + const list = JSON.parse(json) as { num: number; name: string; focused: boolean; output?: string }[]; + return list + .filter((w) => w.num > 0) + .map((w) => ({ num: w.num, name: w.name, active: w.focused, output: w.output })) + .sort((a, b) => a.num - b.num); +} + +/** `wmctrl -d`: desktops, and the work area of the current one. */ +export function wmctrlDesktops(text: string): { workspaces: Workspace[]; workarea?: Rect } { + const workspaces: Workspace[] = []; + let workarea: Rect | undefined; + for (const line of text.split("\n")) { + const m = line.match(/^(\d+)\s+([*-])\s+DG:\s*\S+\s+VP:\s*\S+\s+WA:\s*(-?\d+),(-?\d+)\s+(\d+)x(\d+)\s+(.*)$/); + if (!m) continue; + const active = m[2] === "*"; + workspaces.push({ num: Number(m[1]) + 1, name: m[7].trim() || `Workspace ${Number(m[1]) + 1}`, active }); + if (active) workarea = { x: +m[3], y: +m[4], width: +m[5], height: +m[6] }; + } + return { workspaces, workarea }; +} + +/** `gdbus … VirtualDesktopManager desktops` and `current` (KWin, Plasma 5.25+). */ +export function kwinDesktops(desktopsOut: string, currentOut: string): Workspace[] { + const current = currentOut.match(/'([^']*)'/)?.[1]; + const out: Workspace[] = []; + for (const m of desktopsOut.matchAll(/\('([^']*)',\s*(?:uint32\s+)?(\d+),\s*'((?:[^'\\]|\\.)*)'\)/g)) { + out.push({ num: Number(m[2]) + 1, name: m[3].replace(/\\(.)/g, "$1"), active: m[1] === current }); + } + return out.sort((a, b) => a.num - b.num); +} + +/** A number from gdbus output such as `(,)` or `(4,)`. */ +export function gdbusNumber(out: string): number | undefined { + const m = out.match(/(?:u?int\d+\s+)?(-?\d+)/); + return m ? Number(m[1]) : undefined; +} + +// --------------------------------------------------------------------------- +// Hyprland + +interface HyprMonitor { + id: number; + name: string; + x: number; + y: number; + width: number; + height: number; + scale?: number; + transform?: number; + focused?: boolean; + reserved?: [number, number, number, number]; + activeWorkspace?: { id: number }; +} + +/** The usable area of a monitor from `hyprctl -j monitors` (logical pixels, bars excepted). */ +export function hyprArea(monitorsJson: string, monitorId?: number): Rect | undefined { + const monitors = JSON.parse(monitorsJson) as HyprMonitor[]; + const m = monitors.find((m) => monitorId !== undefined && m.id === monitorId) ?? monitors.find((m) => m.focused) ?? + monitors[0]; + if (!m) return undefined; + const scale = m.scale || 1; + const rotated = (m.transform ?? 0) % 2 === 1; + const w = Math.round((rotated ? m.height : m.width) / scale); + const h = Math.round((rotated ? m.width : m.height) / scale); + const [l, t, r, b] = m.reserved ?? [0, 0, 0, 0]; + return { x: m.x + l, y: m.y + t, width: w - l - r, height: h - t - b }; +} + +/** The monitor of the focused window from `hyprctl -j activewindow`, unless it is the launcher. */ +export function hyprActiveMonitor(json: string): number | undefined { + try { + const w = JSON.parse(json) as { monitor?: number; class?: string }; + if (typeof w.monitor !== "number" || /backslash/i.test(w.class ?? "")) return undefined; + return w.monitor; + } catch { + return undefined; + } +} + +/** `hyprctl --batch` argument for an action on the active window. */ +export function hyprBatch(a: WindowAction, area?: Rect): string { + const d = (s: string) => `dispatch ${s}`; + if (isTile(a)) { + if (!area) throw new Error("Unknown screen size"); + const r = region(area, a); + return [ + d("setfloating"), + d(`resizeactive exact ${r.width} ${r.height}`), + d(`moveactive exact ${r.x} ${r.y}`), + ].join(" ; "); + } + switch (a) { + case "maximize": + return d("fullscreen 1"); + case "fullscreen": + return d("fullscreen 0"); + case "center": + return [d("setfloating"), d("centerwindow 1")].join(" ; "); + case "float": + return d("togglefloating"); + case "close": + return d("killactive"); + default: + throw new Error(`${actionInfo(a).title} isn't available on Hyprland`); + } +} + +// --------------------------------------------------------------------------- +// Sway + +/** The focused workspace's usable rectangle from `swaymsg -t get_workspaces -r` (bars excepted). */ +export function swayArea(json: string): Rect | undefined { + const list = JSON.parse(json) as { focused: boolean; rect: Rect }[]; + return (list.find((w) => w.focused) ?? list[0])?.rect; +} + +/** A `swaymsg` command for an action on the focused window. */ +export function swayCommand(a: WindowAction, area?: Rect): string { + if (isTile(a) || a === "maximize") { + if (!area) throw new Error("Unknown screen size"); + const r = region(area, a); + return `floating enable, resize set width ${r.width} px height ${r.height} px, move absolute position ${r.x} px ${r.y} px`; + } + switch (a) { + case "fullscreen": + return "fullscreen toggle"; + case "center": + return "floating enable, move position center"; + case "float": + return "floating toggle"; + case "close": + return "kill"; + default: + throw new Error(`${actionInfo(a).title} isn't available on Sway`); + } +} + +// --------------------------------------------------------------------------- +// KDE Plasma: KWin's own global shortcuts, invoked over D-Bus + +export function kwinShortcut(a: WindowAction | { move: number } | { switch: number }): string { + if (typeof a === "object") return "move" in a ? `Window to Desktop ${a.move}` : `Switch to Desktop ${a.switch}`; + const names: Partial> = { + left: "Window Quick Tile Left", + right: "Window Quick Tile Right", + top: "Window Quick Tile Top", + bottom: "Window Quick Tile Bottom", + "top-left": "Window Quick Tile Top Left", + "top-right": "Window Quick Tile Top Right", + "bottom-left": "Window Quick Tile Bottom Left", + "bottom-right": "Window Quick Tile Bottom Right", + maximize: "Window Maximize", + center: "Window Move Center", + fullscreen: "Window Fullscreen", + minimize: "Window Minimize", + close: "Window Close", + }; + const name = names[a]; + if (!name) throw new Error(`${actionInfo(a).title} isn't available on KDE Plasma`); + return name; +} + +/** Shell line invoking a KWin shortcut, with gdbus or one of the qdbus spellings. */ +export function kwinShortcutLine(name: string): string { + const n = q(name); + return [ + `gdbus call --session --dest org.kde.kglobalaccel --object-path /component/kwin --method org.kde.kglobalaccel.Component.invokeShortcut ${n}`, + `qdbus6 org.kde.kglobalaccel /component/kwin invokeShortcut ${n}`, + `qdbus org.kde.kglobalaccel /component/kwin invokeShortcut ${n}`, + ].map((l) => `${l} >/dev/null 2>&1`).join(" || "); +} + +// --------------------------------------------------------------------------- +// X11: wmctrl + xdotool, computed in the background job once the window has the focus back + +export interface Monitor extends Rect { + name: string; +} + +/** `xrandr --listactivemonitors`: ` 0: +*eDP-1 1920/344x1080/194+0+0 eDP-1`. */ +export function xrandrMonitors(text: string): Monitor[] { + const out: Monitor[] = []; + for (const m of text.matchAll(/^\s*\d+:\s+\S+\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)\s+(\S+)/gm)) { + out.push({ width: +m[1], height: +m[2], x: +m[3], y: +m[4], name: m[5] }); + } + return out; +} + +/** + * Shell lines that find the active window's monitor (the one under its + * center), keep the part inside the desktop's work area (panels excepted), + * then move it with wmctrl. Sets L T W H for the usable area, X Y WIDTH HEIGHT for the window. + */ +export function x11Lines(a: WindowAction, monitors: Monitor[], workarea?: Rect): string[] { + const ACTIVE = "wmctrl -r :ACTIVE:"; + const unmax = `${ACTIVE} -b remove,maximized_vert,maximized_horz,fullscreen`; + switch (a) { + case "maximize": + return [`${ACTIVE} -b remove,fullscreen`, `${ACTIVE} -b add,maximized_vert,maximized_horz`]; + case "fullscreen": + return [`${ACTIVE} -b toggle,fullscreen`]; + case "close": + return ["wmctrl -c :ACTIVE:"]; + case "minimize": + return ["xdotool getactivewindow windowminimize"]; + case "float": + throw new Error("Floating only means something in a tiling window manager"); + } + const first = monitors[0] ?? workarea ?? { x: 0, y: 0, width: 1920, height: 1080 }; + const wa = workarea ?? first; + const lines = [ + `eval "$(xdotool getactivewindow getwindowgeometry --shell 2>/dev/null)"`, + `cx=$(( \${X:-0} + \${WIDTH:-0} / 2 )); cy=$(( \${Y:-0} + \${HEIGHT:-0} / 2 ))`, + `MX=${first.x}; MY=${first.y}; MW=${first.width}; MH=${first.height}`, + ...monitors.slice(1).map((m) => + `if [ $cx -ge ${m.x} ] && [ $cx -lt ${m.x + m.width} ] && [ $cy -ge ${m.y} ] && [ $cy -lt ${ + m.y + m.height + } ]; then MX=${m.x}; MY=${m.y}; MW=${m.width}; MH=${m.height}; fi` + ), + // Panels: what the work area takes off this monitor. + `L=$(( MX > ${wa.x} ? MX : ${wa.x} )); T=$(( MY > ${wa.y} ? MY : ${wa.y} ))`, + `R=$(( MX + MW < ${wa.x + wa.width} ? MX + MW : ${wa.x + wa.width} )); B=$(( MY + MH < ${ + wa.y + wa.height + } ? MY + MH : ${wa.y + wa.height} ))`, + `W=$(( R - L )); H=$(( B - T ))`, + unmax, + ]; + if (a === "center") { + return [...lines, `${ACTIVE} -e 0,$(( L + (W - \${WIDTH:-W}) / 2 )),$(( T + (H - \${HEIGHT:-H}) / 2 )),-1,-1`]; + } + const geometry: Record = { + left: "$L,$T,$(( W / 2 )),$H", + right: "$(( L + W / 2 )),$T,$(( W - W / 2 )),$H", + top: "$L,$T,$W,$(( H / 2 ))", + bottom: "$L,$(( T + H / 2 )),$W,$(( H - H / 2 ))", + "top-left": "$L,$T,$(( W / 2 )),$(( H / 2 ))", + "top-right": "$(( L + W / 2 )),$T,$(( W - W / 2 )),$(( H / 2 ))", + "bottom-left": "$L,$(( T + H / 2 )),$(( W / 2 )),$(( H - H / 2 ))", + "bottom-right": "$(( L + W / 2 )),$(( T + H / 2 )),$(( W - W / 2 )),$(( H - H / 2 ))", + }; + return [...lines, `${ACTIVE} -e 0,${geometry[a as Tile]}`]; +} + +// --------------------------------------------------------------------------- +// GNOME: the "Window Calls" Shell extension + +/** The string inside gdbus's `('…',)`, unescaped. */ +export function gvariantString(out: string): string { + const t = out.trim(); + const m = t.match(/^\((['"])([\s\S]*)\1,\)$/); + if (!m) throw new Error(`Unexpected gdbus output: ${t.slice(0, 80)}`); + return m[2].replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (_, e: string) => { + if (e[0] === "u" && e.length === 5) return String.fromCharCode(parseInt(e.slice(1), 16)); + if (e[0] === "x" && e.length === 3) return String.fromCharCode(parseInt(e.slice(1), 16)); + return ({ n: "\n", t: "\t", r: "\r" } as Record)[e] ?? e; + }); +} + +interface GnomeWindow { + id: number; + wm_class?: string; + wm_class_instance?: string; + focus?: boolean; + window_type?: number; + in_current_workspace?: boolean; +} + +/** + * The window the user was in, from Window Calls' `List` (in stacking order, + * bottom to top): the focused one, or the topmost below the launcher. + */ +export function gnomeTarget(listJson: string): number | undefined { + const all = (JSON.parse(listJson) as GnomeWindow[]) + .filter((w) => (w.window_type ?? 0) === 0 && w.in_current_workspace !== false); + const others = all.filter((w) => !/backslash/i.test(`${w.wm_class ?? ""} ${w.wm_class_instance ?? ""}`)); + return (others.find((w) => w.focus) ?? others.at(-1))?.id; +} + +const isRect = (r: any): r is Rect => + r && typeof r === "object" && [r.x, r.y, r.width, r.height].every((n) => typeof n === "number") && r.width > 0; + +/** Work area and frame from Window Calls' `Details` (fields vary between versions). */ +export function gnomeDetails(json: string): { area?: Rect; frame?: Rect; maximized: boolean } { + const d = JSON.parse(json) as Record; + const frame = { x: d.x, y: d.y, width: d.width, height: d.height }; + return { + area: [d.area, d.area_cust, d.work_area].find(isRect), + frame: isRect(frame) ? frame : undefined, + maximized: !!d.maximized, + }; +} + +/** A JSON rectangle from Window Calls' `GetFrameRect`. */ +export function parseRect(json: string): Rect | undefined { + const r = JSON.parse(json); + return isRect(r) ? r : undefined; +} + +// --------------------------------------------------------------------------- +// Background job + +export const q = (s: string) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`; + +/** The shell script run once the launcher is hidden. */ +export function delayedScript(lines: string[], delayMs: number): string { + const s = Math.max(0, delayMs) / 1000; + return [...(s ? [`sleep ${s}`] : []), ...lines].join("\n"); +} + +/** `systemd-run` arguments to run a script in the background, outside the launcher. */ +export function systemdRunArgs(unit: string, script: string, env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: window action", + "--collect", + ...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: ["Arch", "sudo pacman -S"], + debian: ["Debian, Ubuntu", "sudo apt install"], + fedora: ["Fedora", "sudo dnf install"], + suse: ["openSUSE", "sudo zypper install"], +}; + +export function installMarkdown(what: string, packages: string[], family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + const lines = families.map((f) => `**${MANAGERS[f][0]}**\n\n\`${MANAGERS[f][1]} ${packages.join(" ")}\``); + return `${what}\n\n${lines.join("\n\n")}`; +} diff --git a/extensions/window-manager/lib_test.ts b/extensions/window-manager/lib_test.ts new file mode 100644 index 0000000..2af2742 --- /dev/null +++ b/extensions/window-manager/lib_test.ts @@ -0,0 +1,243 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { + candidates, + centered, + delayedScript, + gdbusNumber, + gnomeDetails, + gnomeTarget, + gvariantString, + hyprActiveMonitor, + hyprArea, + hyprBatch, + hyprWorkspaces, + kwinDesktops, + kwinShortcut, + kwinShortcutLine, + numbered, + osFamily, + preview, + region, + supports, + swayArea, + swayCommand, + swayWorkspaces, + systemdRunArgs, + wmctrlDesktops, + x11Lines, + xrandrMonitors, +} from "./lib.ts"; + +Deno.test("backends tried for each desktop", () => { + assertEquals(candidates({ HYPRLAND_INSTANCE_SIGNATURE: "abc", WAYLAND_DISPLAY: "wayland-1" }), ["hyprland"]); + assertEquals(candidates({ SWAYSOCK: "/run/user/1000/sway.sock", XDG_CURRENT_DESKTOP: "sway" }), ["sway"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "KDE", XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "w" }), [ + "kde", + ]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "KDE", XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }), ["kde", "x11"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME", XDG_SESSION_TYPE: "wayland", DISPLAY: ":0" }), [ + "gnome", + ]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "GNOME", XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }), ["x11", "gnome"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "XFCE", DISPLAY: ":0" }), ["x11"]); + assertEquals(candidates({}), []); +}); + +Deno.test("what each backend supports", () => { + assertEquals(supports("hyprland", "float"), true); + assertEquals(supports("kde", "float"), false); + assertEquals(supports("hyprland", "minimize"), false); + assertEquals(supports("gnome", "fullscreen"), false); + assertEquals(supports("gnome", "switch"), false); + assertEquals(supports("x11", "switch"), true); +}); + +const AREA = { x: 0, y: 32, width: 1921, height: 1048 }; + +Deno.test("halves and quarters cover the area with no gap", () => { + assertEquals(region(AREA, "left"), { x: 0, y: 32, width: 960, height: 1048 }); + assertEquals(region(AREA, "right"), { x: 960, y: 32, width: 961, height: 1048 }); + assertEquals(region(AREA, "top"), { x: 0, y: 32, width: 1921, height: 524 }); + assertEquals(region(AREA, "bottom"), { x: 0, y: 556, width: 1921, height: 524 }); + assertEquals(region(AREA, "bottom-right"), { x: 960, y: 556, width: 961, height: 524 }); + assertEquals(region(AREA, "maximize"), AREA); + assertEquals(centered(AREA, 800, 600), { x: 561, y: 256, width: 800, height: 600 }); + assertEquals(centered(AREA, 4000, 600).width, 1921); +}); + +Deno.test("preview draws the covered part", () => { + const p = preview("left"); + assertStringIncludes(p, "████████████░░░░░░░░░░░░"); + assertEquals(preview("close"), ""); +}); + +// Hyprland 0.45, a 4K laptop screen at 1.5 with a 30 px waybar, and an external monitor. +const HYPR_MONITORS = JSON.stringify([ + { + id: 0, + name: "eDP-1", + width: 2880, + height: 1800, + x: 0, + y: 0, + scale: 1.5, + transform: 0, + focused: false, + reserved: [0, 30, 0, 0], + activeWorkspace: { id: 1, name: "1" }, + }, + { + id: 1, + name: "DP-2", + width: 1080, + height: 1920, + x: 1920, + y: 0, + scale: 1, + transform: 1, + focused: true, + reserved: [0, 0, 0, 0], + activeWorkspace: { id: 4, name: "4" }, + }, +]); + +Deno.test("hyprland usable area, scaled and rotated", () => { + assertEquals(hyprArea(HYPR_MONITORS, 0), { x: 0, y: 30, width: 1920, height: 1170 }); + assertEquals(hyprArea(HYPR_MONITORS), { x: 1920, y: 0, width: 1920, height: 1080 }); + assertEquals(hyprActiveMonitor(JSON.stringify({ address: "0x5", monitor: 0, class: "firefox" })), 0); + assertEquals(hyprActiveMonitor(JSON.stringify({ monitor: 1, class: "app.backslash.Backslash" })), undefined); + assertEquals(hyprActiveMonitor("{}"), undefined); +}); + +Deno.test("hyprland dispatches", () => { + assertEquals( + hyprBatch("left", { x: 0, y: 30, width: 1920, height: 1170 }), + "dispatch setfloating ; dispatch resizeactive exact 960 1170 ; dispatch moveactive exact 0 30", + ); + assertEquals(hyprBatch("maximize"), "dispatch fullscreen 1"); + assertEquals(hyprBatch("fullscreen"), "dispatch fullscreen 0"); + assertEquals(hyprBatch("float"), "dispatch togglefloating"); + assertEquals(hyprBatch("close"), "dispatch killactive"); + assertThrows(() => hyprBatch("minimize")); +}); + +Deno.test("hyprland workspaces, special ones left out", () => { + const ws = hyprWorkspaces( + JSON.stringify([ + { id: 3, name: "3", monitor: "DP-2", windows: 2 }, + { id: 1, name: "web", monitor: "eDP-1", windows: 4 }, + { id: -98, name: "special:scratch", monitor: "eDP-1", windows: 1 }, + ]), + JSON.stringify({ id: 3, name: "3" }), + ); + assertEquals(ws.map((w) => [w.num, w.name, w.active, w.windows]), [[1, "web", false, 4], [3, "3", true, 2]]); + const all = numbered(ws, 5); + assertEquals(all.map((w) => w.num), [1, 2, 3, 4, 5]); + assertEquals(all[1], { num: 2, name: "2", active: false, windows: 0 }); +}); + +const SWAY_WS = JSON.stringify([ + { num: 1, name: "1", focused: false, output: "eDP-1", rect: { x: 0, y: 0, width: 1920, height: 1050 } }, + { num: 2, name: "2: code", focused: true, output: "eDP-1", rect: { x: 0, y: 30, width: 1920, height: 1050 } }, +]); + +Deno.test("sway workspaces and commands", () => { + assertEquals(swayWorkspaces(SWAY_WS).map((w) => [w.num, w.name, w.active]), [[1, "1", false], [2, "2: code", true]]); + assertEquals(swayArea(SWAY_WS), { x: 0, y: 30, width: 1920, height: 1050 }); + assertEquals( + swayCommand("right", swayArea(SWAY_WS)), + "floating enable, resize set width 960 px height 1050 px, move absolute position 960 px 30 px", + ); + assertEquals(swayCommand("center"), "floating enable, move position center"); + assertEquals(swayCommand("close"), "kill"); +}); + +Deno.test("kwin shortcuts and desktops", () => { + assertEquals(kwinShortcut("top-left"), "Window Quick Tile Top Left"); + assertEquals(kwinShortcut({ move: 3 }), "Window to Desktop 3"); + assertEquals(kwinShortcut({ switch: 2 }), "Switch to Desktop 2"); + assertThrows(() => kwinShortcut("float")); + const line = kwinShortcutLine("Window Maximize"); + assertStringIncludes(line, "invokeShortcut 'Window Maximize'"); + assertStringIncludes(line, " || qdbus6 "); + const desktops = kwinDesktops( + "(<[('0a1b-uuid', uint32 0, 'Desktop 1'), ('2c3d-uuid', 1, 'Mail \\'n\\' chat')]>,)", + "(<'2c3d-uuid'>,)", + ); + assertEquals(desktops, [ + { num: 1, name: "Desktop 1", active: false }, + { num: 2, name: "Mail 'n' chat", active: true }, + ]); + assertEquals(gdbusNumber("(,)"), 4); +}); + +Deno.test("wmctrl desktops and xrandr monitors", () => { + const { workspaces, workarea } = wmctrlDesktops( + "0 - DG: 3840x1080 VP: N/A WA: 0,27 3840x1053 Workspace 1\n1 * DG: 3840x1080 VP: 0,0 WA: 0,27 3840x1053 Mail\n", + ); + assertEquals(workspaces.map((w) => [w.num, w.name, w.active]), [[1, "Workspace 1", false], [2, "Mail", true]]); + assertEquals(workarea, { x: 0, y: 27, width: 3840, height: 1053 }); + const monitors = xrandrMonitors( + "Monitors: 2\n 0: +*eDP-1 1920/344x1080/194+0+0 eDP-1\n 1: +HDMI-1 1920/527x1080/296+1920+0 HDMI-1\n", + ); + assertEquals(monitors, [ + { width: 1920, height: 1080, x: 0, y: 0, name: "eDP-1" }, + { width: 1920, height: 1080, x: 1920, y: 0, name: "HDMI-1" }, + ]); +}); + +Deno.test("x11 script snaps on the window's monitor, inside the work area", async () => { + const monitors = [ + { width: 1920, height: 1080, x: 0, y: 0, name: "eDP-1" }, + { width: 1920, height: 1080, x: 1920, y: 0, name: "HDMI-1" }, + ]; + const lines = x11Lines("right", monitors, { x: 0, y: 27, width: 3840, height: 1053 }); + assertEquals(lines.at(-1), "wmctrl -r :ACTIVE: -e 0,$(( L + W / 2 )),$T,$(( W - W / 2 )),$H"); + // Run the arithmetic with a window on the second monitor, wmctrl replaced by echo. + const script = ["X=2000; Y=100; WIDTH=800; HEIGHT=600", ...lines.slice(1)].join("\n").replaceAll("wmctrl", "echo"); + const out = await new Deno.Command("sh", { args: ["-c", script], stdout: "piped" }).output(); + assertEquals(new TextDecoder().decode(out.stdout).trim().split("\n").at(-1), "-r :ACTIVE: -e 0,2880,27,960,1053"); + const center = x11Lines("center", monitors, { x: 0, y: 27, width: 3840, height: 1053 }); + const out2 = await new Deno.Command("sh", { + args: ["-c", ["X=10; Y=50; WIDTH=800; HEIGHT=600", ...center.slice(1)].join("\n").replaceAll("wmctrl", "echo")], + stdout: "piped", + }).output(); + assertEquals(new TextDecoder().decode(out2.stdout).trim().split("\n").at(-1), "-r :ACTIVE: -e 0,560,253,-1,-1"); + assertEquals(x11Lines("close", monitors), ["wmctrl -c :ACTIVE:"]); + assertThrows(() => x11Lines("float", monitors)); +}); + +Deno.test("gnome: gdbus strings and the window under the launcher", () => { + const list = gvariantString( + `('[{"wm_class":"firefox","id":11,"focus":false,"window_type":0,"in_current_workspace":true},{"wm_class":"Code","id":12,"focus":false,"window_type":0,"in_current_workspace":false},{"wm_class":"org.gnome.Nautilus","id":13,"focus":false,"window_type":0,"in_current_workspace":true},{"wm_class":"backslash","id":14,"focus":true,"window_type":0,"in_current_workspace":true}]',)`, + ); + assertEquals(gnomeTarget(list), 13); + assertEquals(gnomeTarget(JSON.stringify([{ id: 2, wm_class: "kitty", focus: true }, { id: 3 }])), 2); + assertEquals(gnomeTarget("[]"), undefined); + assertEquals(gvariantString(`('it\\'s',)`), "it's"); + const d = gnomeDetails( + JSON.stringify({ + x: 10, + y: 40, + width: 800, + height: 600, + maximized: 0, + area: { x: 0, y: 32, width: 1920, height: 1048 }, + }), + ); + assertEquals(d.area, { x: 0, y: 32, width: 1920, height: 1048 }); + assertEquals(d.frame, { x: 10, y: 40, width: 800, height: 600 }); + assertEquals(gnomeDetails(JSON.stringify({ x: 1, y: 1, width: 5, height: 5, area: {} })).area, undefined); +}); + +Deno.test("background job", () => { + assertEquals(delayedScript(["a", "b"], 250), "sleep 0.25\na\nb"); + assertEquals(delayedScript(["a"], 0), "a"); + const args = systemdRunArgs("u", "echo", { DISPLAY: ":0", SWAYSOCK: undefined }); + assertEquals(args.slice(-4), ["--setenv=DISPLAY=:0", "/bin/sh", "-c", "echo"]); +}); + +Deno.test("os family", () => { + assertEquals(osFamily("ID=ubuntu\nID_LIKE=debian\n"), "debian"); + assertEquals(osFamily("ID=fedora\n"), "fedora"); +}); diff --git a/extensions/window-manager/perform.ts b/extensions/window-manager/perform.ts new file mode 100644 index 0000000..c9cb20e --- /dev/null +++ b/extensions/window-manager/perform.ts @@ -0,0 +1,45 @@ +// The no-view commands: one action on the window the user was in. + +import type { Context } from "@backslash/api"; +import { actionInfo, BACKEND_NAMES, supports, type WindowAction } from "./lib.ts"; +import { act, detect, moveTo, switchTo } from "./system.ts"; + +export interface Prefs { + delay?: number; +} + +export const delayOf = (ctx: Context) => { + const d = Number((ctx.preferences as Prefs).delay); + return Number.isFinite(d) && d >= 0 ? Math.min(d, 2000) : 250; +}; + +async function session() { + const s = await detect(); + if (!s.backend) { + throw new Error("No supported window manager: open the Window Manager command to see what to install"); + } + return s; +} + +/** Closes the launcher with no message, so the focus goes back to the window right away. */ +export const perform = (a: WindowAction) => async (ctx: Context): Promise => { + const s = await session(); + if (!supports(s.backend!, a)) { + throw new Error(`${actionInfo(a).title} isn't available on ${BACKEND_NAMES[s.backend!]}`); + } + await act(s, a, delayOf(ctx)); +}; + +const workspaceArg = (ctx: Context) => { + const n = Math.trunc(Number((ctx.args as { workspace?: unknown }).workspace)); + if (!Number.isFinite(n) || n < 1) throw new Error("Give a workspace number, from 1"); + return n; +}; + +export const moveToWorkspace = async (ctx: Context): Promise => { + await moveTo(await session(), workspaceArg(ctx), delayOf(ctx)); +}; + +export const switchWorkspace = async (ctx: Context): Promise => { + await switchTo(await session(), workspaceArg(ctx)); +}; diff --git a/extensions/window-manager/system.ts b/extensions/window-manager/system.ts new file mode 100644 index 0000000..23f963d --- /dev/null +++ b/extensions/window-manager/system.ts @@ -0,0 +1,357 @@ +// Talking to the compositor: detection, workspaces, and running an action on +// the window the user was in before opening the launcher. +// +// Extensions aren't told which window that was. So every action runs from a +// short-lived background job (systemd-run) a moment after the launcher has +// hidden, when the focus is back on that window. GNOME is the exception: the +// Window Calls extension lists windows in stacking order, so the target is +// the topmost window under the launcher and is acted on right away. + +import { exec } from "@backslash/api"; +import { + type Backend, + candidates, + centered, + delayedScript, + type Env, + gdbusNumber, + gnomeDetails, + gnomeTarget, + gvariantString, + hyprActiveMonitor, + hyprArea, + hyprBatch, + hyprWorkspaces, + isTile, + kwinDesktops, + kwinShortcut, + kwinShortcutLine, + numbered, + osFamily, + parseRect, + q, + type Rect, + region, + SESSION_ENV, + swayArea, + swayCommand, + swayWorkspaces, + systemdRunArgs, + type WindowAction, + wmctrlDesktops, + type Workspace, + x11Lines, + xrandrMonitors, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key) || undefined; + } catch { + return undefined; + } +}; + +export const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); + +const missing = (e: unknown) => + e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable; + +const GNOME_DEST = ["--dest", "org.gnome.Shell", "--object-path", "/org/gnome/Shell/Extensions/Windows"]; + +function gnomeCall(method: string, ...args: (string | number)[]): Promise { + return exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + ...GNOME_DEST, + "--method", + `org.gnome.Shell.Extensions.Windows.${method}`, + ...args.map(String), + ]); +} + +function kwinProperty(name: string): Promise { + return exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + "--dest", + "org.kde.KWin", + "--object-path", + "/VirtualDesktopManager", + "--method", + "org.freedesktop.DBus.Properties.Get", + "org.kde.KWin.VirtualDesktopManager", + name, + ]); +} + +/** Whether the backend answers here. */ +async function works(b: Backend): Promise { + try { + switch (b) { + case "hyprland": + await exec("hyprctl", ["-j", "version"]); + return true; + case "sway": + await exec("swaymsg", ["-t", "get_version"]); + return true; + case "kde": + await exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + "--dest", + "org.kde.kglobalaccel", + "--object-path", + "/component/kwin", + "--method", + "org.kde.kglobalaccel.Component.shortcutNames", + ]); + return true; + case "gnome": + await gnomeCall("List"); + return true; + case "x11": + await exec("wmctrl", ["-m"]); + return true; + } + } catch (e) { + if (missing(e)) return false; + return false; + } +} + +export interface Session { + backend?: Backend; + /** Backends this desktop would use, for the empty state. */ + wanted: Backend[]; + env: Env; +} + +export async function detect(): Promise { + const e = sessionEnv(); + const wanted = candidates(e); + for (const b of wanted) { + if (await works(b)) return { backend: b, wanted, env: e }; + } + return { wanted, env: e }; +} + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +// --------------------------------------------------------------------------- +// Workspaces + +export async function workspaces(b: Backend): Promise { + switch (b) { + case "hyprland": { + const [list, active] = await Promise.all([ + exec("hyprctl", ["-j", "workspaces"]), + exec("hyprctl", ["-j", "activeworkspace"]), + ]); + return numbered(hyprWorkspaces(list, active)); + } + case "sway": + return numbered(swayWorkspaces(await exec("swaymsg", ["-t", "get_workspaces", "-r"]))); + case "kde": { + const [desktops, current] = await Promise.all([kwinProperty("desktops"), kwinProperty("current")]); + const found = kwinDesktops(desktops, current); + if (found.length) return found; + const count = gdbusNumber(await kwinProperty("count")) ?? 4; + return numbered([], count); + } + case "gnome": { + // Window Calls doesn't tell which workspace is active; GNOME's settings say how many there are. + const dynamic = await exec("gsettings", ["get", "org.gnome.mutter", "dynamic-workspaces"]).catch(() => "true"); + const count = dynamic === "false" + ? Number( + await exec("gsettings", ["get", "org.gnome.desktop.wm.preferences", "num-workspaces"]) + .catch(() => "4"), + ) || 4 + : 4; + return numbered([], count); + } + case "x11": { + const { workspaces } = wmctrlDesktops(await exec("wmctrl", ["-d"])); + return workspaces; + } + } +} + +// --------------------------------------------------------------------------- +// Running actions + +/** Starts the script in the background once the launcher has hidden. */ +async function detach(s: Session, lines: string[], delayMs: number) { + const script = delayedScript(lines, delayMs); + try { + await exec("systemd-run", systemdRunArgs(`backslash-window-${Date.now()}`, script, s.env)); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new Error("systemd-run is needed to act on your window once the launcher has hidden"); + } + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: window actions run in the background with systemd-run --user"); + } + throw e; + } +} + +async function hyprAreaNow(): Promise { + const [monitors, active] = await Promise.all([ + exec("hyprctl", ["-j", "monitors"]), + exec("hyprctl", ["-j", "activewindow"]).catch(() => ""), + ]); + return hyprArea(monitors, hyprActiveMonitor(active)); +} + +async function x11Setup() { + const [desktops, monitors] = await Promise.all([ + exec("wmctrl", ["-d"]), + exec("xrandr", ["--listactivemonitors"]).catch(() => ""), + ]); + return { workarea: wmctrlDesktops(desktops).workarea, monitors: xrandrMonitors(monitors) }; +} + +/** The shell lines doing `a` on the focused window, prepared before the launcher hides. */ +async function linesFor(s: Session, a: WindowAction): Promise { + switch (s.backend) { + case "hyprland": { + const area = isTile(a) ? await hyprAreaNow() : undefined; + return [`hyprctl --batch ${q(hyprBatch(a, area))} >/dev/null`]; + } + case "sway": { + const area = isTile(a) || a === "maximize" + ? swayArea(await exec("swaymsg", ["-t", "get_workspaces", "-r"])) + : undefined; + return [`swaymsg ${q(swayCommand(a, area))} >/dev/null`]; + } + case "kde": + return [kwinShortcutLine(kwinShortcut(a))]; + case "x11": { + const { monitors, workarea } = await x11Setup(); + return x11Lines(a, monitors, workarea); + } + default: + throw new Error("No window manager to talk to"); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function gnomeWindow(): Promise { + const id = gnomeTarget(gvariantString(await gnomeCall("List"))); + if (id === undefined) throw new Error("No window to act on"); + return id; +} + +/** GNOME: the target is known, so act at once through Window Calls. */ +async function gnomeAct(a: WindowAction) { + const id = await gnomeWindow(); + switch (a) { + case "maximize": + await gnomeCall("Maximize", id); + return; + case "minimize": + await gnomeCall("Minimize", id); + return; + case "close": + await gnomeCall("Close", id); + return; + case "fullscreen": + throw new Error("GNOME's Window Calls extension can't make a window fullscreen: press F11 in the app"); + case "float": + throw new Error("Floating only means something in a tiling window manager"); + } + const details = gnomeDetails(gvariantString(await gnomeCall("Details", id))); + let area = details.area; + if (!area) { + // Older Window Calls: a maximized window's frame is the work area. + await gnomeCall("Maximize", id); + await sleep(200); + area = parseRect(gvariantString(await gnomeCall("GetFrameRect", id))); + } + if (!area) throw new Error("GNOME didn't tell the size of the screen: update the Window Calls extension"); + if (details.maximized || !details.area) await gnomeCall("Unmaximize", id); + const r = a === "center" + ? centered( + area, + details.frame?.width ?? Math.round(area.width * 0.6), + details.frame?.height ?? Math.round(area.height * 0.6), + ) + : region(area, a); + await gnomeCall("MoveResize", id, r.x, r.y, r.width, r.height); +} + +/** Does `a` to the window the user was in. */ +export async function act(s: Session, a: WindowAction, delayMs: number): Promise { + if (!s.backend) throw new Error("No supported window manager found"); + if (s.backend === "gnome") return await gnomeAct(a); + await detach(s, await linesFor(s, a), delayMs); +} + +/** Moves the window to workspace `n` and follows it. */ +export async function moveTo(s: Session, n: number, delayMs: number, follow = true): Promise { + switch (s.backend) { + case "hyprland": + return await detach(s, [ + `hyprctl dispatch ${follow ? "movetoworkspace" : "movetoworkspacesilent"} ${n} >/dev/null`, + ], delayMs); + case "sway": + return await detach( + s, + [`swaymsg ${ + q(`move container to workspace number ${n}${follow ? `, workspace number ${n}` : ""}`) + } >/dev/null`], + delayMs, + ); + case "kde": + return await detach( + s, + [ + kwinShortcutLine(kwinShortcut({ move: n })), + ...(follow ? [kwinShortcutLine(kwinShortcut({ switch: n }))] : []), + ], + delayMs, + ); + case "x11": + return await detach(s, [`wmctrl -r :ACTIVE: -t ${n - 1}`, ...(follow ? [`wmctrl -s ${n - 1}`] : [])], delayMs); + case "gnome": { + const id = await gnomeWindow(); + await gnomeCall("MoveToWorkspace", id, n - 1); + if (follow) await gnomeCall("Activate", id); + return; + } + default: + throw new Error("No supported window manager found"); + } +} + +/** Shows workspace `n`. Nothing depends on the focused window, so it runs right away. */ +export async function switchTo(s: Session, n: number): Promise { + switch (s.backend) { + case "hyprland": + await exec("hyprctl", ["dispatch", "workspace", String(n)]); + return; + case "sway": + await exec("swaymsg", [`workspace number ${n}`]); + return; + case "kde": + return await detach(s, [kwinShortcutLine(kwinShortcut({ switch: n }))], 0); + case "x11": + await exec("wmctrl", ["-s", String(n - 1)]); + return; + case "gnome": + throw new Error("GNOME doesn't let apps switch workspaces on Wayland: use Super+Page Up/Down"); + default: + throw new Error("No supported window manager found"); + } +} From 935e28b94dab188dcfe955d1240250c77a75e00e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:37:18 +0000 Subject: [PATCH 03/10] Todo.txt, Logseq, Joplin, Zotero, Anki and Countdowns Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/anki/anki.ts | 241 +++++++++ extensions/anki/backslash.toml | 66 +++ extensions/anki/commands/add.ts | 11 + extensions/anki/commands/decks.ts | 91 ++++ extensions/anki/commands/quick-add.ts | 27 + extensions/anki/commands/sync.ts | 11 + extensions/anki/lib.ts | 235 ++++++++ extensions/anki/lib_test.ts | 190 +++++++ extensions/countdowns/backslash.toml | 24 + extensions/countdowns/commands/add.ts | 5 + extensions/countdowns/commands/countdowns.ts | 30 + extensions/countdowns/countdowns.ts | 276 ++++++++++ extensions/countdowns/lib.ts | 289 ++++++++++ extensions/countdowns/lib_test.ts | 199 +++++++ extensions/joplin/backslash.toml | 33 ++ extensions/joplin/commands/new.ts | 14 + extensions/joplin/commands/search.ts | 97 ++++ extensions/joplin/joplin.ts | 342 ++++++++++++ extensions/joplin/lib.ts | 184 +++++++ extensions/joplin/lib_test.ts | 129 +++++ extensions/logseq/backslash.toml | 65 +++ extensions/logseq/commands/capture.ts | 9 + extensions/logseq/commands/graphs.ts | 50 ++ extensions/logseq/commands/journal.ts | 4 + extensions/logseq/commands/search.ts | 76 +++ extensions/logseq/graph.ts | 324 +++++++++++ extensions/logseq/lib.ts | 355 ++++++++++++ extensions/logseq/lib_test.ts | 178 ++++++ extensions/todo-txt/backslash.toml | 57 ++ extensions/todo-txt/commands/add.ts | 15 + extensions/todo-txt/commands/tasks.ts | 63 +++ extensions/todo-txt/lib.ts | 518 ++++++++++++++++++ extensions/todo-txt/lib_test.ts | 347 ++++++++++++ extensions/todo-txt/todo.ts | 382 +++++++++++++ extensions/zotero/backslash.toml | 26 + extensions/zotero/commands/search.ts | 27 + extensions/zotero/lib.ts | 541 +++++++++++++++++++ extensions/zotero/lib_test.ts | 366 +++++++++++++ extensions/zotero/source.ts | 156 ++++++ extensions/zotero/zotero.ts | 248 +++++++++ 40 files changed, 6301 insertions(+) create mode 100644 extensions/anki/anki.ts create mode 100644 extensions/anki/backslash.toml create mode 100644 extensions/anki/commands/add.ts create mode 100644 extensions/anki/commands/decks.ts create mode 100644 extensions/anki/commands/quick-add.ts create mode 100644 extensions/anki/commands/sync.ts create mode 100644 extensions/anki/lib.ts create mode 100644 extensions/anki/lib_test.ts create mode 100644 extensions/countdowns/backslash.toml create mode 100644 extensions/countdowns/commands/add.ts create mode 100644 extensions/countdowns/commands/countdowns.ts create mode 100644 extensions/countdowns/countdowns.ts create mode 100644 extensions/countdowns/lib.ts create mode 100644 extensions/countdowns/lib_test.ts create mode 100644 extensions/joplin/backslash.toml create mode 100644 extensions/joplin/commands/new.ts create mode 100644 extensions/joplin/commands/search.ts create mode 100644 extensions/joplin/joplin.ts create mode 100644 extensions/joplin/lib.ts create mode 100644 extensions/joplin/lib_test.ts create mode 100644 extensions/logseq/backslash.toml create mode 100644 extensions/logseq/commands/capture.ts create mode 100644 extensions/logseq/commands/graphs.ts create mode 100644 extensions/logseq/commands/journal.ts create mode 100644 extensions/logseq/commands/search.ts create mode 100644 extensions/logseq/graph.ts create mode 100644 extensions/logseq/lib.ts create mode 100644 extensions/logseq/lib_test.ts create mode 100644 extensions/todo-txt/backslash.toml create mode 100644 extensions/todo-txt/commands/add.ts create mode 100644 extensions/todo-txt/commands/tasks.ts create mode 100644 extensions/todo-txt/lib.ts create mode 100644 extensions/todo-txt/lib_test.ts create mode 100644 extensions/todo-txt/todo.ts create mode 100644 extensions/zotero/backslash.toml create mode 100644 extensions/zotero/commands/search.ts create mode 100644 extensions/zotero/lib.ts create mode 100644 extensions/zotero/lib_test.ts create mode 100644 extensions/zotero/source.ts create mode 100644 extensions/zotero/zotero.ts diff --git a/extensions/anki/anki.ts b/extensions/anki/anki.ts new file mode 100644 index 0000000..5149cda --- /dev/null +++ b/extensions/anki/anki.ts @@ -0,0 +1,241 @@ +// Talking to Anki through AnkiConnect, and the shared views. + +import { + type Action, + action, + close, + type Context, + copy, + exec, + field, + form, + type FormSpec, + Icons, + type Item, + open, + pop, + storage, + toast, +} from "@backslash/api"; +import { + ANKICONNECT_CODE, + ANKICONNECT_URL, + BASIC, + buildDecks, + CLOZE, + type Deck, + type DeckStats, + ENDPOINT, + installHint, + noteFields, + noteParams, + orderModels, + parseTags, + request, + REVERSED, + toHtml, + unwrap, + visibleDecks, +} from "./lib.ts"; + +/** Anki is closed, or AnkiConnect isn't installed. */ +export class OfflineError extends Error {} + +export async function anki( + ctx: Pick, + action: string, + params: Record = {}, + signal?: AbortSignal, +): Promise { + const key = String(ctx.preferences.apiKey ?? "").trim() || undefined; + let res: Response; + try { + res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request(action, params, key)), + signal: signal ?? AbortSignal.timeout(action === "sync" ? 120_000 : 10_000), + }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw e; + throw new OfflineError("Anki isn't running, or AnkiConnect isn't installed"); + } + if (!res.ok) throw new Error(`AnkiConnect answered ${res.status}`); + return unwrap(await res.json()); +} + +export async function loadDecks(ctx: Pick, signal?: AbortSignal): Promise { + const names = await anki>(ctx, "deckNamesAndIds", {}, signal); + const stats = await anki>(ctx, "getDeckStats", { decks: Object.keys(names) }, signal); + return visibleDecks(buildDecks(names, stats)); +} + +// --- Opening Anki ------------------------------------------------------------ + +const HOME = Deno.env.get("HOME") ?? ""; + +/** Where Anki is installed, in the order tried; the same paths as `[permissions] read`. */ +const LAUNCHERS = [ + "/usr/bin/anki", + "/usr/local/bin/anki", + `${HOME}/.local/bin/anki`, + "/var/lib/flatpak/exports/bin/net.ankiweb.Anki", + `${HOME}/.local/share/flatpak/exports/bin/net.ankiweb.Anki`, + "/snap/bin/anki", +]; + +export async function findAnki(): Promise { + for (const path of LAUNCHERS) { + if (await Deno.stat(path).then((s) => s.isFile, () => false)) return path; + } + return undefined; +} + +/** Brings Anki to the front, or starts it in the background (it outlives this command). */ +export async function openAnki(ctx: Pick): Promise { + try { + await anki(ctx, "guiDeckBrowser"); + return "Anki opened"; + } catch (e) { + if (!(e instanceof OfflineError)) throw e; + } + const path = await findAnki(); + if (!path) throw new Error("Anki isn't installed: get it from apps.ankiweb.net or Flathub (net.ankiweb.Anki)"); + await exec("systemd-run", ["--user", "--collect", "--quiet", `--unit=anki-${Date.now()}`, path]); + return "Starting Anki…"; +} + +export async function sync(ctx: Pick): Promise { + await anki(ctx, "sync"); + return "Anki synced with AnkiWeb"; +} + +// --- Empty state ------------------------------------------------------------- + +export async function offlineItems(ctx: Pick): Promise { + const installed = await findAnki(); + const osRelease = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const steps = [ + "## Connect Anki", + "", + "Backslash talks to Anki through the **AnkiConnect** add-on, while Anki is open.", + "", + "1. In Anki: **Tools → Add-ons → Get Add-ons…**", + `2. Paste the code **${ANKICONNECT_CODE}** and press OK`, + "3. Restart Anki, and keep it open (it can stay minimized)", + "", + "If you set an API key in AnkiConnect's config, enter it in **Settings → Extensions → Anki**.", + ]; + const items: Item[] = [{ + id: "offline", + title: installed ? "Anki isn't running, or AnkiConnect isn't installed" : "Anki isn't installed", + subtitle: installed ? `Add-on code ${ANKICONNECT_CODE}` : "Install Anki, then the AnkiConnect add-on", + icon: { source: Icons.plug, tint: "orange" }, + detail: { + markdown: installed + ? steps.join("\n") + : ["## Install Anki", "", ...installHint(osRelease), "", ...steps.slice(2)].join("\n"), + }, + actions: [ + ...(installed + ? [action("Open Anki", async () => close(await openAnki(ctx)), { icon: Icons.external })] + : [open("https://apps.ankiweb.net", "Download Anki", { icon: Icons.download })]), + open(ANKICONNECT_URL, "AnkiConnect on AnkiWeb", { icon: Icons.globe }), + copy(ANKICONNECT_CODE, "Copy Add-on Code", { shortcut: "c" }), + ], + }]; + return items; +} + +// --- Adding cards ------------------------------------------------------------ + +export async function lastDeck(): Promise { + const d = await storage.get("lastDeck").catch(() => undefined); + return typeof d === "string" ? d : undefined; +} + +async function lastModel(): Promise { + const d = await storage.get("lastModel").catch(() => undefined); + return typeof d === "string" ? d : undefined; +} + +export async function addCard( + ctx: Pick, + o: { deck: string; model: string; front: string; back: string; tags: string[]; rememberModel?: boolean }, +): Promise { + const names = await anki(ctx, "modelFieldNames", { modelName: o.model }); + const fields = noteFields(names, toHtml(o.front), toHtml(o.back)); + const id = await anki(ctx, "addNote", noteParams({ deck: o.deck, model: o.model, fields, tags: o.tags })); + await storage.set("lastDeck", o.deck); + // Quick adds pick their type from the text: only the form's choice is remembered. + if (o.rememberModel) await storage.set("lastModel", o.model); + return id; +} + +/** The deck to add to: the remembered one if it still exists, else the first one that isn't empty "Default". */ +export function pickDeck(decks: string[], remembered: string | undefined): string { + if (remembered && decks.includes(remembered)) return remembered; + return decks.find((d) => d !== "Default") ?? decks[0] ?? "Default"; +} + +export async function addCardForm( + ctx: Pick, + o: { deck?: string; front?: string; standalone?: boolean } = {}, +): Promise { + const [decks, models, remembered, model] = await Promise.all([ + anki(ctx, "deckNames"), + anki(ctx, "modelNames").then(orderModels, () => [BASIC, REVERSED, CLOZE]), + lastDeck(), + lastModel(), + ]); + decks.sort((a, b) => a.localeCompare(b)); + return form({ + title: "Add Card", + submitTitle: "Add Card", + fields: [ + field.dropdown("deck", "Deck", decks.map((d) => ({ value: d, title: d.replace(/::/g, " › ") })), { + value: o.deck ?? pickDeck(decks, remembered), + }), + field.dropdown("model", "Note type", models, { + value: model && models.includes(model) ? model : models[0], + }), + field.textArea("front", "Front", { + required: true, + placeholder: "Question, or a cloze: The capital of France is {{c1::Paris}}", + value: o.front ?? "", + }), + field.textArea("back", "Back", { placeholder: "Answer (Back Extra for a cloze)" }), + field.text("tags", "Tags", { placeholder: "french vocabulary" }), + ], + async submit(values) { + const front = String(values.front ?? "").trim(); + if (!front) throw new Error("The front of the card is empty"); + const modelName = String(values.model || BASIC); + if (/cloze/i.test(modelName) && !/\{\{c\d+::/.test(front)) { + throw new Error("A cloze card needs a deletion: {{c1::hidden text}}"); + } + const deck = String(values.deck || pickDeck(decks, remembered)); + await addCard(ctx, { + deck, + model: modelName, + front, + back: String(values.back ?? ""), + tags: parseTags(String(values.tags ?? "")), + rememberModel: true, + }); + await toast(`Card added to ${deck.replace(/::/g, " › ")}`, { style: "success" }); + return o.standalone ? close(`Added to ${deck.replace(/::/g, " › ")}`) : pop(); + }, + }); +} + +export function ankiActions(ctx: Pick): Action[] { + return [ + action("Sync", async () => close(await sync(ctx)), { + icon: Icons.refresh, + shortcut: "s", + section: "Anki", + }), + action("Open Anki", async () => close(await openAnki(ctx)), { icon: Icons.external, section: "Anki" }), + ]; +} diff --git a/extensions/anki/backslash.toml b/extensions/anki/backslash.toml new file mode 100644 index 0000000..3d96ee7 --- /dev/null +++ b/extensions/anki/backslash.toml @@ -0,0 +1,66 @@ +[extension] +id = "anki" +title = "Anki" +description = "Your Anki decks with today's new, learning and review counts, quick card creation and sync, through AnkiConnect" +version = "1.0.0" +author = "Backslash" +icon = "bs-layers" +license = "MIT" + +# AnkiConnect listens on 127.0.0.1:8765 while Anki runs. When Anki is +# closed, "Open Anki" starts it in the background with systemd-run; the read +# paths are only checked to find where Anki is installed (package, official +# installer, pipx, Flatpak system or user, Snap). +[permissions] +net = ["127.0.0.1:8765"] +run = ["systemd-run"] +read = [ + "/etc/os-release", + "/usr/bin/anki", + "/usr/local/bin/anki", + "~/.local/bin/anki", + "/var/lib/flatpak/exports/bin/net.ankiweb.Anki", + "~/.local/share/flatpak/exports/bin/net.ankiweb.Anki", + "/snap/bin/anki", +] +env = ["HOME"] + +[[commands]] +name = "decks" +title = "Anki Decks" +description = "New, learning and due cards per deck; study, add cards, sync" +icon = "bs-layers" +keywords = ["anki", "flashcards", "decks", "review", "study", "srs", "cartes", "réviser", "paquets", "mémoriser"] + +[[commands]] +name = "add" +title = "Add Anki Card" +description = "Deck, note type (Basic, reversed, Cloze), front, back and tags" +icon = "bs-plus" +keywords = ["anki", "card", "add", "new", "flashcard", "cloze", "carte", "ajouter", "nouvelle"] + +[[commands]] +name = "quick-add" +title = "Quick Add Anki Card" +description = "“front :: back #tag” into the last deck used; a {{c1::cloze}} makes a Cloze card" +icon = "bs-bolt" +mode = "no-view" +keywords = ["anki", "quick", "add", "card", "capture", "carte", "rapide", "ajouter"] +[[commands.arguments]] +name = "card" +placeholder = "bonjour :: hello #french" +required = true + +[[commands]] +name = "sync" +title = "Sync Anki" +description = "Sync your collection with AnkiWeb" +icon = "bs-refresh" +mode = "no-view" +keywords = ["anki", "sync", "ankiweb", "synchroniser"] + +[[preferences]] +name = "apiKey" +title = "AnkiConnect API key" +description = "Only if you set “apiKey” in AnkiConnect's config (Tools → Add-ons → AnkiConnect → Config)" +type = "secret" diff --git a/extensions/anki/commands/add.ts b/extensions/anki/commands/add.ts new file mode 100644 index 0000000..e199071 --- /dev/null +++ b/extensions/anki/commands/add.ts @@ -0,0 +1,11 @@ +import { type Context, list } from "@backslash/api"; +import { addCardForm, OfflineError, offlineItems } from "../anki.ts"; + +export default async (ctx: Context) => { + try { + return await addCardForm(ctx, { front: ctx.query.trim() || undefined, standalone: true }); + } catch (e) { + if (!(e instanceof OfflineError)) throw e; + return list({ items: await offlineItems(ctx) }); + } +}; diff --git a/extensions/anki/commands/decks.ts b/extensions/anki/commands/decks.ts new file mode 100644 index 0000000..254456d --- /dev/null +++ b/extensions/anki/commands/decks.ts @@ -0,0 +1,91 @@ +import { + action, + close, + type Context, + copy, + Icons, + type Item, + list, + meta, + refresh, + updateCommandMetadata, +} from "@backslash/api"; +import { type Deck, dueSubtitle, dueToday, plural } from "../lib.ts"; +import { addCardForm, anki, ankiActions, loadDecks, OfflineError, offlineItems } from "../anki.ts"; + +let footer = ""; + +function deckItem(ctx: Context, d: Deck): Item { + const due = d.reviewCount + d.learnCount; + const todo = due + d.newCount; + return { + id: String(d.id), + title: d.leaf, + subtitle: d.parent || undefined, + icon: { source: d.depth ? Icons.layers : Icons.book, tint: todo ? "blue" : "gray" }, + keywords: d.name.split("::"), + accessories: [ + { text: String(d.newCount), color: d.newCount ? "blue" : "gray", tooltip: "New" }, + { text: String(d.learnCount), color: d.learnCount ? "red" : "gray", tooltip: "Learning" }, + { text: String(d.reviewCount), color: d.reviewCount ? "green" : "gray", tooltip: "To review" }, + ], + detail: { + stats: [ + { label: "New", value: d.newCount, color: "blue" }, + { label: "Learning", value: d.learnCount, color: "red" }, + { label: "To Review", value: d.reviewCount, color: "green" }, + ], + metadata: [ + meta.text("Deck", d.name.replace(/::/g, " › "), { icon: Icons.book }), + meta.text("Cards", d.total.toLocaleString("en"), { icon: Icons.layers }), + meta.text("Today", todo ? `${plural(todo, "card")} to study` : "Nothing left: well done", { + icon: todo ? Icons.clock : Icons.done, + color: todo ? undefined : "green", + }), + meta.separator(), + meta.text("Note", "Counts follow the deck's daily limits and include its subdecks"), + ], + }, + actions: [ + action("Study Now", async () => { + await anki(ctx, "guiDeckReview", { name: d.name }); + return close(`Studying ${d.leaf}`); + }, { icon: Icons.play }), + action("Add Card to Deck", () => addCardForm(ctx, { deck: d.name }), { icon: Icons.plus, shortcut: "n" }), + action("Open Deck Overview", async () => { + await anki(ctx, "guiDeckOverview", { name: d.name }); + return close(); + }, { icon: Icons.eye, shortcut: "o" }), + action("Browse Cards", async () => { + await anki(ctx, "guiBrowse", { query: `"deck:${d.name.replace(/"/g, '\\"')}"` }); + return close(); + }, { icon: Icons.search, shortcut: "b" }), + copy(d.name, "Copy Deck Name", { shortcut: "c", section: "Copy" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "Anki" }), + ...ankiActions(ctx), + ], + }; +} + +export default list({ + placeholder: "Search decks…", + empty: { title: "No deck", icon: Icons.book }, + status: () => footer, + async items(ctx) { + try { + const decks = await loadDecks(ctx, ctx.signal); + const subtitle = dueSubtitle(decks); + updateCommandMetadata({ subtitle: subtitle ?? "Nothing due today" }).catch(() => {}); + const t = dueToday(decks); + footer = `${plural(t.review + t.learn, "card")} due today · ${plural(t.new, "new card")} · ${ + plural(decks.length, "deck") + }`; + return decks.map((d) => deckItem(ctx, d)); + } catch (e) { + if (!(e instanceof OfflineError)) throw e; + footer = "AnkiConnect: 127.0.0.1:8765"; + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return offlineItems(ctx); + } + }, +}); diff --git a/extensions/anki/commands/quick-add.ts b/extensions/anki/commands/quick-add.ts new file mode 100644 index 0000000..eb97581 --- /dev/null +++ b/extensions/anki/commands/quick-add.ts @@ -0,0 +1,27 @@ +import { run } from "@backslash/api"; +import { BASIC, CLOZE, orderModels, parseQuickAdd } from "../lib.ts"; +import { addCard, anki, lastDeck, OfflineError, pickDeck } from "../anki.ts"; + +// "Quick Add Anki Card bonjour :: hello #french" → a Basic card in the last deck used. +export default run<{ card: string }>(async (ctx) => { + const card = parseQuickAdd(String(ctx.args.card ?? ctx.query ?? "")); + try { + const [decks, remembered, models] = await Promise.all([ + anki(ctx, "deckNames"), + lastDeck(), + anki(ctx, "modelNames"), + ]); + const deck = pickDeck(decks, remembered); + const model = card.cloze + ? (models.includes(CLOZE) ? CLOZE : models.find((m) => /cloze/i.test(m))) + : (models.includes(BASIC) ? BASIC : orderModels(models).find((m) => !/cloze/i.test(m))); + if (!model) throw new Error(`No ${card.cloze ? "Cloze" : "Basic"} note type in this collection: use Add Anki Card`); + await addCard(ctx, { deck, model, front: card.front, back: card.back, tags: card.tags }); + return `Added to ${deck.replace(/::/g, " › ")}`; + } catch (e) { + if (e instanceof OfflineError) { + throw new Error("Open Anki with the AnkiConnect add-on (code 2055492159) to add cards"); + } + throw e; + } +}); diff --git a/extensions/anki/commands/sync.ts b/extensions/anki/commands/sync.ts new file mode 100644 index 0000000..ffba9f7 --- /dev/null +++ b/extensions/anki/commands/sync.ts @@ -0,0 +1,11 @@ +import { run } from "@backslash/api"; +import { OfflineError, sync } from "../anki.ts"; + +export default run(async (ctx) => { + try { + return await sync(ctx); + } catch (e) { + if (e instanceof OfflineError) throw new Error("Open Anki with the AnkiConnect add-on (code 2055492159) to sync"); + throw e; + } +}); diff --git a/extensions/anki/lib.ts b/extensions/anki/lib.ts new file mode 100644 index 0000000..97954db --- /dev/null +++ b/extensions/anki/lib.ts @@ -0,0 +1,235 @@ +// AnkiConnect (https://foosoft.net/projects/anki-connect/) responses and +// Anki-specific text handling. Pure functions, no I/O. + +export const ANKICONNECT_CODE = "2055492159"; +export const ANKICONNECT_URL = "https://ankiweb.net/shared/info/2055492159"; +export const ENDPOINT = "http://127.0.0.1:8765"; + +export interface DeckStats { + deck_id: number; + name: string; + new_count: number; + learn_count: number; + review_count: number; + total_in_deck: number; +} + +export interface Deck { + id: number; + /** Full name, `Languages::Japanese::Kanji`. */ + name: string; + /** Last part, `Kanji`. */ + leaf: string; + /** `Languages › Japanese`, or empty for a top-level deck. */ + parent: string; + depth: number; + newCount: number; + learnCount: number; + reviewCount: number; + total: number; +} + +/** The request body AnkiConnect expects. */ +export function request(action: string, params: Record = {}, key?: string) { + return { action, version: 6, params, ...(key ? { key } : {}) }; +} + +/** The result of an AnkiConnect reply, or its error thrown. */ +export function unwrap(reply: unknown): T { + if (!reply || typeof reply !== "object" || !("result" in reply) || !("error" in reply)) { + throw new Error("Unexpected answer from AnkiConnect"); + } + const r = reply as { result: T; error: string | null }; + if (r.error) throw new Error(friendlyError(r.error)); + return r.result; +} + +export function friendlyError(error: string): string { + if (/duplicate/i.test(error)) return "This card already exists in Anki (same first field)"; + if (/valid api key/i.test(error)) return "AnkiConnect wants an API key: set it in Settings → Extensions → Anki"; + if (/deck was not found|deck not found/i.test(error)) return "That deck doesn't exist in Anki any more"; + if (/model was not found/i.test(error)) return "That note type doesn't exist in Anki"; + if (/empty/i.test(error)) return "The first field of the card is empty"; + return `Anki: ${error}`; +} + +/** Decks from `deckNamesAndIds` and `getDeckStats`, in Anki's tree order. */ +export function buildDecks(names: Record, stats: Record): Deck[] { + const byId = new Map(Object.values(stats ?? {}).map((s) => [s.deck_id, s])); + return Object.entries(names ?? {}) + .map(([name, id]) => { + const parts = name.split("::"); + const s = byId.get(id); + return { + id, + name, + leaf: parts[parts.length - 1], + parent: parts.slice(0, -1).join(" › "), + depth: parts.length - 1, + newCount: s?.new_count ?? 0, + learnCount: s?.learn_count ?? 0, + reviewCount: s?.review_count ?? 0, + total: s?.total_in_deck ?? 0, + }; + }) + .sort((a, b) => compareDeckNames(a.name, b.name)); +} + +/** Tree order: `A`, `A::B`, `A::C`, `B`, case-insensitive like Anki. */ +export function compareDeckNames(a: string, b: string): number { + const pa = a.toLowerCase().split("::"); + const pb = b.toLowerCase().split("::"); + for (let i = 0; i < Math.min(pa.length, pb.length); i++) { + const c = pa[i].localeCompare(pb[i]); + if (c) return c; + } + return pa.length - pb.length; +} + +/** Anki hides the empty "Default" deck when there are others. */ +export function visibleDecks(decks: Deck[]): Deck[] { + if (decks.length <= 1) return decks; + return decks.filter((d) => !(d.id === 1 && d.total === 0 && !decks.some((x) => x.name.startsWith("Default::")))); +} + +/** Cards to study today: counts of parent decks include their children, so only top-level decks are summed. */ +export function dueToday(decks: Deck[]): { review: number; learn: number; new: number } { + const top = decks.filter((d) => d.depth === 0); + return { + review: top.reduce((n, d) => n + d.reviewCount, 0), + learn: top.reduce((n, d) => n + d.learnCount, 0), + new: top.reduce((n, d) => n + d.newCount, 0), + }; +} + +/** "42 due today", "42 due today · 10 new", or undefined when there is nothing to do. */ +export function dueSubtitle(decks: Deck[]): string | undefined { + const d = dueToday(decks); + const due = d.review + d.learn; + if (!due && !d.new) return undefined; + return [due ? `${due} due today` : "", d.new ? `${d.new} new` : ""].filter(Boolean).join(" · "); +} + +// --- Notes ------------------------------------------------------------------- + +export const BASIC = "Basic"; +export const REVERSED = "Basic (and reversed card)"; +export const CLOZE = "Cloze"; + +export const isCloze = (text: string) => /\{\{c\d+::/.test(text); + +/** Note types for the form: the three standard ones first, then the user's own. */ +export function orderModels(models: string[]): string[] { + const standard = [BASIC, REVERSED, CLOZE].filter((m) => models.includes(m)); + return [...standard, ...models.filter((m) => !standard.includes(m)).sort((a, b) => a.localeCompare(b))]; +} + +/** `tag1, tag2 #tag3` → `["tag1", "tag2", "tag3"]`; Anki tags have no spaces. */ +export function parseTags(input: string): string[] { + return [...new Set(input.split(/[\s,]+/).map((t) => t.replace(/^#/, "")).filter(Boolean))]; +} + +export interface QuickCard { + front: string; + back: string; + tags: string[]; + cloze: boolean; +} + +/** + * `front :: back #tag`: the first ` :: ` outside a `{{c1::…}}` cloze splits + * the sides; `#words` at the end are tags. A text with a cloze and no back + * is a cloze card. + */ +export function parseQuickAdd(input: string): QuickCard { + let text = input.trim(); + const tags: string[] = []; + for (;;) { + const m = /\s+#([^\s#]+)$/.exec(text); + if (!m) break; + tags.unshift(m[1]); + text = text.slice(0, m.index).trimEnd(); + } + // A spaced " :: " wins, so `std::vector :: dynamic array` keeps its `::`. + const at = (spaced: boolean) => { + let depth = 0; + for (let i = 0; i < text.length; i++) { + if (text.startsWith("{{", i)) { + depth++; + i++; + } else if (text.startsWith("}}", i)) { + depth = Math.max(0, depth - 1); + i++; + } else if ( + depth === 0 && text.startsWith("::", i) && + (!spaced || (/\s/.test(text[i - 1] ?? "") && /\s/.test(text[i + 2] ?? ""))) + ) { + return i; + } + } + return -1; + }; + const spacedAt = at(true); + const split = spacedAt >= 0 ? spacedAt : at(false); + const front = (split < 0 ? text : text.slice(0, split)).trim(); + const back = split < 0 ? "" : text.slice(split + 2).trim(); + const cloze = isCloze(front); + if (!front) throw new Error("Type the front of the card: “front :: back”"); + if (!back && !cloze) throw new Error("Separate the two sides with “::”, e.g. “bonjour :: hello”"); + return { front, back, tags, cloze }; +} + +/** The fields of a note: the first two fields of the note type get the front and back. */ +export function noteFields(fieldNames: string[], front: string, back: string): Record { + const names = fieldNames.length ? fieldNames : ["Front", "Back"]; + const fields: Record = {}; + for (const n of names) fields[n] = ""; + fields[names[0]] = front; + if (names[1]) fields[names[1]] = back; + return fields; +} + +/** Plain text typed in the launcher, as Anki's HTML fields: escaped, line breaks kept. */ +export function toHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\r?\n/g, "
"); +} + +export function noteParams(o: { deck: string; model: string; fields: Record; tags: string[] }) { + return { + note: { + deckName: o.deck, + modelName: o.model, + fields: o.fields, + tags: o.tags, + options: { allowDuplicate: false, duplicateScope: "deck" }, + }, + }; +} + +// --- Installing Anki --------------------------------------------------------- + +/** `ID` and `ID_LIKE` of /etc/os-release. */ +export function osIds(osRelease: string): string[] { + const get = (k: string) => new RegExp(`^${k}=(.*)$`, "m").exec(osRelease)?.[1]?.replace(/^["']|["']$/g, "") ?? ""; + return `${get("ID")} ${get("ID_LIKE")}`.toLowerCase().split(/\s+/).filter(Boolean); +} + +/** How to install Anki on this distribution; Flathub always works and is up to date. */ +export function installHint(osRelease: string): string[] { + const ids = osIds(osRelease); + const lines = ["- Flathub (any distribution): `flatpak install flathub net.ankiweb.Anki`"]; + if (ids.some((i) => ["arch", "manjaro", "endeavouros"].includes(i))) lines.push("- Arch (AUR): `yay -S anki-bin`"); + else if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) lines.push("- Fedora: `sudo dnf install anki`"); + else if (ids.some((i) => ["debian", "ubuntu"].includes(i))) { + lines.push("- Debian, Ubuntu: `sudo apt install anki` (often an older version)"); + } else if (ids.some((i) => ["opensuse", "suse"].includes(i))) lines.push("- openSUSE: `sudo zypper install anki`"); + else if (ids.includes("nixos")) lines.push("- NixOS: add `anki` to your packages"); + lines.push("- Or the official build from [apps.ankiweb.net](https://apps.ankiweb.net)"); + return lines; +} + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; diff --git a/extensions/anki/lib_test.ts b/extensions/anki/lib_test.ts new file mode 100644 index 0000000..dcad697 --- /dev/null +++ b/extensions/anki/lib_test.ts @@ -0,0 +1,190 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + buildDecks, + compareDeckNames, + dueSubtitle, + dueToday, + friendlyError, + installHint, + noteFields, + noteParams, + orderModels, + parseQuickAdd, + parseTags, + request, + toHtml, + unwrap, + visibleDecks, +} from "./lib.ts"; +import { pickDeck } from "./anki.ts"; + +// What AnkiConnect 6 answers for a small collection. +const NAMES = { + "Default": 1, + "Languages": 1700000000001, + "Languages::Japanese": 1700000000002, + "Geography": 1700000000003, + "languages::French": 1700000000004, +}; +const STATS = { + "1": { deck_id: 1, name: "Default", new_count: 0, learn_count: 0, review_count: 0, total_in_deck: 0 }, + "1700000000001": { + deck_id: 1700000000001, + name: "Languages", + new_count: 20, + learn_count: 3, + review_count: 41, + total_in_deck: 900, + }, + "1700000000002": { + deck_id: 1700000000002, + name: "Languages::Japanese", + new_count: 10, + learn_count: 2, + review_count: 30, + total_in_deck: 600, + }, + "1700000000004": { + deck_id: 1700000000004, + name: "languages::French", + new_count: 10, + learn_count: 1, + review_count: 11, + total_in_deck: 300, + }, + "1700000000003": { + deck_id: 1700000000003, + name: "Geography", + new_count: 5, + learn_count: 0, + review_count: 7, + total_in_deck: 250, + }, +}; + +Deno.test("requests and replies", () => { + assertEquals(request("deckNames"), { action: "deckNames", version: 6, params: {} }); + assertEquals(request("sync", {}, "k"), { action: "sync", version: 6, params: {}, key: "k" }); + assertEquals(unwrap({ result: 5, error: null }), 5); + assertThrows( + () => unwrap({ result: null, error: "cannot create note because it is a duplicate" }), + Error, + "already exists", + ); + assertThrows(() => unwrap({ nope: 1 }), Error, "Unexpected"); + assertEquals( + friendlyError("valid api key must be provided"), + "AnkiConnect wants an API key: set it in Settings → Extensions → Anki", + ); + assertEquals(friendlyError("collection is not available"), "Anki: collection is not available"); +}); + +Deno.test("decks in tree order with their counts", () => { + const decks = buildDecks(NAMES, STATS); + assertEquals(decks.map((d) => d.name), [ + "Default", + "Geography", + "Languages", + "languages::French", + "Languages::Japanese", + ]); + const jp = decks.find((d) => d.leaf === "Japanese")!; + assertEquals([jp.parent, jp.depth, jp.newCount, jp.learnCount, jp.reviewCount, jp.total], [ + "Languages", + 1, + 10, + 2, + 30, + 600, + ]); + // The empty Default deck is hidden, as in Anki. + assertEquals(visibleDecks(decks).map((d) => d.leaf), ["Geography", "Languages", "French", "Japanese"]); + assertEquals(visibleDecks(buildDecks({ Default: 1 }, {})).length, 1); + assertEquals(compareDeckNames("A::B", "A"), 1); + assertEquals(compareDeckNames("a", "B") < 0, true); +}); + +Deno.test("due today counts top-level decks only", () => { + const decks = buildDecks(NAMES, STATS); + assertEquals(dueToday(decks), { review: 48, learn: 3, new: 25 }); + assertEquals(dueSubtitle(decks), "51 due today · 25 new"); + assertEquals(dueSubtitle(buildDecks({ Default: 1 }, {})), undefined); +}); + +Deno.test("quick add: sides, tags and clozes", () => { + assertEquals(parseQuickAdd("bonjour :: hello"), { front: "bonjour", back: "hello", tags: [], cloze: false }); + assertEquals(parseQuickAdd(" chat::cat #french #animals "), { + front: "chat", + back: "cat", + tags: ["french", "animals"], + cloze: false, + }); + assertEquals(parseQuickAdd("The capital of France is {{c1::Paris}}"), { + front: "The capital of France is {{c1::Paris}}", + back: "", + tags: [], + cloze: true, + }); + assertEquals(parseQuickAdd("{{c1::Paris::city}} is in {{c2::France}} :: Europe #geo"), { + front: "{{c1::Paris::city}} is in {{c2::France}}", + back: "Europe", + tags: ["geo"], + cloze: true, + }); + assertEquals(parseQuickAdd("C# :: a language").front, "C#"); + assertEquals(parseQuickAdd("std::vector :: C++ dynamic array"), { + front: "std::vector", + back: "C++ dynamic array", + tags: [], + cloze: false, + }); + assertEquals(parseQuickAdd("chat::cat").back, "cat"); + assertThrows(() => parseQuickAdd("no separator"), Error, "::"); + assertThrows(() => parseQuickAdd(":: back"), Error, "front"); +}); + +Deno.test("fields, tags, HTML and note types", () => { + assertEquals(noteFields(["Front", "Back"], "q", "a"), { Front: "q", Back: "a" }); + assertEquals(noteFields(["Text", "Back Extra"], "{{c1::x}}", ""), { Text: "{{c1::x}}", "Back Extra": "" }); + assertEquals(noteFields(["Vorderseite", "Rückseite", "Notes"], "q", "a"), { + Vorderseite: "q", + Rückseite: "a", + Notes: "", + }); + assertEquals(noteFields([], "q", "a"), { Front: "q", Back: "a" }); + assertEquals(parseTags("french, vocab #verbs french"), ["french", "vocab", "verbs"]); + assertEquals(toHtml("a < b & c\nnext"), "a < b & c
next"); + assertEquals( + orderModels(["Image Occlusion", "Cloze", "Basic (type in the answer)", "Basic", "Basic (and reversed card)"]), + [ + "Basic", + "Basic (and reversed card)", + "Cloze", + "Basic (type in the answer)", + "Image Occlusion", + ], + ); + assertEquals(noteParams({ deck: "D", model: "Basic", fields: { Front: "q" }, tags: ["t"] }), { + note: { + deckName: "D", + modelName: "Basic", + fields: { Front: "q" }, + tags: ["t"], + options: { allowDuplicate: false, duplicateScope: "deck" }, + }, + }); +}); + +Deno.test("the last deck is remembered while it exists", () => { + assertEquals(pickDeck(["Default", "French"], "French"), "French"); + assertEquals(pickDeck(["Default", "French"], "Gone"), "French"); + assertEquals(pickDeck(["Default"], undefined), "Default"); + assertEquals(pickDeck([], undefined), "Default"); +}); + +Deno.test("install hints per distribution", () => { + assertEquals(installHint("ID=fedora\nVERSION_ID=41").some((l) => l.includes("dnf")), true); + assertEquals(installHint('ID=linuxmint\nID_LIKE="ubuntu debian"').some((l) => l.includes("apt")), true); + assertEquals(installHint("ID=arch").some((l) => l.includes("anki-bin")), true); + assertEquals(installHint("").length, 2); +}); diff --git a/extensions/countdowns/backslash.toml b/extensions/countdowns/backslash.toml new file mode 100644 index 0000000..aec7f6f --- /dev/null +++ b/extensions/countdowns/backslash.toml @@ -0,0 +1,24 @@ +[extension] +id = "countdowns" +title = "Countdowns" +description = "Count down to vacations, launches and birthdays, and pin the next one to the root search" +version = "1.0.0" +author = "Backslash" +icon = "bs-hourglass" +license = "MIT" + +# Countdowns live in the extension's own storage: no permission needed. + +[[commands]] +name = "countdowns" +title = "Countdowns" +description = "Days left to your events, soonest first; pin one to see it here" +icon = "bs-hourglass" +keywords = ["countdown", "days until", "event", "birthday", "anniversary", "vacation", "compte à rebours", "jours", "anniversaire", "vacances"] + +[[commands]] +name = "add" +title = "Add Countdown" +description = "A date to count down to, once or every year" +icon = "bs-plus" +keywords = ["countdown", "new", "add", "event", "birthday", "date", "compte à rebours", "ajouter", "anniversaire", "événement"] diff --git a/extensions/countdowns/commands/add.ts b/extensions/countdowns/commands/add.ts new file mode 100644 index 0000000..d218086 --- /dev/null +++ b/extensions/countdowns/commands/add.ts @@ -0,0 +1,5 @@ +import type { Context } from "@backslash/api"; +import { countdownForm } from "../countdowns.ts"; + +// "Add Countdown Vacation" opens the form with the title filled in. +export default (ctx: Context) => countdownForm({ title: ctx.query.trim(), standalone: true }); diff --git a/extensions/countdowns/commands/countdowns.ts b/extensions/countdowns/commands/countdowns.ts new file mode 100644 index 0000000..cc75dae --- /dev/null +++ b/extensions/countdowns/commands/countdowns.ts @@ -0,0 +1,30 @@ +import { action, Icons, list } from "@backslash/api"; +import { sortCountdowns } from "../lib.ts"; +import { countdownForm, countdownItem, live, loadAll, pinnedId, suggestionItems, syncSubtitle } from "../countdowns.ts"; + +let footer = ""; + +export default list({ + placeholder: "Search countdowns…", + empty: { title: "No countdown", description: "Press Ctrl+N to add one", icon: Icons.hourglass }, + status: () => footer, + actions: [action("New Countdown", () => countdownForm(), { icon: Icons.plus, shortcut: "n" })], + items: ({ signal }) => + live(async () => { + const now = new Date(); + const [all, pinned] = await Promise.all([loadAll(), pinnedId()]); + await syncSubtitle(all, pinned ?? ""); + if (!all.length) { + footer = "Add a date to count down to"; + return suggestionItems(now); + } + const sorted = sortCountdowns(all, now); + const upcoming = sorted.filter((c) => countdownItem(c, now, false).section !== "Past").length; + footer = `${upcoming} upcoming${all.length > upcoming ? ` · ${all.length - upcoming} past` : ""}`; + // The pinned one comes first, in its own section. + return [ + ...sorted.filter((c) => c.id === pinned).map((c) => ({ ...countdownItem(c, now, true), section: "Pinned" })), + ...sorted.filter((c) => c.id !== pinned).map((c) => countdownItem(c, now, false)), + ]; + }, signal), +}); diff --git a/extensions/countdowns/countdowns.ts b/extensions/countdowns/countdowns.ts new file mode 100644 index 0000000..0da7e26 --- /dev/null +++ b/extensions/countdowns/countdowns.ts @@ -0,0 +1,276 @@ +// Countdowns kept in the extension's storage, and their launcher items. + +import { + type Action, + action, + close, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + pop, + storage, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + breakdown, + calendarDays, + type Color, + COLORS, + type Countdown, + eventDate, + formatDate, + newId, + nextOccurrence, + ordinal, + parseDateInput, + parseTimeInput, + pinnedSubtitle, + progress, + sectionOf, + timeLeft, + yearsAt, +} from "./lib.ts"; + +export async function loadAll(): Promise { + const all = (await storage.get("countdowns").catch(() => undefined)) as Countdown[] | undefined; + return Array.isArray(all) ? all.filter((c) => c && typeof c.date === "string" && typeof c.title === "string") : []; +} + +async function saveAll(all: Countdown[]) { + await storage.set("countdowns", all as unknown as Parameters[1]); + notifyChanged(); +} + +export async function pinnedId(): Promise { + const id = await storage.get("pinned").catch(() => undefined); + return typeof id === "string" ? id : undefined; +} + +/** Shows the pinned countdown under the command in the root search, or clears it. */ +export async function syncSubtitle(all?: Countdown[], pinned?: string) { + const list = all ?? await loadAll(); + const id = pinned ?? await pinnedId(); + const c = list.find((x) => x.id === id); + await updateCommandMetadata({ subtitle: c ? pinnedSubtitle(c, new Date()) : null }).catch(() => {}); +} + +export async function setPinned(id: string | undefined) { + if (id) await storage.set("pinned", id); + else await storage.delete("pinned"); + await syncSubtitle(undefined, id ?? ""); + notifyChanged(); +} + +export async function upsert(c: Countdown) { + const all = await loadAll(); + const i = all.findIndex((x) => x.id === c.id); + if (i >= 0) all[i] = c; + else all.push(c); + await saveAll(all); + await syncSubtitle(all); +} + +export async function remove(c: Countdown) { + await saveAll((await loadAll()).filter((x) => x.id !== c.id)); + if ((await pinnedId()) === c.id) await setPinned(undefined); +} + +// --- Live reload ------------------------------------------------------------- + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Yields `load()` after each change and every minute (the labels move), until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, 60_000 - (Date.now() % 60_000) + 50); + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +// --- Form -------------------------------------------------------------------- + +const COLOR_NAMES = COLORS.map((c) => ({ value: c, title: c[0].toUpperCase() + c.slice(1) })); + +export function countdownForm( + o: { countdown?: Countdown; title?: string; date?: string; yearly?: boolean; standalone?: boolean } = {}, +): FormSpec { + const c = o.countdown; + return form({ + title: c ? `Edit “${c.title}”` : "New Countdown", + submitTitle: c ? "Save Countdown" : "Add Countdown", + fields: [ + field.text("title", "Title", { + required: true, + placeholder: "Vacation, Mom's birthday…", + value: c?.title ?? o.title ?? "", + }), + field.text("date", "Date", { + required: true, + placeholder: "2026-12-24, Dec 24, 24 December 2027", + value: c?.date ?? o.date ?? "", + }), + field.text("time", "Time", { placeholder: "Optional: 18:30, 6pm", value: c?.time ?? "" }), + field.checkbox("yearly", "Repeats every year (birthdays, anniversaries)", { + value: c?.yearly ?? o.yearly ?? false, + }), + field.dropdown("color", "Color", COLOR_NAMES, { value: c?.color ?? "blue" }), + ], + async submit(values) { + const now = new Date(); + const title = String(values.title ?? "").trim(); + if (!title) throw new Error("Give the countdown a title"); + const date = parseDateInput(String(values.date ?? ""), now); + if (!date) throw new Error(`“${values.date}” is not a date (2026-12-24, Dec 24, 24 December 2027)`); + const time = parseTimeInput(String(values.time ?? "")); + const color = (COLORS as string[]).includes(String(values.color)) ? values.color as Color : "blue"; + const next: Countdown = { + id: c?.id ?? newId(), + title, + date, + ...(time ? { time } : {}), + ...(values.yearly === true ? { yearly: true } : {}), + color, + created: c?.created ?? now.getTime(), + }; + await upsert(next); + const left = timeLeft(next, now); + await toast(c ? "Countdown saved" : `${title} ${left.label}`, { style: "success" }); + return o.standalone ? close(`${title} ${left.label}`) : pop(); + }, + }); +} + +// --- Items ------------------------------------------------------------------- + +export function countdownItem(c: Countdown, now: Date, pinned: boolean): Item { + const at = nextOccurrence(c, now); + const left = timeLeft(c, now); + const color: Color = left.past ? "gray" : c.color ?? "blue"; + const years = yearsAt(c, now); + const ratio = progress(c, now); + const hours = Math.round(left.ms / 3_600_000); + const actions: Action[] = [ + action(pinned ? "Unpin from Root Search" : "Pin to Root Search", async () => { + await setPinned(pinned ? undefined : c.id); + await toast(pinned ? "Unpinned" : `“${pinnedSubtitle(c, new Date())}” shows in the root search`, { + style: "success", + }); + }, { icon: Icons.pin, shortcut: "p" }), + action("Edit Countdown", () => countdownForm({ countdown: c }), { icon: Icons.text, shortcut: "e" }), + action("New Countdown", () => countdownForm(), { icon: Icons.plus, shortcut: "n" }), + copy(pinnedSubtitle(c, now), "Copy Time Left", { shortcut: "c", section: "Copy" }), + copy(`${c.title}: ${formatDate(c, at, now)}`, "Copy Date", { section: "Copy" }), + action("Delete Countdown", async () => { + await remove(c); + await toast(`Deleted “${c.title}”`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${c.title}”?`, + section: "Danger", + shortcut: "Delete", + }), + ]; + const stats = left.past ? [{ label: "days ago", value: -left.days, color: "gray" as const }] : [ + { + label: left.days === 0 && c.time ? "hours left" : "days left", + value: left.days === 0 && c.time ? Math.max(0, hours) : left.days, + color, + }, + ...(left.days >= 14 ? [{ label: "weeks", value: Math.floor(left.days / 7) }] : []), + ...(left.days > 0 && left.days < 14 ? [{ label: "hours", value: hours }] : []), + { label: "elapsed", value: `${Math.round(ratio * 100)} %` }, + ]; + return { + id: c.id, + title: c.title, + subtitle: formatDate(c, at, now), + icon: pinned + ? { source: Icons.pin, tint: color } + : { source: left.past ? Icons.done : Icons.hourglass, tint: color }, + section: sectionOf(c, now), + keywords: [c.date, ...(c.yearly ? ["yearly", "birthday", "anniversary"] : [])], + accessories: [ + ...(c.yearly ? [{ icon: Icons.repeat, tooltip: years ? `${ordinal(years)} time` : "Every year" }] : []), + { text: left.label, color: left.past ? undefined : left.days < 7 ? color : undefined }, + ], + detail: { + stats, + metadata: [ + meta.text("Date", formatDate(c, at, now, { year: true }), { icon: Icons.calendar, color }), + ...(left.past ? [] : [ + meta.text("Time left", left.days === 0 ? left.label.replace(/^in /, "") : breakdown(now, at), { + icon: Icons.hourglass, + }), + ]), + ...(c.yearly + ? [ + meta.text("Repeats", years ? `Every year · ${ordinal(years)} this time` : "Every year", { + icon: Icons.repeat, + }), + ] + : []), + ...(c.yearly && calendarDays(eventDate(c), now) > 0 + ? [meta.text("First", eventDate(c).toLocaleDateString("en", { dateStyle: "medium" }))] + : []), + meta.progress(c.yearly ? "Since the last one" : "Since added", ratio), + meta.separator(), + meta.text("Added", new Date(c.created).toLocaleDateString("en", { dateStyle: "medium" }), { icon: Icons.plus }), + ...(pinned ? [meta.text("Pinned", "Shown in the root search", { icon: Icons.pin, color: "yellow" })] : []), + ], + }, + actions, + }; +} + +export function suggestionItems(now: Date): Item[] { + const ny = { title: `New Year ${now.getFullYear() + 1}`, date: `${now.getFullYear() + 1}-01-01` }; + const nyLeft = calendarDays(now, new Date(now.getFullYear() + 1, 0, 1)); + return [ + { + id: "suggest-new-year", + title: ny.title, + subtitle: `Jan 1 · in ${nyLeft} days`, + icon: { source: Icons.sparkle, tint: "yellow" }, + section: "Suggestions", + actions: [ + action("Add Countdown", async () => { + await upsert({ id: newId(), title: ny.title, date: ny.date, color: "yellow", created: Date.now() }); + await toast(`${ny.title} in ${nyLeft} days`, { style: "success" }); + }, { icon: Icons.plus }), + ], + }, + { + id: "suggest-birthday", + title: "A Birthday…", + subtitle: "Repeats every year and counts the years", + icon: { source: Icons.heart, tint: "pink" }, + section: "Suggestions", + actions: [action("New Birthday", () => countdownForm({ yearly: true }), { icon: Icons.plus })], + }, + { + id: "suggest-new", + title: "New Countdown…", + subtitle: "A vacation, a launch, a deadline", + icon: { source: Icons.plus, tint: "blue" }, + section: "Suggestions", + actions: [action("New Countdown", () => countdownForm(), { icon: Icons.plus })], + }, + ]; +} diff --git a/extensions/countdowns/lib.ts b/extensions/countdowns/lib.ts new file mode 100644 index 0000000..33de0e8 --- /dev/null +++ b/extensions/countdowns/lib.ts @@ -0,0 +1,289 @@ +// Countdown arithmetic: next occurrences (yearly repeats, leap days), time +// left in calendar days (safe across daylight saving changes), progress and +// the labels shown. Pure functions on local dates, no I/O. + +export type Color = "red" | "orange" | "yellow" | "green" | "teal" | "blue" | "purple" | "pink" | "gray"; +export const COLORS: Color[] = ["blue", "green", "orange", "red", "purple", "pink", "teal", "yellow", "gray"]; + +export interface Countdown { + id: string; + title: string; + /** YYYY-MM-DD; for a yearly event, its first occurrence (a birth date). */ + date: string; + /** HH:MM, local time; none for an all-day event. */ + time?: string; + yearly?: boolean; + color?: Color; + /** When it was added (ms), the start of its progress bar. */ + created: number; +} + +const DAY = 86_400_000; +const p2 = (n: number) => String(n).padStart(2, "0"); + +export const isoDay = (d: Date) => `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; + +export const isLeapYear = (y: number) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; + +/** Days of a month, `month` from 1. */ +export const daysInMonth = (y: number, month: number) => new Date(Date.UTC(y, month, 0)).getUTCDate(); + +/** Calendar days from `a` to `b`, by their local dates: 23 or 25 hour days still count as one. */ +export function calendarDays(a: Date, b: Date): number { + const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + return Math.round((ub - ua) / DAY); +} + +function parts(c: Pick) { + const [y, m, d] = c.date.split("-").map(Number); + const [hh, mm] = c.time ? c.time.split(":").map(Number) : [0, 0]; + return { y, m, d, hh, mm }; +} + +/** The event in a given year; Feb 29 falls on Feb 28 in other years. */ +export function inYear(c: Pick, year: number): Date { + const { m, d, hh, mm } = parts(c); + return new Date(year, m - 1, Math.min(d, daysInMonth(year, m)), hh, mm); +} + +/** The moment of the event; all-day events start at midnight. */ +export function eventDate(c: Pick): Date { + const { y } = parts(c); + return inYear(c, y); +} + +/** An all-day event lasts until the end of its day, a timed one until its minute. */ +function isOver(c: Pick, at: Date, now: Date): boolean { + return c.time ? at.getTime() <= now.getTime() - 60_000 : calendarDays(now, at) < 0; +} + +/** The occurrence counted down to: the date itself, or for a yearly event the next one (today included). */ +export function nextOccurrence(c: Pick, now: Date): Date { + if (!c.yearly) return eventDate(c); + const { y } = parts(c); + let year = Math.max(now.getFullYear(), y); + let at = inYear(c, year); + if (isOver(c, at, now)) at = inYear(c, ++year); + return at; +} + +export function previousOccurrence(c: Pick, now: Date): Date | undefined { + if (!c.yearly) return undefined; + const next = nextOccurrence(c, now); + const { y } = parts(c); + return next.getFullYear() - 1 >= y ? inYear(c, next.getFullYear() - 1) : undefined; +} + +/** For yearly events: how many years the next occurrence marks (30 for a 30th birthday). */ +export function yearsAt(c: Countdown, now: Date): number | undefined { + if (!c.yearly) return undefined; + const n = nextOccurrence(c, now).getFullYear() - parts(c).y; + return n > 0 ? n : undefined; +} + +export function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" } as Record)[n % 10] ?? + "th"; + return `${n}${s}`; +} + +export interface Left { + /** Calendar days until the occurrence (negative when past). */ + days: number; + ms: number; + past: boolean; + /** "in 12 days", "tomorrow", "today", "in 3 hours", "now", "2 days ago". */ + label: string; +} + +const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`; + +export function timeLeft(c: Countdown, now: Date): Left { + const at = nextOccurrence(c, now); + const days = calendarDays(now, at); + const ms = at.getTime() - now.getTime(); + const past = isOver(c, at, now); + let label: string; + if (c.time && (days === 0 || Math.abs(ms) < 6 * 3_600_000)) { + const minutes = Math.round(Math.abs(ms) / 60_000); + if (minutes < 1 || (!past && ms <= 0)) label = "now"; + else { + const span = minutes < 60 ? plural(minutes, "minute") : plural(Math.round(minutes / 60), "hour"); + label = past ? `${span} ago` : `in ${span}`; + } + } else if (days === 0) label = "today"; + else if (days === 1) label = "tomorrow"; + else if (days === -1) label = "yesterday"; + else if (days > 0) label = `in ${plural(days, "day")}`; + else label = `${plural(-days, "day")} ago`; + return { days, ms, past, label }; +} + +export type Section = "This Week" | "This Month" | "Later" | "Past"; +export const SECTIONS: Section[] = ["This Week", "This Month", "Later", "Past"]; + +/** Within 7 days, within 31 days, later, or past. */ +export function sectionOf(c: Countdown, now: Date): Section { + const left = timeLeft(c, now); + if (left.past) return "Past"; + return left.days < 7 ? "This Week" : left.days < 31 ? "This Month" : "Later"; +} + +/** Soonest first, then the past ones, most recent first. */ +export function sortCountdowns(list: Countdown[], now: Date): Countdown[] { + const key = (c: Countdown) => nextOccurrence(c, now).getTime(); + return [...list].sort((a, b) => { + const pa = timeLeft(a, now).past; + const pb = timeLeft(b, now).past; + if (pa !== pb) return pa ? 1 : -1; + return pa ? key(b) - key(a) : key(a) - key(b) || a.title.localeCompare(b.title); + }); +} + +/** From the creation (or the last yearly occurrence, if later) to the occurrence, 0 to 1. */ +export function progress(c: Countdown, now: Date): number { + const at = nextOccurrence(c, now).getTime(); + const prev = previousOccurrence(c, now)?.getTime(); + const start = Math.max(c.created, prev ?? -Infinity); + if (at <= start) return now.getTime() >= at ? 1 : 0; + return Math.max(0, Math.min(1, (now.getTime() - start) / (at - start))); +} + +/** "2 months, 1 week, 3 days" in calendar terms, for the detail panel. */ +export function breakdown(now: Date, at: Date): string { + let from = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let to = new Date(at.getFullYear(), at.getMonth(), at.getDate()); + if (to < from) [from, to] = [to, from]; + let months = (to.getFullYear() - from.getFullYear()) * 12 + to.getMonth() - from.getMonth(); + const shifted = (n: number) => { + const first = new Date(from.getFullYear(), from.getMonth() + n, 1); + first.setDate(Math.min(from.getDate(), daysInMonth(first.getFullYear(), first.getMonth() + 1))); + return first; + }; + if (shifted(months) > to) months--; + let days = calendarDays(shifted(months), to); + const years = Math.floor(months / 12); + months %= 12; + const weeks = Math.floor(days / 7); + days %= 7; + const out = [ + years ? plural(years, "year") : "", + months ? plural(months, "month") : "", + weeks ? plural(weeks, "week") : "", + days ? plural(days, "day") : "", + ].filter(Boolean); + return out.length ? out.join(", ") : "Today"; +} + +/** What the root search shows for the pinned countdown: "Vacation in 12 days". */ +export function pinnedSubtitle(c: Countdown, now: Date): string { + return `${c.title} ${timeLeft(c, now).label}`; +} + +// --- Input ------------------------------------------------------------------- + +const MONTHS = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +]; + +/** `dec`, `Dec.`, `december` → 12. */ +function month(word: string): number | undefined { + const w = word.toLowerCase().replace(/\.$/, ""); + if (w.length < 3) return undefined; + const i = MONTHS.findIndex((m) => m.startsWith(w)); + return i >= 0 ? i + 1 : undefined; +} + +function valid(y: number, m: number, d: number) { + return m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m); +} + +/** + * A date typed by a person: `2026-12-25`, `12-25` or `Dec 25` / `25 December` + * (the next one), `Dec 25 2027`, `25 Dec 2027`, `today`, `tomorrow`. + * Returns YYYY-MM-DD or undefined. + */ +export function parseDateInput(input: string, now: Date): string | undefined { + const s = input.trim().toLowerCase().replace(/,/g, " ").replace(/\s+/g, " "); + if (!s) return undefined; + const iso = (y: number, m: number, d: number) => valid(y, m, d) ? `${y}-${p2(m)}-${p2(d)}` : undefined; + if (s === "today") return isoDay(now); + if (s === "tomorrow") return isoDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)); + let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s); + if (m) return iso(+m[1], +m[2], +m[3]); + // Without a year: the next one (today included). + const upcoming = (mo: number, d: number) => { + const y = now.getFullYear(); + if (!valid(2024, mo, d)) return undefined; // a leap year: Feb 29 exists + for (let year = y; year < y + 8; year++) { + if (!valid(year, mo, d)) continue; + if (calendarDays(now, new Date(year, mo - 1, d)) >= 0) return iso(year, mo, d); + } + return undefined; + }; + m = /^(\d{1,2})-(\d{1,2})$/.exec(s); + if (m) return upcoming(+m[1], +m[2]); + const words = s.split(" "); + // "dec 25", "dec 25 2027", "25 dec", "25 dec 2027", "december 25th" + const num = (w: string | undefined) => w && /^\d{1,2}(st|nd|rd|th)?$/.test(w) ? parseInt(w) : undefined; + const year = (w: string | undefined) => w && /^\d{4}$/.test(w) ? +w : undefined; + if (words.length === 2 || words.length === 3) { + const [a, b, c] = words; + let mo = month(a), d = num(b); + if (!mo || !d) [mo, d] = [month(b), num(a)]; + if (mo && d) { + if (words.length === 2) return upcoming(mo, d); + const y = year(c); + if (y) return iso(y, mo, d); + } + } + return undefined; +} + +/** `14:30`, `9:05`, `2pm`, `2:30 pm`, `noon`; empty → undefined; throws when not understood. */ +export function parseTimeInput(input: string): string | undefined { + const s = input.trim().toLowerCase().replace(/\s+/g, ""); + if (!s) return undefined; + if (s === "noon") return "12:00"; + if (s === "midnight") return "00:00"; + const m = /^(\d{1,2})(?:[:h.](\d{2}))?(am|pm)?$/.exec(s); + if (!m) throw new Error(`“${input}” is not a time (18:30, 6pm)`); + let h = +m[1]; + const min = m[2] ? +m[2] : 0; + if (m[3]) { + if (h < 1 || h > 12) throw new Error(`“${input}” is not a time (18:30, 6pm)`); + h = (h % 12) + (m[3] === "pm" ? 12 : 0); + } + if (h > 23 || min > 59) throw new Error(`“${input}” is not a time (18:30, 6pm)`); + return `${p2(h)}:${p2(min)}`; +} + +export function formatDate(c: Pick, at: Date, now: Date, o: { year?: boolean } = {}): string { + const date = at.toLocaleDateString("en", { + weekday: "short", + month: "short", + day: "numeric", + year: o.year || at.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + return c.time ? `${date}, ${c.time}` : date; +} + +export function newYear(now: Date): { title: string; date: string } { + return { title: `New Year ${now.getFullYear() + 1}`, date: `${now.getFullYear() + 1}-01-01` }; +} + +export function newId(now = Date.now()): string { + return `${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`; +} diff --git a/extensions/countdowns/lib_test.ts b/extensions/countdowns/lib_test.ts new file mode 100644 index 0000000..415dfd1 --- /dev/null +++ b/extensions/countdowns/lib_test.ts @@ -0,0 +1,199 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { + breakdown, + calendarDays, + type Countdown, + daysInMonth, + inYear, + isLeapYear, + nextOccurrence, + ordinal, + parseDateInput, + parseTimeInput, + pinnedSubtitle, + previousOccurrence, + progress, + sectionOf, + sortCountdowns, + timeLeft, + yearsAt, +} from "./lib.ts"; + +// Local times: the results must not depend on the machine's time zone. +const NOW = new Date(2026, 9, 28, 10, 0); // Wed 28 Oct 2026, 10:00 +const cd = (date: string, extra: Partial = {}): Countdown => ({ + id: date, + title: "Vacation", + date, + created: new Date(2026, 9, 18, 10, 0).getTime(), + ...extra, +}); +const day = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()]; + +Deno.test("leap years and month lengths", () => { + assertEquals([2024, 2025, 2100, 2000].map(isLeapYear), [true, false, false, true]); + assertEquals(daysInMonth(2024, 2), 29); + assertEquals(daysInMonth(2026, 2), 28); + assertEquals(daysInMonth(2026, 12), 31); +}); + +Deno.test("calendar days ignore daylight saving changes", () => { + // The night of a DST change is 23 or 25 hours long in many zones; it is still one day. + assertEquals(calendarDays(new Date(2026, 2, 28, 12), new Date(2026, 2, 29, 12)), 1); + assertEquals(calendarDays(new Date(2026, 2, 28, 23, 59), new Date(2026, 2, 29, 0, 1)), 1); + assertEquals(calendarDays(new Date(2026, 9, 24, 0, 30), new Date(2026, 9, 26, 0, 0)), 2); + assertEquals(calendarDays(new Date(2026, 2, 1), new Date(2026, 9, 31)), 244); + assertEquals(calendarDays(new Date(2024, 1, 28), new Date(2024, 2, 1)), 2); + assertEquals(calendarDays(new Date(2026, 1, 28), new Date(2026, 2, 1)), 1); +}); + +Deno.test("labels for all-day events", () => { + const label = (date: string) => timeLeft(cd(date), NOW).label; + assertEquals(label("2026-11-09"), "in 12 days"); + assertEquals(label("2026-10-29"), "tomorrow"); + assertEquals(label("2026-10-28"), "today"); + assertEquals(label("2026-10-27"), "yesterday"); + assertEquals(label("2026-10-20"), "8 days ago"); + assertEquals(label("2026-10-30"), "in 2 days"); + assert(!timeLeft(cd("2026-10-28"), NOW).past, "an all-day event lasts the whole day"); + assert(timeLeft(cd("2026-10-27"), NOW).past); +}); + +Deno.test("labels for timed events", () => { + const label = (date: string, time: string, now = NOW) => timeLeft(cd(date, { time }), now).label; + assertEquals(label("2026-10-28", "13:00"), "in 3 hours"); + assertEquals(label("2026-10-28", "10:20"), "in 20 minutes"); + assertEquals(label("2026-10-28", "10:00"), "now"); + assertEquals(label("2026-10-28", "08:00"), "2 hours ago"); + assertEquals(label("2026-10-29", "02:00", new Date(2026, 9, 28, 23, 0)), "in 3 hours"); + assertEquals(label("2026-10-29", "18:00"), "tomorrow"); + assertEquals(label("2026-11-09", "18:00"), "in 12 days"); + // A DST night between now and the event doesn't change the day count. + assertEquals(label("2026-03-30", "09:00", new Date(2026, 2, 28, 9, 0)), "in 2 days"); + assertEquals(label("2026-03-29", "12:00", new Date(2026, 2, 28, 12, 0)), "tomorrow"); +}); + +Deno.test("yearly events: next occurrence, today included", () => { + const birthday = cd("1990-11-09", { yearly: true }); + assertEquals(day(nextOccurrence(birthday, NOW)), [2026, 11, 9, 0, 0]); + assertEquals(yearsAt(birthday, NOW), 36); + assertEquals(timeLeft(birthday, NOW).label, "in 12 days"); + const passed = cd("1990-03-02", { yearly: true }); + assertEquals(day(nextOccurrence(passed, NOW)), [2027, 3, 2, 0, 0]); + assertEquals(day(previousOccurrence(passed, NOW)!), [2026, 3, 2, 0, 0]); + const today = cd("2000-10-28", { yearly: true }); + assertEquals(timeLeft(today, NOW).label, "today"); + assertEquals(yearsAt(today, NOW), 26); + // A yearly event set in the future starts that year. + const future = cd("2027-06-01", { yearly: true }); + assertEquals(day(nextOccurrence(future, NOW)), [2027, 6, 1, 0, 0]); + assertEquals(yearsAt(future, NOW), undefined); + assertEquals(previousOccurrence(future, NOW), undefined); + // A timed yearly event that already happened today moves on to next year. + const morning = cd("2020-10-28", { yearly: true, time: "08:00" }); + assertEquals(day(nextOccurrence(morning, NOW)), [2027, 10, 28, 8, 0]); +}); + +Deno.test("leap-day birthdays fall on Feb 28 in other years", () => { + const leap = cd("2000-02-29", { yearly: true }); + assertEquals(day(inYear(leap, 2027)), [2027, 2, 28, 0, 0]); + assertEquals(day(inYear(leap, 2028)), [2028, 2, 29, 0, 0]); + assertEquals(day(nextOccurrence(leap, NOW)), [2027, 2, 28, 0, 0]); + assertEquals(day(nextOccurrence(leap, new Date(2028, 1, 1))), [2028, 2, 29, 0, 0]); + assertEquals(timeLeft(leap, new Date(2027, 1, 28, 9)).label, "today"); + assertEquals(yearsAt(leap, NOW), 27); +}); + +Deno.test("sections and order", () => { + const list = [ + cd("2026-12-25", { id: "xmas" }), + cd("2026-10-30", { id: "soon" }), + cd("2026-11-15", { id: "month" }), + cd("2026-10-01", { id: "old" }), + cd("2026-10-20", { id: "recent" }), + cd("1990-11-01", { id: "bday", yearly: true }), + ]; + assertEquals(list.map((c) => sectionOf(c, NOW)), ["Later", "This Week", "This Month", "Past", "Past", "This Week"]); + assertEquals(sortCountdowns(list, NOW).map((c) => c.id), ["soon", "bday", "month", "xmas", "recent", "old"]); +}); + +Deno.test("progress since creation, or since the last yearly occurrence", () => { + // Created on Oct 18, due Nov 7 at midnight: half-way on Oct 28 at 00:00. + const c = cd("2026-11-07"); + const ratio = progress(c, NOW); + assert(ratio > 0.5 && ratio < 0.53, String(ratio)); + assertEquals(progress(cd("2026-10-01"), NOW), 1); + assertEquals(progress(cd("2026-12-01", { created: NOW.getTime() }), NOW), 0); + // Yearly: from last year's occurrence (later than the creation date here). + const yearly = cd("1990-04-28", { yearly: true, created: new Date(2020, 0, 1).getTime() }); + const r = progress(yearly, NOW); + assert(r > 0.49 && r < 0.51, String(r)); +}); + +Deno.test("breakdown in calendar units", () => { + assertEquals(breakdown(NOW, new Date(2026, 9, 28, 18)), "Today"); + assertEquals(breakdown(NOW, new Date(2026, 10, 9)), "1 week, 5 days"); + assertEquals(breakdown(NOW, new Date(2027, 0, 1)), "2 months, 4 days"); + assertEquals(breakdown(NOW, new Date(2028, 0, 1)), "1 year, 2 months, 4 days"); + assertEquals(breakdown(new Date(2026, 0, 31), new Date(2026, 2, 1)), "1 month, 1 day"); + assertEquals(breakdown(new Date(2026, 10, 9), NOW), "1 week, 5 days"); +}); + +Deno.test("dates and times typed in the form", () => { + assertEquals(parseDateInput("2026-12-24", NOW), "2026-12-24"); + assertEquals(parseDateInput("2026-2-3", NOW), "2026-02-03"); + assertEquals(parseDateInput("12-24", NOW), "2026-12-24"); + assertEquals(parseDateInput("03-01", NOW), "2027-03-01"); // next one + assertEquals(parseDateInput("10-28", NOW), "2026-10-28"); // today counts + assertEquals(parseDateInput("Dec 24", NOW), "2026-12-24"); + assertEquals(parseDateInput("24 december", NOW), "2026-12-24"); + assertEquals(parseDateInput("December 24th, 2027", NOW), "2027-12-24"); + assertEquals(parseDateInput("24 Dec. 2027", NOW), "2027-12-24"); + assertEquals(parseDateInput("Feb 29", NOW), "2028-02-29"); // the next leap day + assertEquals(parseDateInput("2027-02-29", NOW), undefined); + assertEquals(parseDateInput("tomorrow", NOW), "2026-10-29"); + assertEquals(parseDateInput("soon", NOW), undefined); + assertEquals(parseDateInput("13-01", NOW), undefined); + assertEquals(parseTimeInput(""), undefined); + assertEquals(parseTimeInput("18:30"), "18:30"); + assertEquals(parseTimeInput("9:05"), "09:05"); + assertEquals(parseTimeInput("6pm"), "18:00"); + assertEquals(parseTimeInput("12 am"), "00:00"); + assertEquals(parseTimeInput("12:15pm"), "12:15"); + assertEquals(parseTimeInput("18h30"), "18:30"); + assertThrows(() => parseTimeInput("25:00")); + assertThrows(() => parseTimeInput("13pm")); + assertThrows(() => parseTimeInput("later")); +}); + +Deno.test("root search subtitle and ordinals", () => { + assertEquals(pinnedSubtitle(cd("2026-11-09"), NOW), "Vacation in 12 days"); + assertEquals(pinnedSubtitle(cd("2026-10-28"), NOW), "Vacation today"); + assertEquals([1, 2, 3, 4, 11, 12, 13, 21, 22, 101, 111].map(ordinal), [ + "1st", + "2nd", + "3rd", + "4th", + "11th", + "12th", + "13th", + "21st", + "22nd", + "101st", + "111th", + ]); +}); + +Deno.test("item: sections, stats and actions", async () => { + const { countdownItem } = await import("./countdowns.ts"); + const item = countdownItem(cd("2026-11-09", { color: "green" }), NOW, false); + assertEquals(item.section, "This Month"); + assertEquals(item.accessories?.at(-1)?.text, "in 12 days"); + assertEquals(item.detail?.stats?.[0], { label: "days left", value: 12, color: "green" }); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), [ + "Pin to Root Search", + "Edit Countdown", + "New Countdown", + ]); + assert(item.actions?.some((a) => a.destructive && a.confirm)); +}); diff --git a/extensions/joplin/backslash.toml b/extensions/joplin/backslash.toml new file mode 100644 index 0000000..13e19cc --- /dev/null +++ b/extensions/joplin/backslash.toml @@ -0,0 +1,33 @@ +[extension] +id = "joplin" +title = "Joplin" +description = "Search your Joplin notes by notebook, preview them with their tags, create notes and copy links" +version = "1.0.0" +author = "Backslash" +icon = "bs-note" +license = "MIT" + +# Joplin desktop serves its Data API on 127.0.0.1:41184 when the Web Clipper +# service is enabled; nothing else is reached. +[permissions] +net = ["127.0.0.1:41184"] + +[[commands]] +name = "search" +title = "Search Joplin Notes" +description = "Full-text search by notebook, with previews, tags and to-dos" +icon = "bs-search" +keywords = ["joplin", "notes", "notebook", "markdown", "search", "notes", "carnet", "chercher", "rechercher"] + +[[commands]] +name = "new" +title = "New Joplin Note" +description = "Title, markdown, notebook, tags; the last notebook is remembered" +icon = "bs-plus" +keywords = ["joplin", "note", "new", "create", "write", "nouvelle note", "créer", "écrire"] + +[[preferences]] +name = "token" +title = "API token" +description = "Joplin → Tools → Options → Web Clipper → Advanced options → Authorization token" +type = "secret" diff --git a/extensions/joplin/commands/new.ts b/extensions/joplin/commands/new.ts new file mode 100644 index 0000000..9efb293 --- /dev/null +++ b/extensions/joplin/commands/new.ts @@ -0,0 +1,14 @@ +import { action, type Context, Icons, list, refresh } from "@backslash/api"; +import { connect, noteForm, OfflineError, setupItems, TokenError } from "../joplin.ts"; + +export default async (ctx: Context) => { + try { + return await noteForm(await connect(ctx, ctx.signal), { title: ctx.query.trim(), standalone: true }); + } catch (e) { + if (!(e instanceof OfflineError || e instanceof TokenError)) throw e; + return list({ + items: setupItems(e), + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + }); + } +}; diff --git a/extensions/joplin/commands/search.ts b/extensions/joplin/commands/search.ts new file mode 100644 index 0000000..160e7e1 --- /dev/null +++ b/extensions/joplin/commands/search.ts @@ -0,0 +1,97 @@ +import { action, type Context, Icons, type Item, list, refresh } from "@backslash/api"; +import { + byUpdated, + descendants, + type Folder, + folderPaths, + type Note, + NOTE_FIELDS, + type Page, + searchQuery, +} from "../lib.ts"; +import { + type Client, + connect, + loadFolders, + noteForm, + noteItem, + noteTags, + OfflineError, + setupItems, + TokenError, +} from "../joplin.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + let client: Client; + let folders: Folder[]; + try { + client = await connect(ctx, ctx.signal); + folders = await loadFolders(client, ctx.signal); + } catch (e) { + if (!(e instanceof OfflineError || e instanceof TokenError)) throw e; + return list({ + items: setupItems(e), + status: "Joplin Data API · 127.0.0.1:41184", + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + }); + } + const c = client; + const paths = folderPaths(folders); + const pathOf = new Map(paths.map((p) => [p.id, p.path])); + const top = paths.filter((p) => p.depth === 0); + return list({ + placeholder: "Search Joplin notes…", + filter: top.length > 1 + ? { + options: [ + { value: "all", title: "All Notebooks" }, + ...top.slice(0, 12).map((p) => ({ value: p.id, title: p.path })), + ], + } + : undefined, + empty: { title: "No matching note", description: "Press Ctrl+N to create one", icon: Icons.note }, + status: () => footer, + actions: [ + action("New Note", () => noteForm(c, { title: ctx.query }), { icon: Icons.plus, shortcut: "n" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + async search(query, { filter, signal }) { + const q = searchQuery(query); + const inside = filter && filter !== "all" ? descendants(folders, filter) : undefined; + const params = { fields: NOTE_FIELDS, limit: 100 }; + const page = q + ? await c.get>("/search", { ...params, query: q, type: "note" }, signal) + : await c.get>("/notes", { ...params, order_by: "updated_time", order_dir: "DESC" }, signal); + let notes = page.items.filter((n) => !inside || inside.has(n.parent_id)); + if (!q) notes = byUpdated(notes); + notes = notes.slice(0, 40); + const tags = await Promise.all(notes.map((n) => noteTags(c, n.id, signal))); + const where = inside ? pathOf.get(filter!) : undefined; + footer = q + ? `${notes.length}${page.has_more ? "+" : ""} note${notes.length === 1 ? "" : "s"}${ + where ? ` in ${where}` : "" + }` + : `Recently updated${where ? ` in ${where}` : ""} · ${paths.length} notebooks`; + const items: Item[] = notes.map((n, i) => + noteItem(c, { note: n, notebook: pathOf.get(n.parent_id), tags: tags[i] }, q ? "Notes" : "Recently Updated") + ); + if (q) { + items.push({ + id: "create", + title: `Create “${query.trim()}”`, + subtitle: `New note${where ? ` in ${where}` : ""}`, + icon: { source: Icons.plus, tint: "green" }, + section: "Create", + actions: [ + action("New Note", () => noteForm(c, { title: query.trim(), folder: inside ? filter : undefined }), { + icon: Icons.plus, + }), + ], + }); + } + return items; + }, + }); +}; diff --git a/extensions/joplin/joplin.ts b/extensions/joplin/joplin.ts new file mode 100644 index 0000000..23a0b52 --- /dev/null +++ b/extensions/joplin/joplin.ts @@ -0,0 +1,342 @@ +// The Joplin Data API, served by the desktop app's Web Clipper service, and +// 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, + refresh, + storage, + toast, +} from "@backslash/api"; +import { + ago, + apiUrl, + BASE, + type Folder, + folderPaths, + markdownLink, + type Note, + openFolderUrl, + openNoteUrl, + type Page, + parseTags, + previewBody, + snippet, + type Tag, + todoState, + when, + wordCount, +} from "./lib.ts"; + +/** The Web Clipper service is off (or Joplin is closed). */ +export class OfflineError extends Error {} +/** No token, or a wrong one. */ +export class TokenError extends Error {} + +export interface Client { + token: string; + get(path: string, params?: Record, signal?: AbortSignal): Promise; + send(method: string, path: string, body: unknown): Promise; +} + +async function call(url: string, init: RequestInit): Promise { + try { + return await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(10_000) }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw e; + throw new OfflineError("Joplin's Web Clipper service is off"); + } +} + +async function parse(res: Response): Promise { + if (res.status === 403) throw new TokenError("Joplin refused the token"); + const text = await res.text(); + if (!res.ok) { + let message = text; + try { + message = JSON.parse(text).error ?? text; + } catch { /* plain text */ } + throw new Error(`Joplin: ${String(message).split("\n")[0] || res.status}`); + } + return (text ? JSON.parse(text) : undefined) as T; +} + +/** Checks the service is on (no token needed), then that a token is set. */ +export async function connect(ctx: Pick, signal?: AbortSignal): Promise { + const ping = await call(`${BASE}/ping`, { signal }); + if (!(await ping.text()).includes("JoplinClipperServer")) { + throw new OfflineError("Something else answers on port 41184"); + } + const token = String(ctx.preferences.token ?? "").trim(); + if (!token) throw new TokenError("Add your Joplin API token"); + return { + token, + get: async (path, params = {}, s) => parse(await call(apiUrl(path, params, token), { signal: s ?? signal })), + send: async (method, path, body) => + parse( + await call(apiUrl(path, {}, token), { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ), + }; +} + +/** Every page of a paginated list. */ +export async function all( + c: Client, + path: string, + params: Record, + signal?: AbortSignal, + max = 2000, +): Promise { + const out: T[] = []; + for (let page = 1; out.length < max; page++) { + const r = await c.get>(path, { ...params, limit: 100, page }, signal); + out.push(...r.items); + if (!r.has_more) break; + } + return out; +} + +export const loadFolders = (c: Client, signal?: AbortSignal) => + all(c, "/folders", { fields: "id,title,parent_id" }, signal); + +export async function noteTags(c: Client, id: string, signal?: AbortSignal): Promise { + const r = await c.get>(`/notes/${id}/tags`, { fields: "id,title" }, signal).catch(() => undefined); + return (r?.items ?? []).map((t) => t.title).sort(); +} + +// --- Setup ------------------------------------------------------------------- + +export function setupItems(e: OfflineError | TokenError): Item[] { + if (e instanceof OfflineError) { + return [{ + id: "offline", + title: "Joplin's Web Clipper service is off", + subtitle: "Open Joplin, then Tools → Options → Web Clipper → Enable", + icon: { source: Icons.plug, tint: "orange" }, + detail: { + markdown: [ + "## Turn on the Web Clipper service", + "", + "Backslash reads your notes through the Data API of **Joplin desktop**, served by its Web Clipper service.", + "", + "1. Open Joplin", + "2. **Tools → Options → Web Clipper** (on macOS: Joplin → Settings)", + "3. **Enable Web Clipper Service**: it shows *Status: Enabled, port 41184*", + "", + "Joplin must stay open (it can be minimized). Another port than 41184 isn't supported.", + ].join("\n"), + }, + actions: [ + open("joplin://x-callback-url/openFolder?id=", "Open Joplin", { icon: Icons.external }), + open("https://joplinapp.org/help/apps/clipper", "Web Clipper Help", { icon: Icons.globe }), + open("https://joplinapp.org/download/", "Download Joplin", { icon: Icons.download }), + ], + }]; + } + return [{ + id: "token", + title: e.message, + subtitle: "Tools → Options → Web Clipper → Advanced options → Authorization token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Joplin", + "", + "1. In Joplin: **Tools → Options → Web Clipper**", + "2. Under **Advanced options**, copy the **Authorization token**", + "3. Paste it in **Settings → Extensions → Joplin → Configure** (stored in your keyring)", + ].join("\n"), + }, + actions: [ + open("https://joplinapp.org/help/api/references/rest_api/#authorisation", "Token Help", { icon: Icons.globe }), + ], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof OfflineError || e instanceof TokenError) return setupItems(e); + throw e; + } +} + +// --- Notes ------------------------------------------------------------------- + +export interface Shown { + note: Note; + notebook?: string; + tags: string[]; +} + +function noteMetadata(s: Shown): Metadata[] { + const n = s.note; + const state = todoState(n); + const lines: Metadata[] = []; + if (s.notebook) lines.push(meta.text("Notebook", s.notebook, { icon: Icons.book })); + if (s.tags.length) lines.push(meta.tags("Tags", s.tags.map((t) => ({ text: t, color: "blue" as const })))); + if (state !== "note") { + lines.push(meta.text("To-do", state === "done" ? `Done ${ago(n.todo_completed)}` : "Open", { + icon: state === "done" ? Icons.done : Icons.todo, + color: state === "done" ? "green" : state === "overdue" ? "red" : undefined, + })); + if (n.todo_due) { + lines.push( + meta.text("Due", when(n.todo_due), { icon: Icons.bell, color: state === "overdue" ? "red" : undefined }), + ); + } + } + lines.push(meta.separator()); + lines.push(meta.text("Updated", `${when(n.updated_time)} · ${ago(n.updated_time)}`, { icon: Icons.clock })); + if (n.created_time) lines.push(meta.text("Created", when(n.created_time), { icon: Icons.calendar })); + lines.push(meta.text("Words", wordCount(n.body))); + if (n.author) lines.push(meta.person("Author", n.author)); + if (n.source_url) { + const host = (() => { + try { + return new URL(n.source_url).host; + } catch { + return n.source_url; + } + })(); + lines.push(meta.link("Source", host, n.source_url)); + } + return lines; +} + +export function notePage(c: Client, s: Shown): DetailSpec { + return detail({ + title: s.note.title || "Untitled", + markdown: `# ${s.note.title || "Untitled"}\n\n${previewBody(s.note.body ?? "", 50_000) || "*Empty note*"}`, + metadata: noteMetadata(s), + status: [s.notebook, `${wordCount(s.note.body)} words`, `updated ${ago(s.note.updated_time)}`].filter(Boolean) + .join(" · "), + actions: noteActions(c, s, { page: true }), + }); +} + +export function noteActions(c: Client, s: Shown, o: { page?: boolean } = {}): Action[] { + const n = s.note; + const state = todoState(n); + const body = (n.body ?? "").trim(); + return [ + open(openNoteUrl(n.id), "Open in Joplin", { icon: Icons.external }), + ...(o.page ? [] : [action("View Note", () => notePage(c, s), { icon: Icons.eye, shortcut: "o" })]), + ...(state === "note" ? [] : [ + action(state === "done" ? "Mark as Not Done" : "Mark as Done", async () => { + await c.send("PUT", `/notes/${n.id}`, { todo_completed: state === "done" ? 0 : Date.now() }); + await toast(state === "done" ? "Reopened" : "Done", { style: "success" }); + return o.page ? pop() : refresh(); + }, { icon: state === "done" ? Icons.todo : Icons.done, shortcut: "d" }), + ]), + copy(markdownLink(n), "Copy Markdown Link", { shortcut: "c", section: "Copy" }), + paste(markdownLink(n), "Paste Markdown Link", { shortcut: "v", section: "Copy" }), + copy(body, "Copy Markdown", { section: "Copy" }), + copy(openNoteUrl(n.id), "Copy Joplin URL", { icon: Icons.link, section: "Copy" }), + copy(n.title, "Copy Title", { section: "Copy" }), + ...(n.source_url ? [open(n.source_url, "Open Source Page", { icon: Icons.globe, section: "Open" })] : []), + open(openFolderUrl(n.parent_id), "Open Notebook in Joplin", { icon: Icons.book, section: "Open" }), + action("New Note", () => noteForm(c, { folder: n.parent_id }), { + icon: Icons.plus, + shortcut: "n", + section: "Open", + }), + ]; +} + +export function noteItem(c: Client, s: Shown, section?: string): Item { + const n = s.note; + const state = todoState(n); + const icon = state === "note" + ? { source: Icons.note, tint: "blue" as const } + : state === "done" + ? { source: Icons.squareCheck, tint: "green" as const } + : { source: Icons.square, tint: state === "overdue" ? "red" as const : "orange" as const }; + return { + id: n.id, + title: n.title || "Untitled", + subtitle: snippet(n.body) || undefined, + icon, + section, + accessories: [ + ...s.tags.slice(0, 2).map((t) => ({ tag: t, color: "blue" as const })), + ...(s.notebook ? [{ icon: Icons.book, text: s.notebook.split(" / ").pop(), tooltip: s.notebook }] : []), + { text: ago(n.updated_time), tooltip: `Updated ${when(n.updated_time)}` }, + ], + detail: { markdown: previewBody(n.body ?? "") || "*Empty note*", metadata: noteMetadata(s) }, + actions: noteActions(c, s), + }; +} + +// --- New note ---------------------------------------------------------------- + +export async function lastFolder(): Promise { + const f = await storage.get("lastFolder").catch(() => undefined); + return typeof f === "string" ? f : undefined; +} + +async function tagId(c: Client, title: string): Promise { + const found = await c.get>("/search", { type: "tag", query: title, fields: "id,title" }); + const hit = found.items.find((t) => t.title.toLowerCase() === title); + if (hit) return hit.id; + return (await c.send("POST", "/tags", { title })).id; +} + +export async function noteForm( + c: Client, + o: { folder?: string; title?: string; standalone?: boolean } = {}, +): Promise { + const [folders, remembered] = await Promise.all([loadFolders(c), lastFolder()]); + const paths = folderPaths(folders); + if (!paths.length) throw new Error("Create a notebook in Joplin first"); + const initial = [o.folder, remembered].find((id) => id && paths.some((p) => p.id === id)) ?? paths[0].id; + return form({ + title: "New Joplin Note", + submitTitle: "Create Note", + fields: [ + field.text("title", "Title", { required: true, placeholder: "Meeting notes", value: o.title ?? "" }), + field.textArea("body", "Note", { placeholder: "Markdown" }), + field.dropdown("folder", "Notebook", paths.map((p) => ({ value: p.id, title: p.path })), { value: initial }), + field.text("tags", "Tags", { placeholder: "work, ideas" }), + field.checkbox("todo", "To-do"), + ], + async submit(values) { + const title = String(values.title ?? "").trim(); + if (!title) throw new Error("Give the note a title"); + const parent_id = String(values.folder || initial); + const note = await c.send("POST", "/notes", { + title, + body: String(values.body ?? ""), + parent_id, + is_todo: values.todo === true ? 1 : 0, + }); + for (const t of parseTags(String(values.tags ?? ""))) { + await c.send("POST", `/tags/${await tagId(c, t)}/notes`, { id: note.id }); + } + await storage.set("lastFolder", parent_id); + const where = paths.find((p) => p.id === parent_id)?.path ?? "Joplin"; + await toast(`Note created in ${where}`, { style: "success" }); + return o.standalone ? close(`Created in ${where}`) : pop(); + }, + }); +} diff --git a/extensions/joplin/lib.ts b/extensions/joplin/lib.ts new file mode 100644 index 0000000..a0874dd --- /dev/null +++ b/extensions/joplin/lib.ts @@ -0,0 +1,184 @@ +// Joplin Data API (https://joplinapp.org/help/api/references/rest_api/) +// responses, links and text. Pure functions, no I/O. + +export const BASE = "http://127.0.0.1:41184"; +export const NOTE_FIELDS = + "id,parent_id,title,body,created_time,updated_time,is_todo,todo_completed,todo_due,source_url,author"; + +export interface Note { + id: string; + parent_id: string; + title: string; + body?: string; + created_time?: number; + updated_time?: number; + is_todo?: number; + todo_completed?: number; + todo_due?: number; + source_url?: string; + author?: string; +} + +export interface Folder { + id: string; + title: string; + parent_id: string; +} + +export interface Tag { + id: string; + title: string; +} + +export interface Page { + items: T[]; + has_more: boolean; +} + +/** `/search?query=…`, with the token and fields. */ +export function apiUrl(path: string, params: Record, token: string): string { + const url = new URL(path, BASE); + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + url.searchParams.set("token", token); + return url.toString(); +} + +/** + * What is typed, as a Joplin search: the last word gets a `*` so results + * come while typing (`proj` finds “project”), unless it is a filter + * (`tag:x`), a quoted phrase or already ends with `*`. + */ +export function searchQuery(input: string): string { + const q = input.trim().replace(/\s+/g, " "); + if (!q) return ""; + const words = q.split(" "); + const last = words[words.length - 1]; + if (/[:*"]/.test(last) || last.startsWith("-")) return q; + words[words.length - 1] = `${last}*`; + return words.join(" "); +} + +/** Notebooks with their full path, `Work / Projects / Alpha`, in tree order. */ +export function folderPaths(folders: Folder[]): { id: string; path: string; depth: number }[] { + const byId = new Map(folders.map((f) => [f.id, f])); + const path = (f: Folder, seen = new Set()): string[] => { + const parent = byId.get(f.parent_id); + if (!parent || seen.has(parent.id)) return [f.title]; + seen.add(f.id); + return [...path(parent, seen), f.title]; + }; + return folders + .map((f) => { + const p = path(f); + return { id: f.id, path: p.join(" / "), depth: p.length - 1, parts: p }; + }) + .sort((a, b) => { + for (let i = 0; i < Math.min(a.parts.length, b.parts.length); i++) { + const c = a.parts[i].localeCompare(b.parts[i], undefined, { sensitivity: "base" }); + if (c) return c; + } + return a.parts.length - b.parts.length; + }) + .map(({ id, path, depth }) => ({ id, path, depth })); +} + +/** A notebook and every notebook inside it. */ +export function descendants(folders: Folder[], root: string): Set { + const out = new Set([root]); + let grew = true; + while (grew) { + grew = false; + for (const f of folders) { + if (out.has(f.parent_id) && !out.has(f.id)) { + out.add(f.id); + grew = true; + } + } + } + return out; +} + +export const openNoteUrl = (id: string) => `joplin://x-callback-url/openNote?id=${encodeURIComponent(id)}`; +export const openFolderUrl = (id: string) => `joplin://x-callback-url/openFolder?id=${encodeURIComponent(id)}`; + +/** `[Title](:/id)`, the internal link Joplin itself copies. */ +export function markdownLink(note: Pick): string { + const title = (note.title || "Untitled").replace(/([[\]\\])/g, "\\$1"); + return `[${title}](:/${note.id})`; +} + +/** + * The body for the preview panel: Joplin resources (`![](:/id)`) can't be + * shown outside Joplin, so pictures become their caption and internal + * links their text. + */ +export function previewBody(body: string, max = 4000): string { + let text = body + .replace(/!\[([^\]]*)\]\(:\/[0-9a-f]{32}\)/g, (_, alt) => `*[image${alt ? `: ${alt}` : ""}]*`) + .replace(/]*src=["']:\/[0-9a-f]{32}["'][^>]*>/gi, "*[image]*") + .replace(/\[([^\]]*)\]\(:\/[0-9a-f]{32}(#[^)]*)?\)/g, "**$1**"); + if (text.length > max) text = `${text.slice(0, max).replace(/\s+\S*$/, "")}\n\n…`; + return text.trim(); +} + +/** The first line of text, without markdown marks, for the subtitle. */ +export function snippet(body: string | undefined, max = 90): string { + for (const raw of (body ?? "").split("\n")) { + const line = raw + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/^\s*([-*+] \[[ x]\]|#+|[-*+]|\d+\.|>)\s*/i, "") + .replace(/[*_`~]/g, "") + .trim(); + if (line) return line.length > max ? `${line.slice(0, max - 1)}…` : line; + } + return ""; +} + +export function wordCount(text: string | undefined): number { + return (text ?? "").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length; +} + +export function ago(ms: number | undefined, now = Date.now()): string { + if (!ms) return ""; + const s = Math.max(0, Math.round((now - ms) / 1000)); + if (s < 60) return "just now"; + const m = Math.round(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.round(h / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.round(d / 30.4); + if (mo < 12) return `${mo}mo ago`; + return `${Math.round(d / 365)}y ago`; +} + +export function when(ms: number | undefined): string { + return ms ? new Date(ms).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }) : ""; +} + +export type TodoState = "note" | "open" | "done" | "overdue"; + +export function todoState(n: Note, now = Date.now()): TodoState { + if (!n.is_todo) return "note"; + if (n.todo_completed) return "done"; + return n.todo_due && n.todo_due < now ? "overdue" : "open"; +} + +/** `work, ideas #later` → `["work", "ideas", "later"]`; Joplin lowercases tags. */ +export function parseTags(input: string): string[] { + return [ + ...new Set( + input.split(",").flatMap((t) => t.split(/\s+#/)).map((t) => t.trim().replace(/^#/, "").toLowerCase()).filter( + Boolean, + ), + ), + ]; +} + +/** Sorts recent notes, most recently updated first. */ +export function byUpdated(notes: Note[]): Note[] { + return [...notes].sort((a, b) => (b.updated_time ?? 0) - (a.updated_time ?? 0)); +} diff --git a/extensions/joplin/lib_test.ts b/extensions/joplin/lib_test.ts new file mode 100644 index 0000000..5b026a4 --- /dev/null +++ b/extensions/joplin/lib_test.ts @@ -0,0 +1,129 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + apiUrl, + byUpdated, + descendants, + type Folder, + folderPaths, + markdownLink, + openNoteUrl, + parseTags, + previewBody, + searchQuery, + snippet, + todoState, + wordCount, +} from "./lib.ts"; +import { noteItem } from "./joplin.ts"; + +const FOLDERS: Folder[] = [ + { id: "w", title: "Work", parent_id: "" }, + { id: "p", title: "Projects", parent_id: "w" }, + { id: "a", title: "Alpha", parent_id: "p" }, + { id: "h", title: "home", parent_id: "" }, + { id: "o", title: "Orphan", parent_id: "deleted-parent" }, +]; + +Deno.test("API URLs carry the token and skip empty params", () => { + assertEquals( + apiUrl("/search", { query: "a b*", type: "note", page: undefined, limit: 10 }, "tok"), + "http://127.0.0.1:41184/search?query=a+b*&type=note&limit=10&token=tok", + ); +}); + +Deno.test("search as you type", () => { + assertEquals(searchQuery(" meeting proj "), "meeting proj*"); + assertEquals(searchQuery("tag:work"), "tag:work"); + assertEquals(searchQuery('"exact phrase"'), '"exact phrase"'); + assertEquals(searchQuery("ide*"), "ide*"); + assertEquals(searchQuery("recipe -cake"), "recipe -cake"); + assertEquals(searchQuery(""), ""); +}); + +Deno.test("notebook paths in tree order, and what is inside one", () => { + assertEquals(folderPaths(FOLDERS), [ + { id: "h", path: "home", depth: 0 }, + { id: "o", path: "Orphan", depth: 0 }, + { id: "w", path: "Work", depth: 0 }, + { id: "p", path: "Work / Projects", depth: 1 }, + { id: "a", path: "Work / Projects / Alpha", depth: 2 }, + ]); + assertEquals([...descendants(FOLDERS, "w")].sort(), ["a", "p", "w"]); + assertEquals([...descendants(FOLDERS, "h")], ["h"]); + // A cycle (corrupted sync) doesn't hang. + folderPaths([{ id: "x", title: "X", parent_id: "y" }, { id: "y", title: "Y", parent_id: "x" }]); +}); + +Deno.test("links", () => { + assertEquals(markdownLink({ id: "0123abcd", title: "Plan [v2]" }), "[Plan \\[v2\\]](:/0123abcd)"); + assertEquals(markdownLink({ id: "1", title: "" }), "[Untitled](:/1)"); + assertEquals(openNoteUrl("abc"), "joplin://x-callback-url/openNote?id=abc"); +}); + +Deno.test("preview: resources become captions, internal links their text", () => { + const id = "0123456789abcdef0123456789abcdef"; + const body = + `# Trip\n\n![map](:/${id})\n\nSee [packing list](:/${id}#top) and [site](https://x.org).`; + assertEquals( + previewBody(body), + "# Trip\n\n*[image: map]*\n*[image]*\nSee **packing list** and [site](https://x.org).", + ); + assertEquals(previewBody("word ".repeat(50), 22), "word word word word\n\n…"); +}); + +Deno.test("snippet, words, tags", () => { + assertEquals(snippet("\n\n# Heading\nbody"), "Heading"); + assertEquals(snippet("- [ ] **Buy** [milk](https://shop) ![x](:/1)"), "Buy milk"); + assertEquals(snippet(undefined), ""); + assertEquals(snippet("a".repeat(100), 10), "aaaaaaaaa…"); + assertEquals(wordCount("Hello, world — 42 !"), 3); + assertEquals(parseTags("Work, ideas #Later,, work"), ["work", "ideas", "later"]); +}); + +Deno.test("to-do states and times", () => { + const now = 1_800_000_000_000; + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 0 }, now), "note"); + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 1 }, now), "open"); + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 1, todo_due: now - 1 }, now), "overdue"); + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 1, todo_completed: now }, now), "done"); + assertEquals(ago(now - 30_000, now), "just now"); + assertEquals(ago(now - 5 * 3_600_000, now), "5h ago"); + assertEquals(ago(now - 3 * 86_400_000, now), "3d ago"); + assertEquals(ago(undefined, now), ""); + assertEquals( + byUpdated([{ id: "a", parent_id: "", title: "", updated_time: 1 }, { + id: "b", + parent_id: "", + title: "", + updated_time: 2, + }]) + .map((n) => n.id), + ["b", "a"], + ); +}); + +Deno.test("note item", () => { + const client = { token: "t", get: () => Promise.reject(), send: () => Promise.reject() }; + const item = noteItem(client, { + note: { + id: "n1", + parent_id: "a", + title: "Kickoff", + body: "## Agenda\n- scope", + updated_time: Date.now(), + is_todo: 1, + }, + notebook: "Work / Projects / Alpha", + tags: ["meeting"], + }); + assertEquals(item.subtitle, "Agenda"); + assertEquals(item.icon, { source: "bs-square", tint: "orange" }); + assertEquals(item.accessories?.map((a) => a.text ?? a.tag), ["meeting", "Alpha", "just now"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Open in Joplin", + "View Note", + "Mark as Done", + "Copy Markdown Link", + ]); +}); diff --git a/extensions/logseq/backslash.toml b/extensions/logseq/backslash.toml new file mode 100644 index 0000000..40b8dcc --- /dev/null +++ b/extensions/logseq/backslash.toml @@ -0,0 +1,65 @@ +[extension] +id = "logseq" +title = "Logseq" +description = "Search your Logseq pages and blocks, preview them with properties and backlinks, open today's journal and capture blocks" +version = "1.0.0" +author = "Backslash" +icon = "bs-layers" +license = "MIT" + +# Graphs can be any folder of your home, so reading it is needed to search +# them (they are listed in ~/.logseq/graphs). Logseq has no link to add a +# block, so captures append to today's journal file directly: writing is +# allowed in the folders dedicated to Logseq only (~/Logseq, ~/logseq, +# ~/Documents/Logseq, ~/Documents/logseq, and the graphs inside them). For a +# graph elsewhere the block is copied and today's journal opened to paste it. +[permissions] +read = ["~"] +write = ["~/Logseq", "~/logseq", "~/Documents/Logseq", "~/Documents/logseq"] +env = ["HOME"] + +[[commands]] +name = "search" +title = "Search Logseq" +description = "Pages, journals and blocks of your graphs, with previews and backlinks" +icon = "bs-search" +keywords = ["logseq", "notes", "outliner", "pages", "blocks", "graph", "pkm", "chercher", "notes", "graphe"] + +[[commands]] +name = "journal" +title = "Open Logseq Journal" +description = "Today's journal page, created if needed" +icon = "bs-calendar" +mode = "no-view" +keywords = ["logseq", "journal", "today", "daily", "aujourd'hui", "journal du jour"] + +[[commands]] +name = "capture" +title = "Capture to Logseq" +description = "Append a block to today's journal" +icon = "bs-bolt" +mode = "no-view" +keywords = ["logseq", "capture", "block", "jot", "append", "inbox", "noter", "ajouter", "bloc"] +[[commands.arguments]] +name = "text" +placeholder = "Idea, TODO, link…" +required = true + +[[commands]] +name = "graphs" +title = "Logseq Graphs" +description = "Your graphs with their page and journal counts" +icon = "bs-layers" +keywords = ["logseq", "graph", "graphs", "graphe", "open"] + +[[preferences]] +name = "graph" +title = "Graph for the journal and captures" +description = "A graph name, or its folder (~/Logseq/work). Empty: the one opened last in Logseq" + +[[preferences]] +name = "timestamp" +title = "Prefix captures with the time" +description = "“- 14:05 your text”" +type = "boolean" +default = false diff --git a/extensions/logseq/commands/capture.ts b/extensions/logseq/commands/capture.ts new file mode 100644 index 0000000..a1ef69f --- /dev/null +++ b/extensions/logseq/commands/capture.ts @@ -0,0 +1,9 @@ +import { run } from "@backslash/api"; +import { capture } from "../graph.ts"; + +// "Capture to Logseq Call the bank" → a block at the end of today's journal. +export default run<{ text: string }>(async (ctx) => { + const text = String(ctx.args.text ?? ctx.query ?? "").trim(); + if (!text) throw new Error("Type the block after the command"); + return await capture(ctx, text); +}); diff --git a/extensions/logseq/commands/graphs.ts b/extensions/logseq/commands/graphs.ts new file mode 100644 index 0000000..7cf64a4 --- /dev/null +++ b/extensions/logseq/commands/graphs.ts @@ -0,0 +1,50 @@ +import { type Context, copy, Icons, list, meta, open } from "@backslash/api"; +import { ago, graphUri, plural } from "../lib.ts"; +import { indexGraph, loadGraphs, NO_GRAPH, pickGraph, tildify, todayJournal } from "../graph.ts"; + +export default async (ctx: Context) => { + const graphs = await loadGraphs(ctx); + const current = pickGraph(graphs, ctx); + return list({ + placeholder: "Logseq graphs…", + empty: NO_GRAPH, + status: `${graphs.length} graph${graphs.length === 1 ? "" : "s"}`, + items: await Promise.all(graphs.map(async (g) => { + const pages = await indexGraph(g); + const journals = pages.filter((p) => p.journal).length; + const blocks = pages.reduce((n, p) => n + p.page.blocks.length, 0); + const today = await todayJournal(g); + return { + id: g.path, + title: g.name, + subtitle: tildify(g.path), + icon: { source: Icons.layers, tint: g === current ? "teal" as const : "gray" as const }, + accessories: [ + ...(g === current ? [{ tag: "Journal & capture", color: "teal" as const }] : []), + { text: plural(pages.length - journals, "page") }, + ...(g.used && Number.isFinite(g.used) ? [{ text: ago(g.used), tooltip: "Last opened in Logseq" }] : []), + ], + detail: { + stats: [ + { label: "Pages", value: pages.length - journals }, + { label: "Journals", value: journals }, + { label: "Blocks", value: blocks.toLocaleString("en") }, + ], + metadata: [ + meta.text("Folder", tildify(g.path), { icon: Icons.folder }), + meta.text("Today", `${today.title}${today.exists ? "" : " (not created yet)"}`, { icon: Icons.calendar }), + ...(g === current + ? [meta.text("Used for", "Today's journal and captures", { icon: Icons.bolt, color: "teal" })] + : []), + ], + }, + actions: [ + open(graphUri(g.name), "Open in Logseq", { icon: Icons.external }), + open(today.uri, "Open Today's Journal", { icon: Icons.calendar, shortcut: "j" }), + open(g.path, "Show in Folder", { icon: Icons.folder, shortcut: "o" }), + copy(g.path, "Copy Folder Path", { shortcut: "c" }), + ], + }; + })), + }); +}; diff --git a/extensions/logseq/commands/journal.ts b/extensions/logseq/commands/journal.ts new file mode 100644 index 0000000..2d4dfbb --- /dev/null +++ b/extensions/logseq/commands/journal.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { openToday } from "../graph.ts"; + +export default run(async (ctx) => await openToday(ctx)); diff --git a/extensions/logseq/commands/search.ts b/extensions/logseq/commands/search.ts new file mode 100644 index 0000000..399e0bd --- /dev/null +++ b/extensions/logseq/commands/search.ts @@ -0,0 +1,76 @@ +import { action, type Context, Icons, type Item, list, open, refresh } from "@backslash/api"; +import { pageUri, plural, score } from "../lib.ts"; +import { blockItem, clearIndex, indexGraph, loadGraphs, NO_GRAPH, pageItem, todayJournal } from "../graph.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + const graphs = await loadGraphs(ctx); + const several = graphs.length > 1; + return list({ + placeholder: "Search pages and blocks…", + filter: several + ? { options: [{ value: "all", title: "All Graphs" }, ...graphs.map((g) => ({ value: g.path, title: g.name }))] } + : undefined, + empty: graphs.length + ? { title: "Nothing found", description: "Press Enter on “Create” to start a page", icon: Icons.note } + : NO_GRAPH, + status: () => footer, + actions: [action("Reload Graphs", () => (clearIndex(), refresh()), { icon: Icons.refresh, shortcut: "r" })], + async search(query, { filter }) { + const shown = !filter || filter === "all" ? graphs : graphs.filter((g) => g.path === filter); + if (!shown.length) return []; + const pages = (await Promise.all(shown.map((g) => indexGraph(g)))).flat(); + const q = query.trim(); + const items: Item[] = []; + if (!q) { + const journals = pages.filter((p) => p.journal).length; + footer = `${plural(pages.length - journals, "page")} · ${plural(journals, "journal")}${ + several ? ` in ${shown.length} graphs` : ` · ${shown[0].name}` + }`; + const today = await todayJournal(shown[0]); + items.push({ + id: "today", + title: today.exists ? "Open Today's Journal" : "Create Today's Journal", + subtitle: today.title, + icon: { source: Icons.calendar, tint: "teal" }, + section: "Journal", + accessories: several ? [{ text: shown[0].name }] : [], + actions: [open(today.uri, today.exists ? "Open Journal" : "Create Journal", { icon: Icons.calendar })], + }); + const recent = [...pages].sort((a, b) => b.modified - a.modified).slice(0, 40); + for (const p of recent) items.push(pageItem(p, { several, section: "Recently Modified" })); + return items; + } + const ranked = pages + .map((p) => ({ + p, + s: Math.max( + score(p.name, q) * 2, + ...p.aliases.map((a) => score(a, q) * 2), + ...p.tags.map((t) => score(t, q)), + ), + })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || b.p.backlinks - a.p.backlinks || b.p.modified - a.p.modified); + for (const { p } of ranked.slice(0, 25)) items.push(pageItem(p, { several, section: "Pages" })); + const blocks = pages + .flatMap((p) => p.page.blocks.map((b) => ({ p, b, s: score(b.content, q) }))) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || b.p.modified - a.p.modified); + for (const { p, b } of blocks.slice(0, 40)) items.push(blockItem(p, b, { several })); + footer = `${plural(ranked.length, "page")} · ${plural(blocks.length, "block")}`; + if (!ranked.some(({ p }) => p.name.toLowerCase() === q.toLowerCase())) { + items.push({ + id: "create", + title: `Create “${q}”`, + subtitle: several ? `New page in ${shown[0].name}` : "New page", + icon: { source: Icons.plus, tint: "green" }, + section: "Create", + actions: [open(pageUri(shown[0].name, q), "Create Page in Logseq", { icon: Icons.plus })], + }); + } + return items; + }, + }); +}; diff --git a/extensions/logseq/graph.ts b/extensions/logseq/graph.ts new file mode 100644 index 0000000..fb9d43e --- /dev/null +++ b/extensions/logseq/graph.ts @@ -0,0 +1,324 @@ +// Logseq graphs on disk, and their pages and blocks as launcher items. + +import { + type Action, + action, + clipboard, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + openExternal, + paste, +} from "@backslash/api"; +import { + ago, + appendBlock, + backlinkCounts, + type Block, + blockUri, + type Config, + formatDate, + type Graph, + graphFromCacheFile, + listProperty, + type Page, + pageKey, + pageNameFromFile, + pageUri, + parseConfig, + parseJournalFile, + parsePage, + plain, + previewMarkdown, + taskMarker, + 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); +const expand = (p: string) => (p === "~" ? HOME : p.startsWith("~/") ? `${HOME}${p.slice(1)}` : p); + +const isDir = (p: string) => Deno.stat(p).then((s) => s.isDirectory, () => false); + +/** Graphs Logseq knows (its cache in ~/.logseq/graphs), plus a folder set in the preferences; most recently used first. */ +export async function loadGraphs(ctx: Pick): Promise { + const found: Graph[] = []; + try { + for await (const e of Deno.readDir(`${HOME}/.logseq/graphs`)) { + const g = e.isFile ? graphFromCacheFile(e.name) : undefined; + if (!g) continue; + const stat = await Deno.stat(`${HOME}/.logseq/graphs/${e.name}`).catch(() => undefined); + found.push({ ...g, used: stat?.mtime?.getTime() }); + } + } catch { /* Logseq never ran */ } + const pref = String(ctx.preferences.graph ?? "").trim(); + if (pref.includes("/")) { + const path = expand(pref).replace(/\/+$/, ""); + if (!found.some((g) => g.path === path)) found.push({ name: path.split("/").pop()!, path, used: Infinity }); + } + const present = await Promise.all(found.map((g) => isDir(g.path))); + return found.filter((_, i) => present[i]).sort((a, b) => (b.used ?? 0) - (a.used ?? 0)); +} + +/** The graph for the journal and captures: the preference (a name or a folder), else the one used last. */ +export function pickGraph(graphs: Graph[], ctx: Pick): Graph | undefined { + const pref = String(ctx.preferences.graph ?? "").trim(); + if (pref) { + const path = expand(pref).replace(/\/+$/, ""); + const hit = graphs.find((g) => g.path === path || g.name.toLowerCase() === pref.toLowerCase()); + if (hit) return hit; + } + return graphs[0]; +} + +export async function graphConfig(g: Graph): Promise { + return parseConfig(await Deno.readTextFile(`${g.path}/logseq/config.edn`).catch(() => undefined)); +} + +export interface PageFile { + graph: Graph; + name: string; + /** Relative to the graph. */ + file: string; + journal?: Date; + page: Page; + modified: number; + tags: string[]; + aliases: string[]; + backlinks: number; +} + +const MAX_FILE = 2 * 1024 * 1024; + +async function mdFiles(dir: string): Promise { + const out: string[] = []; + try { + for await (const e of Deno.readDir(dir)) if (e.isFile && /\.md$/i.test(e.name)) out.push(e.name); + } catch { /* missing folder */ } + return out; +} + +const cache = new Map }>(); + +export const clearIndex = () => cache.clear(); + +/** Every page and journal of a graph, read once per minute at most. */ +export function indexGraph(g: Graph, maxAge = 60_000): Promise { + const hit = cache.get(g.path); + if (hit && Date.now() - hit.at < maxAge) return hit.pages; + const pages = (async () => { + const config = await graphConfig(g); + const files = [ + ...(await mdFiles(`${g.path}/${config.pagesDir}`)).map((f) => ({ dir: config.pagesDir, f, journal: false })), + ...(await mdFiles(`${g.path}/${config.journalsDir}`)).map((f) => ({ dir: config.journalsDir, f, journal: true })), + ]; + const out: PageFile[] = []; + for (let i = 0; i < files.length; i += 64) { + const batch = await Promise.all( + files.slice(i, i + 64).map(async ({ dir, f, journal }) => { + const full = `${g.path}/${dir}/${f}`; + try { + const stat = await Deno.stat(full); + const page = parsePage(stat.size > MAX_FILE ? "" : await Deno.readTextFile(full)); + const base = f.replace(/\.md$/i, ""); + const date = journal ? parseJournalFile(base, config.journalFile) : undefined; + const name = page.properties.title ?? + (date ? formatDate(config.journalTitle, date) : pageNameFromFile(base, config.tripleLowbar)); + return { + graph: g, + name, + file: `${dir}/${f}`, + journal: date, + page, + modified: stat.mtime?.getTime() ?? 0, + tags: listProperty(page.properties.tags), + aliases: listProperty(page.properties.alias), + backlinks: 0, + } satisfies PageFile; + } catch { + return undefined; + } + }), + ); + for (const p of batch) if (p) out.push(p); + } + const counts = backlinkCounts(out.map((p) => ({ name: p.name, page: p.page, aliases: p.aliases }))); + for (const p of out) p.backlinks = counts.get(pageKey(p.name)) ?? 0; + return out; + })(); + cache.set(g.path, { at: Date.now(), pages }); + pages.catch(() => cache.delete(g.path)); + return pages; +} + +// --- Today's journal ------------------------------------------------------------ + +export async function todayJournal(g: Graph, now = new Date()) { + const config = await graphConfig(g); + const ext = config.org ? "org" : "md"; + const file = `${g.path}/${config.journalsDir}/${formatDate(config.journalFile, now)}.${ext}`; + const title = formatDate(config.journalTitle, now); + const exists = await Deno.stat(file).then(() => true, () => false); + return { file, title, exists, config, uri: pageUri(g.name, title) }; +} + +export async function openToday(ctx: Pick): Promise { + const g = pickGraph(await loadGraphs(ctx), ctx); + if (!g) throw new Error("No Logseq graph found: open a folder in Logseq once, or set one in the preferences"); + const j = await todayJournal(g); + await openExternal(j.uri); + return `${j.title} · ${g.name}`; +} + +/** Folders dedicated to Logseq, where `[permissions] write` lets captures go (graphs in them or below). */ +export const WRITABLE = ["~/Logseq", "~/logseq", "~/Documents/Logseq", "~/Documents/logseq"]; + +export async function capture(ctx: Pick, text: string, now = new Date()): Promise { + const g = pickGraph(await loadGraphs(ctx), ctx); + if (!g) throw new Error("No Logseq graph found: open a folder in Logseq once, or set one in the preferences"); + const j = await todayJournal(g, now); + const p2 = (n: number) => String(n).padStart(2, "0"); + const time = ctx.preferences.timestamp === true ? `${p2(now.getHours())}:${p2(now.getMinutes())}` : undefined; + try { + const content = j.exists ? await Deno.readTextFile(j.file) : ""; + await Deno.mkdir(j.file.replace(/\/[^/]+$/, ""), { recursive: true }); + await Deno.writeTextFile(j.file, appendBlock(content, text, { org: j.config.org, time })); + cache.delete(g.path); + return `Added to ${j.title} · ${g.name}`; + } catch (e) { + if (!(e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied)) throw e; + // The graph is elsewhere: hand the block over through Logseq itself. + await clipboard.write(time ? `${time} ${text}` : text); + await openExternal(j.uri); + return `Copied: paste it in today's journal (Backslash may only write in ${WRITABLE.join(", ")})`; + } +} + +// --- Items -------------------------------------------------------------------- + +const when = (ms: number) => new Date(ms).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }); + +function propertyLines(props: Record): Metadata[] { + return Object.entries(props) + .filter(([k]) => !["tags", "alias", "title", "id", "collapsed", "filters", "public", "icon"].includes(k)) + .slice(0, 8) + .map(([k, v]) => { + const list = listProperty(v); + return v.includes("[[") || (v.includes(",") && list.length > 1) + ? meta.tags(k, list.slice(0, 6).map((t) => ({ text: t, color: "teal" as const }))) + : meta.text(k, v.length > 60 ? `${v.slice(0, 59)}…` : v); + }); +} + +export function pageMetadata(p: PageFile, several: boolean): Metadata[] { + const lines: Metadata[] = []; + if (several) lines.push(meta.text("Graph", p.graph.name, { icon: Icons.layers, color: "teal" })); + lines.push(meta.text("Type", p.journal ? "Journal" : "Page", { icon: p.journal ? Icons.calendar : Icons.note })); + if (p.tags.length) { + lines.push(meta.tags("Tags", p.tags.slice(0, 8).map((t) => ({ text: `#${t}`, color: "teal" as const })))); + } + if (p.aliases.length) lines.push(meta.tags("Aliases", p.aliases.slice(0, 6))); + lines.push(...propertyLines(p.page.properties)); + lines.push(meta.separator()); + lines.push(meta.text("Backlinks", p.backlinks, { icon: Icons.link, color: p.backlinks ? "teal" : undefined })); + lines.push(meta.text("Blocks", p.page.blocks.length, { icon: Icons.layers })); + lines.push(meta.text("Words", wordCount(p.page))); + lines.push(meta.text("Modified", when(p.modified), { icon: Icons.clock })); + lines.push(meta.text("File", p.file, { icon: Icons.file })); + return lines; +} + +function pageActions(p: PageFile, several: boolean, o: { page?: boolean } = {}): Action[] { + const uri = pageUri(p.graph.name, p.name); + const full = `${p.graph.path}/${p.file}`; + return [ + open(uri, "Open in Logseq", { icon: Icons.external }), + ...(o.page ? [] : [action("View Page", () => pagePage(p, several), { icon: Icons.eye, shortcut: "o" })]), + copy(`[[${p.name}]]`, "Copy Page Reference", { shortcut: "c", section: "Copy" }), + paste(`[[${p.name}]]`, "Paste Page Reference", { shortcut: "v", section: "Copy" }), + copy(uri, "Copy Logseq URL", { icon: Icons.link, section: "Copy" }), + copy(full, "Copy File Path", { section: "Copy" }), + open(full, "Open File in Default App", { icon: Icons.file, section: "Open" }), + open(full.replace(/\/[^/]+$/, ""), "Show in Folder", { icon: Icons.folder, section: "Open" }), + ]; +} + +export function pagePage(p: PageFile, several: boolean): DetailSpec { + return detail({ + title: p.name, + markdown: `# ${p.name}\n\n${previewMarkdown(p.page, 30_000) || "*Empty page*"}`, + metadata: pageMetadata(p, several), + status: `${p.graph.name} · ${p.page.blocks.length} blocks · ${p.backlinks} backlink${p.backlinks === 1 ? "" : "s"}`, + actions: pageActions(p, several, { page: true }), + }); +} + +export function pageItem(p: PageFile, o: { several: boolean; section?: string }): Item { + const first = p.page.blocks.find((b) => b.content)?.content; + return { + id: `${p.graph.path}/${p.file}`, + title: p.name, + subtitle: first ? plain(first.split("\n")[0]).slice(0, 100) : undefined, + icon: { source: p.journal ? Icons.calendar : Icons.note, tint: "teal" }, + section: o.section, + accessories: [ + ...p.tags.slice(0, 2).map((t) => ({ tag: `#${t}`, color: "teal" as const })), + ...(p.backlinks ? [{ icon: Icons.link, text: String(p.backlinks), tooltip: `${p.backlinks} backlinks` }] : []), + ...(o.several ? [{ text: p.graph.name }] : []), + { text: ago(p.modified), tooltip: `Modified ${when(p.modified)}` }, + ], + detail: { markdown: previewMarkdown(p.page, 3000) || "*Empty page*", metadata: pageMetadata(p, o.several) }, + actions: pageActions(p, o.several), + }; +} + +export function blockItem(p: PageFile, b: Block, o: { several: boolean }): Item { + const marker = taskMarker(b.content); + const text = plain(b.content.split("\n")[0]) || plain(b.content); + const uri = b.id ? blockUri(p.graph.name, b.id) : pageUri(p.graph.name, p.name); + return { + id: `${p.graph.path}/${p.file}#${b.id ?? text.slice(0, 40)}`, + title: text.slice(0, 120), + subtitle: p.name, + icon: marker + ? { source: marker === "DONE" ? Icons.squareCheck : Icons.square, tint: marker === "DONE" ? "green" : "orange" } + : { source: Icons.dot, tint: "teal" }, + section: "Blocks", + accessories: [...(marker ? [{ tag: marker }] : []), ...(o.several ? [{ text: p.graph.name }] : [])], + detail: { + markdown: previewMarkdown({ properties: {}, blocks: [{ ...b, depth: 0 }] }, 3000), + metadata: [ + meta.text("Page", p.name, { icon: p.journal ? Icons.calendar : Icons.note, color: "teal" }), + ...(o.several ? [meta.text("Graph", p.graph.name, { icon: Icons.layers })] : []), + ...propertyLines(b.properties), + meta.text("Modified", when(p.modified), { icon: Icons.clock }), + ], + }, + actions: [ + open(uri, b.id ? "Open Block in Logseq" : "Open Page in Logseq", { icon: Icons.external }), + ...(b.id ? [open(pageUri(p.graph.name, p.name), "Open Page in Logseq", { icon: Icons.note })] : []), + action("View Page", () => pagePage(p, o.several), { icon: Icons.eye, shortcut: "o" }), + copy(b.content, "Copy Block", { shortcut: "c", section: "Copy" }), + ...(b.id + ? [ + copy(`((${b.id}))`, "Copy Block Reference", { section: "Copy" }), + paste(`((${b.id}))`, "Paste Block Reference", { shortcut: "v", section: "Copy" }), + ] + : []), + copy(`[[${p.name}]]`, "Copy Page Reference", { section: "Copy" }), + ], + }; +} + +export const NO_GRAPH = { + title: "No Logseq graph found", + description: + "Open a folder in Logseq once (it is listed in ~/.logseq/graphs), or set a graph folder in Settings → Extensions → Logseq.", + icon: Icons.layers, +}; diff --git a/extensions/logseq/lib.ts b/extensions/logseq/lib.ts new file mode 100644 index 0000000..9c3c6f2 --- /dev/null +++ b/extensions/logseq/lib.ts @@ -0,0 +1,355 @@ +// Logseq graphs on disk: graph discovery, config.edn, file names, the +// outline format, references and journals. Pure functions, no I/O. + +export interface Graph { + name: string; + /** Absolute folder. */ + path: string; + /** When Logseq last used it (ms), from its cache file. */ + used?: number; +} + +/** + * `~/.logseq/graphs/logseq_local_++home++me++notes.transit` → `/home/me/notes`. + * Other cache files (Windows paths, iCloud, the GitHub sync of old versions) are ignored. + */ +export function graphFromCacheFile(file: string): Graph | undefined { + const m = /^logseq_local_(.+)\.transit$/.exec(file); + if (!m || !m[1].startsWith("++")) return undefined; + const path = m[1].replace(/\+\+/g, "/").replace(/\/+$/, ""); + const name = path.split("/").pop() ?? ""; + return name ? { name, path } : undefined; +} + +export interface Config { + journalFile: string; + journalTitle: string; + pagesDir: string; + journalsDir: string; + tripleLowbar: boolean; + org: boolean; +} + +export const DEFAULT_CONFIG: Config = { + journalFile: "yyyy_MM_dd", + journalTitle: "MMM do, yyyy", + pagesDir: "pages", + journalsDir: "journals", + tripleLowbar: false, + org: false, +}; + +/** The few keys of `logseq/config.edn` that decide where files are and what they are called. */ +export function parseConfig(edn: string | undefined): Config { + if (!edn) return { ...DEFAULT_CONFIG }; + const text = edn.split("\n").map((l) => l.replace(/(^|[^\\"]);.*$/, "$1")).join("\n"); + const str = (key: string) => new RegExp(`:${key.replace("/", "\\/")}\\s+"([^"]*)"`).exec(text)?.[1]; + const kw = (key: string) => new RegExp(`:${key.replace("/", "\\/")}\\s+:([\\w-]+)`).exec(text)?.[1]; + return { + journalFile: str("journal/file-name-format") ?? DEFAULT_CONFIG.journalFile, + journalTitle: str("journal/page-title-format") ?? DEFAULT_CONFIG.journalTitle, + pagesDir: str("pages-directory") ?? DEFAULT_CONFIG.pagesDir, + journalsDir: str("journals-directory") ?? DEFAULT_CONFIG.journalsDir, + tripleLowbar: kw("file/name-format") === "triple-lowbar", + org: /^org$/i.test(kw("preferred-format") ?? str("preferred-format") ?? ""), + }; +} + +// --- Dates (date-fns-like formats, as Logseq uses) ---------------------------- + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 + ? "th" + : ({ 1: "st", 2: "nd", 3: "rd" } as Record)[n % 10] ?? "th"; + return `${n}${s}`; +} + +/** `yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dd`, `do`, `d`, `EEEE`, `EEE`, `E`; anything else is kept. */ +export function formatDate(format: string, d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + const tokens: [string, () => string][] = [ + ["yyyy", () => String(d.getFullYear())], + ["yy", () => String(d.getFullYear()).slice(-2)], + ["MMMM", () => MONTHS[d.getMonth()]], + ["MMM", () => MONTHS[d.getMonth()].slice(0, 3)], + ["MM", () => p(d.getMonth() + 1)], + ["M", () => String(d.getMonth() + 1)], + ["dd", () => p(d.getDate())], + ["do", () => ordinal(d.getDate())], + ["d", () => String(d.getDate())], + ["EEEE", () => DAYS[d.getDay()]], + ["EEE", () => DAYS[d.getDay()].slice(0, 3)], + ["E", () => DAYS[d.getDay()].slice(0, 3)], + ]; + let out = ""; + for (let i = 0; i < format.length;) { + if (format[i] === "'") { + const close = format.indexOf("'", i + 1); + out += format.slice(i + 1, close < 0 ? undefined : close); + i = close < 0 ? format.length : close + 1; + continue; + } + const hit = tokens.find(([t]) => format.startsWith(t, i)); + if (hit) { + out += hit[1](); + i += hit[0].length; + } else out += format[i++]; + } + return out; +} + +/** `2026_10_28` with format `yyyy_MM_dd` → that date (only numeric formats are read back). */ +export function parseJournalFile(base: string, format: string): Date | undefined { + const order: string[] = []; + const re = new RegExp( + "^" + + format.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/yyyy|MM|dd/g, (t) => { + order.push(t); + return t === "yyyy" ? "(\\d{4})" : "(\\d{2})"; + }) + "$", + ); + const m = re.exec(base); + if (!m || order.length !== 3) return undefined; + const v = Object.fromEntries(order.map((t, i) => [t, Number(m[i + 1])])); + const date = new Date(v.yyyy, v.MM - 1, v.dd); + return date.getMonth() === v.MM - 1 ? date : undefined; +} + +// --- Page names -------------------------------------------------------------- + +/** A page's name from its file: `a___b.md` (triple-lowbar) or `a%2Fb.md` (legacy) → `a/b`. */ +export function pageNameFromFile(base: string, tripleLowbar: boolean): string { + let name = base; + if (tripleLowbar) name = name.replace(/___/g, "/"); + try { + name = decodeURIComponent(name); + } catch { /* not encoded */ } + return name; +} + +/** Logseq links pages case-insensitively. */ +export const pageKey = (name: string) => name.trim().toLowerCase(); + +export const pageUri = (graph: string, page: string) => + `logseq://graph/${encodeURIComponent(graph)}?page=${encodeURIComponent(page)}`; +export const blockUri = (graph: string, id: string) => + `logseq://graph/${encodeURIComponent(graph)}?block-id=${encodeURIComponent(id)}`; +export const graphUri = (graph: string) => `logseq://graph/${encodeURIComponent(graph)}`; + +// --- The outline ------------------------------------------------------------- + +export interface Block { + /** Text without its bullet and properties, continuation lines joined with `\n`. */ + content: string; + /** The `id::` property, set when the block was referenced or linked. */ + id?: string; + depth: number; + properties: Record; +} + +export interface Page { + properties: Record; + blocks: Block[]; +} + +const PROPERTY = /^\s*([A-Za-z0-9_\-/.]+)::\s?(.*)$/; + +/** Splits a Logseq markdown file into its page properties and blocks. */ +export function parsePage(text: string): Page { + const properties: Record = {}; + const blocks: Block[] = []; + type Open = { depth: number; indent: number; lines: string[]; props: Record; pageProps: boolean }; + let cur: Open | undefined; + let started = false; + let inCode = false; + const flush = () => { + if (!cur) return; + const content = cur.lines.join("\n").trim(); + if (content || cur.props.id) blocks.push({ content, id: cur.props.id, depth: cur.depth, properties: cur.props }); + cur = undefined; + }; + const add = (line: string) => { + if (/^\s*```/.test(line)) inCode = !inCode; + const prop = !inCode && PROPERTY.exec(line); + if (!prop) return void cur!.lines.push(line); + (cur!.pageProps ? properties : cur!.props)[prop[1].toLowerCase()] = prop[2].trim(); + }; + for (const line of text.replace(/\r/g, "").split("\n")) { + const bullet = inCode ? null : /^([ \t]*)[-*](?: (.*))?$/.exec(line); + if (bullet) { + flush(); + const ws = bullet[1].replace(/\t/g, " ").length; + const first = bullet[2] ?? ""; + // A first block made only of properties holds the page's properties. + const pageProps = !started && ws === 0 && PROPERTY.test(first); + started = true; + cur = { depth: Math.floor(ws / 2), indent: ws, lines: [], props: {}, pageProps }; + add(first); + continue; + } + if (!started) { + const prop = PROPERTY.exec(line); + if (prop) properties[prop[1].toLowerCase()] = prop[2].trim(); + else if (line.trim()) { + // Text before any bullet: a block of its own. + started = true; + cur = { depth: 0, indent: 0, lines: [], props: {}, pageProps: false }; + add(line.trim()); + } + continue; + } + if (!cur) continue; + // Continuation lines are indented two spaces past the bullet; deeper indentation is kept. + const ws = /^[ \t]*/.exec(line)![0].replace(/\t/g, " ").length; + add(" ".repeat(Math.max(0, ws - cur.indent - 2)) + line.replace(/^[ \t]*/, "")); + } + flush(); + return { properties, blocks }; +} + +/** `[[Page]]`, `#tag`, `#[[Long tag]]` in a text, lowercased, in order, once each. */ +export function references(text: string): string[] { + const out = new Set(); + const clean = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, ""); + for (const m of clean.matchAll(/\[\[([^\]]+)\]\]/g)) out.add(pageKey(m[1])); + for (const m of clean.matchAll(/(?:^|\s)#([^\s#[\],.;:!?"'()]+)/g)) out.add(pageKey(m[1])); + return [...out]; +} + +/** `tags:: [[a]], b` or `alias:: x, [[y z]]` → `["a", "b"]`. */ +export function listProperty(value: string | undefined): string[] { + if (!value) return []; + return value.split(",").map((v) => v.trim().replace(/^\[\[(.*)\]\]$/, "$1").replace(/^#/, "")).filter(Boolean); +} + +/** How many blocks of other pages reference each page (key: lowercased name). */ +export function backlinkCounts(pages: { name: string; page: Page; aliases?: string[] }[]): Map { + const alias = new Map(); + for (const p of pages) for (const a of p.aliases ?? []) alias.set(pageKey(a), pageKey(p.name)); + const counts = new Map(); + for (const p of pages) { + const self = pageKey(p.name); + const tags = listProperty(p.page.properties.tags).map(pageKey); + for (const t of tags) if (t !== self) counts.set(alias.get(t) ?? t, (counts.get(alias.get(t) ?? t) ?? 0) + 1); + for (const b of p.page.blocks) { + const refs = new Set( + [...references(b.content), ...listProperty(b.properties.tags).map(pageKey)].map((r) => alias.get(r) ?? r), + ); + for (const r of refs) if (r !== self) counts.set(r, (counts.get(r) ?? 0) + 1); + } + } + return counts; +} + +/** Inline Logseq syntax made readable: `[[x]]` → x, TODO markers, `{{embed}}`, highlights. */ +export function plain(text: string): string { + return text + .replace(/^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELED|CANCELLED)\s+/, "") + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/#\[\[([^\]]+)\]\]/g, "#$1") + .replace(/\{\{[^}]*\}\}/g, "") + .replace(/\(\([0-9a-f-]{36}\)\)/g, "…") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*_`~^=]{1,2}([^*_`~^=]+)[*_`~^=]{1,2}/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +export function taskMarker(text: string): string | undefined { + return /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELED|CANCELLED)\s/.exec(text)?.[1]; +} + +/** The outline as markdown for the preview panel. */ +export function previewMarkdown(page: Page, max = 4000): string { + const out: string[] = []; + let size = 0; + for (const b of page.blocks) { + const marker = taskMarker(b.content); + let text = b.content + .replace(/\[\[([^\]]+)\]\]/g, "**$1**") + .replace(/\(\(([0-9a-f-]{36})\)\)/g, "*(block ref)*") + .replace(/\{\{embed[^}]*\}\}/g, "*(embed)*"); + if (marker) { + text = text.replace( + /^\S+\s+/, + marker === "DONE" ? "[x] " : marker === "CANCELED" || marker === "CANCELLED" ? "~~" : "[ ] ", + ); + } + const indent = " ".repeat(Math.min(b.depth, 6)); + const lines = text.split("\n"); + const line = `${indent}- ${lines[0]}${lines.slice(1).map((l) => `\n${indent} ${l}`).join("")}`; + out.push(line); + size += line.length; + if (size > max) { + out.push("\n…"); + break; + } + } + return out.join("\n"); +} + +export function wordCount(page: Page): number { + return page.blocks.reduce((n, b) => n + b.content.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length, 0); +} + +// --- Capture ----------------------------------------------------------------- + +/** + * Appends a top-level block to a journal file. Several lines become one + * block with continuation lines; the file keeps a final line break. + */ +export function appendBlock(content: string, text: string, o: { org?: boolean; time?: string } = {}): string { + const lines = text.replace(/\r/g, "").trim().split("\n"); + const bullet = o.org ? "*" : "-"; + const first = `${bullet} ${o.time ? `${o.time} ` : ""}${lines[0]}`; + const block = [first, ...lines.slice(1).map((l) => ` ${l}`)].join("\n"); + // A new journal is just "-" (Logseq's empty first block): replace it. + const existing = content.replace(/\s+$/, ""); + if (!existing || existing === bullet) return `${block}\n`; + return `${existing}\n${block}\n`; +} + +// --- Search ------------------------------------------------------------------ + +const fold = (s: string) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); + +/** Every word must be in the text; 3 per word at a word start, 1 inside a word. */ +export function score(text: string, query: string): number { + const t = fold(text); + let s = 0; + for (const w of fold(query).split(/\s+/).filter(Boolean)) { + const i = t.indexOf(w); + if (i < 0) return 0; + s += i === 0 || /[\s\[#(/_-]/.test(t[i - 1]) ? 3 : 1; + } + return s; +} + +export function ago(ms: number, now = Date.now()): string { + const m = Math.round((now - ms) / 60_000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.round(h / 24); + if (d < 30) return `${d}d ago`; + return d < 365 ? `${Math.round(d / 30.4)}mo ago` : `${Math.round(d / 365)}y ago`; +} + +export const plural = (n: number, word: string) => `${n.toLocaleString("en")} ${word}${n === 1 ? "" : "s"}`; diff --git a/extensions/logseq/lib_test.ts b/extensions/logseq/lib_test.ts new file mode 100644 index 0000000..7f10e10 --- /dev/null +++ b/extensions/logseq/lib_test.ts @@ -0,0 +1,178 @@ +import { assert, assertEquals } from "@std/assert"; +import { + appendBlock, + backlinkCounts, + blockUri, + DEFAULT_CONFIG, + formatDate, + graphFromCacheFile, + listProperty, + ordinal, + pageNameFromFile, + pageUri, + parseConfig, + parseJournalFile, + parsePage, + plain, + previewMarkdown, + references, + score, + taskMarker, +} from "./lib.ts"; +import { pickGraph } from "./graph.ts"; + +const PAGE = `title:: Project Alpha +tags:: [[project]], work +alias:: Alpha +status:: active + +- Kickoff with [[Bob Smith]] #meeting + id:: 6720f1b2-1c3d-4e5f-8a9b-0c1d2e3f4a5b + collapsed:: true + - TODO Write the brief + SCHEDULED: <2026-10-30 Fri> + - DONE Book the room #[[Meeting Rooms]] +- Notes + continued on a second line + \`\`\`js + - not a block:: really + \`\`\` +- +`; + +Deno.test("graphs from Logseq's cache files", () => { + assertEquals(graphFromCacheFile("logseq_local_++home++me++Documents++notes.transit"), { + name: "notes", + path: "/home/me/Documents/notes", + }); + assertEquals(graphFromCacheFile("logseq_local_C:++Users++me++notes.transit"), undefined); + assertEquals(graphFromCacheFile("logseq_local_++home++me++notes++.transit")?.path, "/home/me/notes"); + assertEquals(graphFromCacheFile("something.json"), undefined); +}); + +Deno.test("config.edn keys, comments ignored", () => { + const edn = `{:meta/version 1 + ;; :journal/page-title-format "yyyy-MM-dd" + :journal/page-title-format "EEE, dd.MM.yyyy" + :journal/file-name-format "yyyy-MM-dd" + :preferred-format :markdown + :file/name-format :triple-lowbar + :journals-directory "daily"}`; + assertEquals(parseConfig(edn), { + journalTitle: "EEE, dd.MM.yyyy", + journalFile: "yyyy-MM-dd", + pagesDir: "pages", + journalsDir: "daily", + tripleLowbar: true, + org: false, + }); + assertEquals(parseConfig(undefined), DEFAULT_CONFIG); + assertEquals(parseConfig(`{:preferred-format "Org"}`).org, true); +}); + +Deno.test("journal dates", () => { + const d = new Date(2026, 9, 28); + assertEquals(formatDate("MMM do, yyyy", d), "Oct 28th, 2026"); + assertEquals(formatDate("yyyy_MM_dd", d), "2026_10_28"); + assertEquals(formatDate("EEEE, MMMM d, yyyy", new Date(2026, 0, 1)), "Thursday, January 1, 2026"); + assertEquals(formatDate("EEE, dd.MM.yy", new Date(2026, 1, 3)), "Tue, 03.02.26"); + assertEquals(formatDate("do 'of' MMMM", new Date(2026, 4, 22)), "22nd of May"); + assertEquals( + [1, 2, 3, 4, 11, 12, 13, 21, 22, 23].map(ordinal).join(" "), + "1st 2nd 3rd 4th 11th 12th 13th 21st 22nd 23rd", + ); + assertEquals(parseJournalFile("2026_10_28", "yyyy_MM_dd")?.getDate(), 28); + assertEquals(parseJournalFile("2026-02-30", "yyyy-MM-dd"), undefined); + assertEquals(parseJournalFile("notes", "yyyy_MM_dd"), undefined); +}); + +Deno.test("page names from files", () => { + assertEquals(pageNameFromFile("projects___alpha", true), "projects/alpha"); + assertEquals(pageNameFromFile("projects%2Falpha", false), "projects/alpha"); + assertEquals(pageNameFromFile("What%3F", false), "What?"); + assertEquals(pageNameFromFile("100% sure", false), "100% sure"); + assertEquals(pageUri("my notes", "Oct 28th, 2026"), "logseq://graph/my%20notes?page=Oct%2028th%2C%202026"); + assertEquals(blockUri("notes", "abc"), "logseq://graph/notes?block-id=abc"); +}); + +Deno.test("the outline: page properties, blocks, block properties, code", () => { + const page = parsePage(PAGE); + assertEquals(page.properties, { + title: "Project Alpha", + tags: "[[project]], work", + alias: "Alpha", + status: "active", + }); + assertEquals(page.blocks.map((b) => [b.depth, b.content]), [ + [0, "Kickoff with [[Bob Smith]] #meeting"], + [1, "TODO Write the brief\nSCHEDULED: <2026-10-30 Fri>"], + [1, "DONE Book the room #[[Meeting Rooms]]"], + [0, "Notes\ncontinued on a second line\n```js\n- not a block:: really\n```"], + ]); + assertEquals(page.blocks[0].id, "6720f1b2-1c3d-4e5f-8a9b-0c1d2e3f4a5b"); + assertEquals(page.blocks[0].properties.collapsed, "true"); + // Page properties in a first bullet, as Logseq writes them in some versions. + const bulleted = parsePage("- tags:: a, b\n type:: book\n- First block\n"); + assertEquals(bulleted.properties, { tags: "a, b", type: "book" }); + assertEquals(bulleted.blocks.map((b) => b.content), ["First block"]); + // A file without bullets is one block. + assertEquals(parsePage("Just text\n").blocks.map((b) => b.content), ["Just text"]); + assertEquals(parsePage("").blocks, []); +}); + +Deno.test("references, list properties and backlinks", () => { + assertEquals(references("See [[Bob Smith]] and #meeting, #[[Meeting Rooms]] `#not` [[bob smith]]"), [ + "bob smith", + "meeting rooms", + "meeting", + ]); + assertEquals(references("C# and issue#12 and https://x.org/#anchor"), []); + assertEquals(listProperty("[[project]], work, #later"), ["project", "work", "later"]); + const alpha = { name: "Project Alpha", page: parsePage(PAGE), aliases: ["Alpha"] }; + const journal = { + name: "Oct 28th, 2026", + page: parsePage("- Met [[Alpha]] team\n- [[Project Alpha]] again #meeting\n"), + }; + const counts = backlinkCounts([alpha, journal, { name: "Project", page: parsePage("- [[project]] self ref") }]); + assertEquals(counts.get("project alpha"), 2); // via its alias, and by name + assertEquals(counts.get("meeting"), 2); + assertEquals(counts.get("project"), 1); // tags:: of Project Alpha; its own self-reference doesn't count + assertEquals(counts.get("bob smith"), 1); +}); + +Deno.test("readable text and previews", () => { + assertEquals( + plain("TODO Call [[Bob Smith]] about **the** ((6720f1b2-1c3d-4e5f-8a9b-0c1d2e3f4a5b)) {{embed [[x]]}}"), + "Call Bob Smith about the …", + ); + assertEquals(taskMarker("DONE x"), "DONE"); + assertEquals(taskMarker("TODOS x"), undefined); + const md = previewMarkdown(parsePage(PAGE)); + assert(md.startsWith("- Kickoff with **Bob Smith** #meeting\n - [ ] Write the brief\n SCHEDULED")); + assert(md.includes(" - [x] Book the room")); +}); + +Deno.test("capturing a block", () => { + assertEquals(appendBlock("", "Call the bank"), "- Call the bank\n"); + assertEquals(appendBlock("-\n", "Call the bank"), "- Call the bank\n"); // Logseq's empty new journal + assertEquals(appendBlock("- one", "two"), "- one\n- two\n"); + assertEquals(appendBlock("- one\n\n\n", "two\nmore", { time: "14:05" }), "- one\n- 14:05 two\n more\n"); + assertEquals(appendBlock("* one\n", "two", { org: true }), "* one\n* two\n"); +}); + +Deno.test("search scoring", () => { + assertEquals(score("Project Alpha", "alp"), 3); + assertEquals(score("Project Alpha", "pha"), 1); + assertEquals(score("Project Alpha", "proj alp"), 6); + assertEquals(score("Project Alpha", "beta"), 0); + assertEquals(score("Café notes", "cafe"), 3); +}); + +Deno.test("the graph for journal and captures", () => { + const graphs = [{ name: "work", path: "/h/Logseq/work", used: 2 }, { name: "notes", path: "/h/notes", used: 1 }]; + assertEquals(pickGraph(graphs, { preferences: {} })?.name, "work"); + assertEquals(pickGraph(graphs, { preferences: { graph: "Notes" } })?.name, "notes"); + assertEquals(pickGraph(graphs, { preferences: { graph: "/h/notes/" } })?.name, "notes"); + assertEquals(pickGraph(graphs, { preferences: { graph: "missing" } })?.name, "work"); + assertEquals(pickGraph([], { preferences: {} }), undefined); +}); diff --git a/extensions/todo-txt/backslash.toml b/extensions/todo-txt/backslash.toml new file mode 100644 index 0000000..ca63ae0 --- /dev/null +++ b/extensions/todo-txt/backslash.toml @@ -0,0 +1,57 @@ +[extension] +id = "todo-txt" +title = "Todo.txt" +description = "Your todo.txt tasks by due date, with priorities, +projects, @contexts, recurrence and quick add" +version = "1.0.0" +author = "Backslash" +icon = "bs-square-check" +license = "MIT" + +# Extensions declare their files ahead of time, so todo.txt is read from the +# usual places only: ~/todo.txt or ~/Documents/todo.txt (with done.txt next +# to it), or the folders that sync apps and todo.sh use, ~/Dropbox/todo +# (Simpletask, Markor) and ~/.todo. Writing is needed to add, complete and +# edit tasks; the file is watched to reload when another app changes it. +[permissions] +read = ["~/todo.txt", "~/done.txt", "~/Documents/todo.txt", "~/Documents/done.txt", "~/Dropbox/todo", "~/.todo"] +write = ["~/todo.txt", "~/done.txt", "~/Documents/todo.txt", "~/Documents/done.txt", "~/Dropbox/todo", "~/.todo"] +env = ["HOME"] + +[[commands]] +name = "tasks" +title = "Todo.txt Tasks" +description = "Overdue, today's and upcoming tasks; complete, edit, prioritize" +icon = "bs-square-check" +keywords = ["todo", "todo.txt", "tasks", "gtd", "due", "today", "tâches", "à faire", "liste"] + +[[commands]] +name = "add" +title = "Add Todo.txt Task" +description = "“(A) Call Bob +Work @phone due:fri rec:1w”, saved at once" +icon = "bs-plus" +mode = "no-view" +keywords = ["todo", "add", "task", "new", "capture", "ajouter", "tâche", "nouvelle"] +[[commands.arguments]] +name = "task" +placeholder = "(A) Call Bob +Work @phone due:fri" +required = true + +[[preferences]] +name = "file" +title = "todo.txt file" +description = "~/todo.txt, ~/Documents/todo.txt, or a file in ~/Dropbox/todo or ~/.todo. Empty: the first of these that exists" +default = "" + +[[preferences]] +name = "archive" +title = "Move completed tasks to done.txt" +description = "Off: completed tasks stay in todo.txt, marked “x”" +type = "boolean" +default = true + +[[preferences]] +name = "createdDate" +title = "Add the creation date to new tasks" +description = "“2026-10-28 Call Bob”, as todo.sh -t does" +type = "boolean" +default = true diff --git a/extensions/todo-txt/commands/add.ts b/extensions/todo-txt/commands/add.ts new file mode 100644 index 0000000..196747f --- /dev/null +++ b/extensions/todo-txt/commands/add.ts @@ -0,0 +1,15 @@ +import { run } from "@backslash/api"; +import { normalizeNewTask, parseTask, relativeDay, tildify } from "../lib.ts"; +import { changeLine, HOME, locate, prefs, today } from "../todo.ts"; + +// "Add Todo.txt Task (A) Call Bob +Work @phone due:fri": appended at once. +export default run<{ task: string }>(async (ctx) => { + const input = String(ctx.args.task ?? ctx.query ?? "").trim(); + if (!input) throw new Error("Type the task after the command"); + const files = await locate(ctx); + const line = normalizeNewTask(input, today(), prefs(ctx).addCreated); + await changeLine(files, undefined, [line]); + const t = parseTask(line); + const due = t.due ? `, due ${relativeDay(t.due, today())}` : ""; + return `Added to ${tildify(files.todo, HOME)}${due}`; +}); diff --git a/extensions/todo-txt/commands/tasks.ts b/extensions/todo-txt/commands/tasks.ts new file mode 100644 index 0000000..6b38d9a --- /dev/null +++ b/extensions/todo-txt/commands/tasks.ts @@ -0,0 +1,63 @@ +import { action, type Context, Icons, list, open, toast, updateCommandMetadata } from "@backslash/api"; +import { dueSummary, matchesFilter, normalizeNewTask, sortTasks, tildify, topLabels } from "../lib.ts"; +import { changeLine, HOME, live, loadTasks, locate, prefs, taskForm, taskItem, today } from "../todo.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + const files = await locate(ctx); + const { archive, addCreated } = prefs(ctx); + const open0 = (await loadTasks(files)).map((r) => r.task); + const { projects, contexts } = topLabels(open0); + const where = tildify(files.todo, HOME); + const newTask = action("New Task", () => taskForm(files, { text: ctx.query, addCreated }), { + icon: Icons.plus, + shortcut: "n", + }); + return list({ + placeholder: "Filter tasks by text, +project, @context or (A)…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "due", title: "Due This Week" }, + { value: "pri", title: "Prioritized" }, + ...projects.map((p) => ({ value: `+${p}`, title: `+${p}` })), + ...contexts.map((c) => ({ value: `@${c}`, title: `@${c}` })), + { value: "done", title: "Completed" }, + ], + }, + empty: files.exists ? { title: "No task here", description: "Press Ctrl+N to add one", icon: Icons.squareCheck } : { + title: `No todo.txt yet`, + description: `Press Ctrl+N to add your first task: it creates ${where}. ` + + "Set another file (e.g. ~/Dropbox/todo/todo.txt) in Settings → Extensions → Todo.txt.", + icon: Icons.squareCheck, + }, + status: () => footer, + actions: [ + newTask, + ...(ctx.query.trim() + ? [action(`Add “${ctx.query.trim()}”`, async () => { + await changeLine(files, undefined, [normalizeNewTask(ctx.query, today(), addCreated)]); + await toast("Task added", { style: "success" }); + }, { icon: Icons.plus })] + : []), + open(files.todo, "Open todo.txt", { icon: Icons.file, shortcut: "o" }), + ], + items: ({ signal, filter }) => + live( + async () => { + const now = today(); + const rows = await loadTasks(files, filter === "done"); + const tasks = rows.filter((r) => !r.archived).map((r) => r.task); + const summary = dueSummary(tasks, now); + updateCommandMetadata({ subtitle: summary ?? null }).catch(() => {}); + const shown = sortTasks(rows.filter((r) => matchesFilter(r.task, filter, now)), now); + const openCount = tasks.filter((t) => !t.done).length; + footer = [`${openCount} open task${openCount === 1 ? "" : "s"}`, summary, where].filter(Boolean).join(" · "); + return shown.map((r) => taskItem(r, files, { archive, addCreated, now })); + }, + signal, + files.exists ? files.todo : undefined, + ), + }); +}; diff --git a/extensions/todo-txt/lib.ts b/extensions/todo-txt/lib.ts new file mode 100644 index 0000000..e7e59e5 --- /dev/null +++ b/extensions/todo-txt/lib.ts @@ -0,0 +1,518 @@ +// The todo.txt format (http://todotxt.org): parsing, writing, dates, +// recurrence and the views of a task list. Pure functions, no I/O. + +export interface Task { + /** The line as found in the file (without the line break). */ + raw: string; + done: boolean; + /** YYYY-MM-DD */ + completed?: string; + created?: string; + /** "A"…"Z"; for completed tasks, taken from `pri:A` when the priority was moved there. */ + priority?: string; + /** Everything after the completion mark, dates and priority. */ + text: string; + /** The words of `text` without +projects, @contexts and key:value pairs. */ + title: string; + projects: string[]; + contexts: string[]; + /** key:value pairs, keys lowercased (`due`, `t`, `rec`, `pri`, …). */ + tags: Record; + due?: string; + threshold?: string; + rec?: string; +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const validDate = (s: string): boolean => DATE_RE.test(s) && new Date(`${s}T00:00:00Z`).toISOString().startsWith(s); +export const isDate = (s: string | undefined): s is string => { + try { + return !!s && validDate(s); + } catch { + return false; + } +}; + +/** `due:2026-10-01`, `rec:+1w`, never URLs (`https://…`) nor times (`10:30`). */ +export function keyValue(word: string): [string, string] | undefined { + const m = /^([^\s:]+):([^\s:]+)$/.exec(word); + if (!m || m[2].startsWith("//") || /^\d+$/.test(m[1])) return undefined; + return [m[1].toLowerCase(), m[2]]; +} + +export function parseTask(raw: string): Task { + let rest = raw.trim(); + const take = (re: RegExp): string | undefined => { + const m = re.exec(rest); + if (!m) return undefined; + rest = rest.slice(m[0].length); + return m[1]; + }; + const DATE = /^(\d{4}-\d{2}-\d{2})(?:\s+|$)/; + const PRI = /^\(([A-Z])\)(?:\s+|$)/; + let done = false; + let completed: string | undefined; + let created: string | undefined; + let priority: string | undefined; + if (/^x(\s|$)/.test(rest)) { + done = true; + rest = rest.slice(1).trimStart(); + completed = take(DATE); + // Lenient: some apps keep the priority before or after the dates. + priority = take(PRI); + if (completed) created = take(DATE); + priority ??= take(PRI); + } else { + priority = take(PRI); + created = take(DATE); + } + const text = rest.trim(); + const projects: string[] = []; + const contexts: string[] = []; + const tags: Record = {}; + const words: string[] = []; + for (const word of text.split(/\s+/).filter(Boolean)) { + if (/^\+\S+$/.test(word)) { + if (!projects.includes(word.slice(1))) projects.push(word.slice(1)); + } else if (/^@\S+$/.test(word)) { + if (!contexts.includes(word.slice(1))) contexts.push(word.slice(1)); + } else { + const kv = keyValue(word); + if (kv) tags[kv[0]] = kv[1]; + else words.push(word); + } + } + if (done && !priority && /^[A-Z]$/.test(tags.pri ?? "")) priority = tags.pri; + return { + raw, + done, + completed: isDate(completed) ? completed : undefined, + created: isDate(created) ? created : undefined, + priority, + text, + title: words.join(" ") || text, + projects, + contexts, + tags, + due: isDate(tags.due) ? tags.due : undefined, + threshold: isDate(tags.t) ? tags.t : undefined, + rec: tags.rec, + }; +} + +/** Lines of a file: blank lines dropped, `\r` removed. */ +export function parseFile(content: string): Task[] { + return content.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.trim()).map(parseTask); +} + +export interface TaskParts { + done?: boolean; + completed?: string; + created?: string; + priority?: string; + text: string; +} + +/** Builds a line; for completed tasks the priority becomes `pri:X`, as todo.sh does. */ +export function formatTask(p: TaskParts): string { + const parts: string[] = []; + let text = p.text.trim(); + if (p.done) { + parts.push("x"); + if (p.completed) parts.push(p.completed); + if (p.completed && p.created) parts.push(p.created); + if (p.priority && !/(^|\s)pri:[A-Z](\s|$)/.test(text)) text = `${text} pri:${p.priority}`.trim(); + } else { + if (p.priority) parts.push(`(${p.priority})`); + if (p.created) parts.push(p.created); + } + if (text) parts.push(text); + return parts.join(" "); +} + +/** Replaces (or removes, with `undefined`) one key:value in a description. */ +export function setTag(text: string, key: string, value: string | undefined): string { + const words = text.split(/\s+/).filter(Boolean); + const out: string[] = []; + let set = false; + for (const w of words) { + const kv = keyValue(w); + if (kv && kv[0] === key.toLowerCase()) { + if (value !== undefined && !set) out.push(`${key}:${value}`); + set = true; + } else out.push(w); + } + if (!set && value !== undefined) out.push(`${key}:${value}`); + return out.join(" "); +} + +// --- Dates (calendar days, no time zone involved) ---------------------------- + +/** Local date as YYYY-MM-DD. */ +export function isoDay(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +const toUtc = (iso: string) => { + const [y, m, d] = iso.split("-").map(Number); + return Date.UTC(y, m - 1, d); +}; +const fromUtc = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +/** Days from `a` to `b` (both YYYY-MM-DD). */ +export const daysBetween = (a: string, b: string) => Math.round((toUtc(b) - toUtc(a)) / 86_400_000); + +export function addDays(iso: string, n: number): string { + return fromUtc(toUtc(iso) + n * 86_400_000); +} + +/** Months added with the day kept, or the month's last day (Jan 31 + 1m = Feb 28). */ +export function addMonths(iso: string, n: number): string { + const [y, m, d] = iso.split("-").map(Number); + const first = new Date(Date.UTC(y, m - 1 + n, 1)); + const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate(); + first.setUTCDate(Math.min(d, last)); + return first.toISOString().slice(0, 10); +} + +/** 0 = Sunday. */ +export const weekday = (iso: string) => new Date(toUtc(iso)).getUTCDay(); + +export function addBusinessDays(iso: string, n: number): string { + let day = iso; + let left = n; + while (left > 0) { + day = addDays(day, 1); + if (weekday(day) !== 0 && weekday(day) !== 6) left--; + } + return day; +} + +export interface Recurrence { + /** `+`: from the due date (strict), else from the completion day. */ + strict: boolean; + amount: number; + unit: "d" | "b" | "w" | "m" | "y"; +} + +/** `rec:1w`, `rec:+2m`, `rec:3d`, `rec:5b` (business days), `rec:1y`; a bare `rec:w` means 1. */ +export function parseRecurrence(value: string | undefined): Recurrence | undefined { + const m = /^(\+)?(\d*)([dbwmy])$/i.exec(value ?? ""); + if (!m) return undefined; + const amount = m[2] ? Number(m[2]) : 1; + if (amount < 1) return undefined; + return { strict: !!m[1], amount, unit: m[3].toLowerCase() as Recurrence["unit"] }; +} + +export function addInterval(iso: string, r: Pick): string { + switch (r.unit) { + case "d": + return addDays(iso, r.amount); + case "b": + return addBusinessDays(iso, r.amount); + case "w": + return addDays(iso, 7 * r.amount); + case "m": + return addMonths(iso, r.amount); + case "y": + return addMonths(iso, 12 * r.amount); + } +} + +export function describeRecurrence(value: string | undefined): string | undefined { + const r = parseRecurrence(value); + if (!r) return value; + const names = { d: "day", b: "business day", w: "week", m: "month", y: "year" }; + const every = r.amount === 1 ? `Every ${names[r.unit]}` : `Every ${r.amount} ${names[r.unit]}s`; + return `${every}${r.strict ? ", from the due date" : ", from completion"}`; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + +/** + * A date typed by a person: `2026-11-03`, `today`, `tomorrow`, `mon`…`sunday` + * (the next one, never today), `3d`, `+2w`, `1m`, `1y`, `next week`. + * Returns YYYY-MM-DD, or undefined if not understood. + */ +export function parseDateInput(input: string, today: string): string | undefined { + const s = input.trim().toLowerCase(); + if (!s) return undefined; + if (isDate(input.trim())) return s; + if (["today", "tod", "now"].includes(s)) return today; + if (["tomorrow", "tom", "tmr"].includes(s)) return addDays(today, 1); + if (s === "yesterday") return addDays(today, -1); + if (s === "next week") return addDays(today, 7); + if (s === "next month") return addMonths(today, 1); + const wd = s.length >= 3 ? WEEKDAYS.findIndex((w) => w.startsWith(s)) : -1; + if (wd >= 0) { + const diff = (wd - weekday(today) + 7) % 7 || 7; + return addDays(today, diff); + } + const rel = /^\+?(\d+)\s*([dbwmy])$/.exec(s); + if (rel) return addInterval(today, { amount: Number(rel[1]), unit: rel[2] as Recurrence["unit"] }); + return undefined; +} + +/** + * What `add` writes: relative `due:`/`t:` values made absolute + * (`due:tomorrow` → `due:2026-10-29`) and, with `addCreated`, today's date + * after the priority when the task has none. + */ +export function normalizeNewTask(input: string, today: string, addCreated: boolean): string { + const t = parseTask(input.trim()); + let text = t.text; + for (const key of ["due", "t"]) { + const v = t.tags[key]; + if (v && !isDate(v)) { + const d = parseDateInput(v, today); + if (d) text = setTag(text, key, d); + } + } + if (t.done) return formatTask({ ...t, text, completed: t.completed ?? today }); + return formatTask({ priority: t.priority, created: t.created ?? (addCreated ? today : undefined), text }); +} + +// --- Completing, with recurrence --------------------------------------------- + +export interface Completion { + /** The completed line. */ + done: string; + /** The next occurrence of a `rec:` task, to add to the list. */ + next?: string; +} + +export function completeTask(task: Task, today: string): Completion { + const done = formatTask({ + done: true, + completed: today, + created: task.created, + priority: task.priority, + text: task.text, + }); + const r = parseRecurrence(task.rec); + if (!r) return { done }; + let text = task.text; + if (task.due) { + const due = addInterval(r.strict ? task.due : today, r); + text = setTag(text, "due", due); + // The threshold keeps its distance to the due date. + if (task.threshold) text = setTag(text, "t", addDays(due, -daysBetween(task.threshold, task.due))); + } else if (task.threshold) { + text = setTag(text, "t", addInterval(r.strict ? task.threshold : today, r)); + } else { + text = setTag(text, "due", addInterval(today, r)); + } + const next = formatTask({ priority: task.priority, created: task.created ? today : undefined, text }); + return { done, next }; +} + +/** Back to an open task: the completion mark and date go, `pri:X` becomes the priority again. */ +export function uncompleteTask(task: Task): string { + const pri = task.tags.pri && /^[A-Z]$/.test(task.tags.pri) ? task.tags.pri : task.priority; + return formatTask({ priority: pri, created: task.created, text: setTag(task.text, "pri", undefined) }); +} + +export function setPriority(task: Task, priority: string | undefined): string { + if (task.done) { + return formatTask({ ...task, priority: undefined, text: setTag(task.text, "pri", priority) }); + } + return formatTask({ priority, created: task.created, text: task.text }); +} + +export function setDue(task: Task, due: string | undefined): string { + return formatTask({ ...task, text: setTag(task.text, "due", due) }); +} + +// --- Editing ----------------------------------------------------------------- + +export interface EditValues { + description: string; + priority: string; + due: string; + threshold: string; + rec: string; +} + +/** The form's fields for a task: the dates and recurrence apart from the description. */ +export function editValues(task: Task): EditValues { + let description = task.text; + for (const key of ["due", "t", "rec"]) description = setTag(description, key, undefined); + if (task.done) description = setTag(description, "pri", undefined); + return { + description, + priority: task.priority ?? "", + due: task.tags.due ?? "", + threshold: task.tags.t ?? "", + rec: task.tags.rec ?? "", + }; +} + +/** Builds the line from the form; throws a readable error on a bad date or recurrence. */ +export function applyEdit(task: Task | undefined, v: EditValues, today: string, addCreated: boolean): string { + const description = v.description.trim().replace(/\s+/g, " "); + if (!description) throw new Error("The task needs a description"); + const date = (label: string, s: string) => { + if (!s.trim()) return undefined; + const d = parseDateInput(s, today); + if (!d) throw new Error(`${label}: “${s}” is not a date (2026-11-03, tomorrow, fri, 3d…)`); + return d; + }; + const rec = v.rec.trim(); + if (rec && !parseRecurrence(rec)) throw new Error(`Recurrence: “${rec}” is not valid (1d, 2w, +1m, 1y, 5b)`); + const priority = v.priority.trim().toUpperCase(); + if (priority && !/^[A-Z]$/.test(priority)) throw new Error("Priority: a letter from A to Z"); + // A description typed with its own due:/t:/rec: wins over empty fields. + const typed = parseTask(description); + let text = description; + const due = date("Due", v.due) ?? (typed.tags.due ? date("Due", typed.tags.due) : undefined); + const t = date("Threshold", v.threshold) ?? (typed.tags.t ? date("Threshold", typed.tags.t) : undefined); + text = setTag(text, "due", due); + text = setTag(text, "t", t); + if (rec) text = setTag(text, "rec", rec); + if (!task) return formatTask({ priority: priority || undefined, created: addCreated ? today : undefined, text }); + return formatTask({ + done: task.done, + completed: task.completed, + created: task.created, + priority: priority || undefined, + text, + }); +} + +// --- Views ------------------------------------------------------------------- + +export type Section = "Overdue" | "Today" | "Upcoming" | "No Date" | "Completed"; +export const SECTIONS: Section[] = ["Overdue", "Today", "Upcoming", "No Date", "Completed"]; + +export function sectionOf(task: Task, today: string): Section { + if (task.done) return "Completed"; + if (task.due) { + const d = daysBetween(today, task.due); + return d < 0 ? "Overdue" : d === 0 ? "Today" : "Upcoming"; + } + // Not before a future threshold date: it is planned, so "Upcoming". + if (task.threshold && daysBetween(today, task.threshold) > 0) return "Upcoming"; + return "No Date"; +} + +const priRank = (t: Task) => t.priority ? t.priority.charCodeAt(0) - 65 : 26; + +/** By section, then due date, then priority, then the order in the file. */ +export function sortTasks(rows: T[], today: string): T[] { + return [...rows].sort((a, b) => { + const sa = SECTIONS.indexOf(sectionOf(a.task, today)); + const sb = SECTIONS.indexOf(sectionOf(b.task, today)); + if (sa !== sb) return sa - sb; + if (a.task.done && b.task.done) { + return (b.task.completed ?? "").localeCompare(a.task.completed ?? "") || b.index - a.index; + } + const da = a.task.due ?? a.task.threshold ?? "9999"; + const db = b.task.due ?? b.task.threshold ?? "9999"; + return da.localeCompare(db) || priRank(a.task) - priRank(b.task) || a.index - b.index; + }); +} + +/** `today`, `tomorrow`, `yesterday`, `in 3 days`, `2 days ago`, then `Nov 14`. */ +export function relativeDay(iso: string, today: string): string { + const d = daysBetween(today, iso); + if (d === 0) return "today"; + if (d === 1) return "tomorrow"; + if (d === -1) return "yesterday"; + if (d > 1 && d < 7) return `in ${d} days`; + if (d < -1 && d > -14) return `${-d} days ago`; + const date = new Date(toUtc(iso)); + const sameYear = iso.slice(0, 4) === today.slice(0, 4); + return date.toLocaleDateString("en", { + month: "short", + day: "numeric", + year: sameYear ? undefined : "numeric", + timeZone: "UTC", + }); +} + +export function longDate(iso: string): string { + return new Date(toUtc(iso)).toLocaleDateString("en", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); +} + +export type Tint = "red" | "orange" | "yellow" | "blue" | "green" | "gray" | "purple" | "teal"; + +export function priorityColor(p: string | undefined): Tint { + if (!p) return "gray"; + return ({ A: "red", B: "orange", C: "yellow" } as Record)[p] ?? "blue"; +} + +/** Filter chips: `all`, `due`, `pri`, `done`, `+project`, `@context`. */ +export function matchesFilter(task: Task, filter: string | undefined, today: string): boolean { + if (!filter || filter === "all") return !task.done; + if (filter === "done") return task.done; + if (task.done) return false; + if (filter === "due") return !!task.due && daysBetween(today, task.due) <= 7; + if (filter === "pri") return !!task.priority; + if (filter.startsWith("+")) return task.projects.some((p) => p.toLowerCase() === filter.slice(1).toLowerCase()); + if (filter.startsWith("@")) return task.contexts.some((c) => c.toLowerCase() === filter.slice(1).toLowerCase()); + return true; +} + +/** The most used projects and contexts of the open tasks, for the filter chips. */ +export function topLabels(tasks: Task[], max = 6): { projects: string[]; contexts: string[] } { + const count = (pick: (t: Task) => string[]) => { + const n = new Map(); + for (const t of tasks) if (!t.done) { for (const x of pick(t)) n.set(x, (n.get(x) ?? 0) + 1); } + return [...n].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, max).map(([k]) => k); + }; + return { projects: count((t) => t.projects), contexts: count((t) => t.contexts).slice(0, Math.ceil(max / 2)) }; +} + +/** "3 overdue · 2 due today", or undefined when nothing is due. */ +export function dueSummary(tasks: Task[], today: string): string | undefined { + const open = tasks.filter((t) => !t.done); + const overdue = open.filter((t) => sectionOf(t, today) === "Overdue").length; + const due = open.filter((t) => sectionOf(t, today) === "Today").length; + const parts = [overdue ? `${overdue} overdue` : "", due ? `${due} due today` : ""].filter(Boolean); + return parts.length ? parts.join(" · ") : undefined; +} + +// --- Files ------------------------------------------------------------------- + +/** + * Replaces the first line equal to `old` by `replacement` (removed when + * empty), or appends `replacement` when `old` is undefined. Keeps the file's + * line endings and its final line break. Returns undefined when `old` is not + * there any more (the file changed meanwhile). + */ +export function replaceLine( + content: string, + old: string | undefined, + replacement: string[], +): string | undefined { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const lines = content === "" ? [] : content.split(/\r?\n/); + const trailing = lines.length > 0 && lines[lines.length - 1] === ""; + if (trailing) lines.pop(); + if (old === undefined) { + lines.push(...replacement); + } else { + const i = lines.findIndex((l) => l.trimEnd() === old.trimEnd()); + if (i < 0) return undefined; + lines.splice(i, 1, ...replacement); + } + return lines.length ? lines.join(eol) + eol : ""; +} + +/** `~/x` → `/home/me/x`. */ +export function expandHome(path: string, home: string): string { + return path === "~" ? home : path.startsWith("~/") ? `${home}${path.slice(1)}` : path; +} + +export function tildify(path: string, home: string): string { + return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +export const dirname = (path: string) => path.replace(/\/[^/]*$/, "") || "/"; diff --git a/extensions/todo-txt/lib_test.ts b/extensions/todo-txt/lib_test.ts new file mode 100644 index 0000000..1ec91ac --- /dev/null +++ b/extensions/todo-txt/lib_test.ts @@ -0,0 +1,347 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { + addBusinessDays, + addMonths, + applyEdit, + completeTask, + daysBetween, + describeRecurrence, + dueSummary, + editValues, + formatTask, + keyValue, + matchesFilter, + normalizeNewTask, + parseDateInput, + parseFile, + parseRecurrence, + parseTask, + relativeDay, + replaceLine, + sectionOf, + setDue, + setPriority, + setTag, + sortTasks, + topLabels, + uncompleteTask, +} from "./lib.ts"; + +const TODAY = "2026-10-28"; // a Wednesday + +Deno.test("parses a full open task", () => { + const t = parseTask("(A) 2026-10-01 Call Mom +Family @phone due:2026-10-30 t:2026-10-29 rec:+1w id:42"); + assertEquals(t.done, false); + assertEquals(t.priority, "A"); + assertEquals(t.created, "2026-10-01"); + assertEquals(t.title, "Call Mom"); + assertEquals(t.projects, ["Family"]); + assertEquals(t.contexts, ["phone"]); + assertEquals(t.due, "2026-10-30"); + assertEquals(t.threshold, "2026-10-29"); + assertEquals(t.rec, "+1w"); + assertEquals(t.tags.id, "42"); + assertEquals(t.text, "Call Mom +Family @phone due:2026-10-30 t:2026-10-29 rec:+1w id:42"); +}); + +Deno.test("parses completed tasks, with or without dates and pri:", () => { + const a = parseTask("x 2026-10-27 2026-10-01 Pay rent +Home pri:B"); + assertEquals([a.done, a.completed, a.created, a.priority, a.title], [ + true, + "2026-10-27", + "2026-10-01", + "B", + "Pay rent", + ]); + const b = parseTask("x Buy milk"); + assertEquals([b.done, b.completed, b.created, b.title], [true, undefined, undefined, "Buy milk"]); + const c = parseTask("x 2026-10-27 (A) Lenient app"); + assertEquals([c.completed, c.priority, c.title], ["2026-10-27", "A", "Lenient app"]); +}); + +Deno.test("what is not a priority, a date, a project or a tag", () => { + // Priority must be first, uppercase, followed by a space. + assertEquals(parseTask("Really (A) not first").priority, undefined); + assertEquals(parseTask("(a) lowercase").priority, undefined); + assertEquals(parseTask("(A)->arrow").priority, undefined); + // "x" must be followed by a space and be lowercase. + assertEquals(parseTask("xylophone lessons").done, false); + assertEquals(parseTask("X 2026-10-01 shout").done, false); + // Mid-text dates are text; e-mail addresses are not contexts; URLs and times are not tags. + const t = parseTask("Email bob@example.com about https://x.org/a?b=c at 10:30 on 2026-11-01 C++"); + assertEquals(t.contexts, []); + assertEquals(t.projects, []); + assertEquals(t.tags, {}); + assertEquals(t.created, undefined); + assertEquals(keyValue("due:2026-01-01"), ["due", "2026-01-01"]); + assertEquals(keyValue("DUE:x"), ["due", "x"]); + assertEquals(keyValue("a:b:c"), undefined); + assertEquals(keyValue("10:30"), undefined); + // An invalid date is kept as a tag but not as the due date. + const bad = parseTask("Thing due:2026-13-45"); + assertEquals([bad.due, bad.tags.due], [undefined, "2026-13-45"]); +}); + +Deno.test("a task made only of tags keeps them as its title", () => { + assertEquals(parseTask("+Garden @home").title, "+Garden @home"); + assertEquals(parseTask("(B) 2026-01-01").title, ""); +}); + +Deno.test("parseFile skips blank lines and Windows line breaks", () => { + const tasks = parseFile("(A) One\r\n\r\n \nTwo +p\r\n"); + assertEquals(tasks.map((t) => t.title), ["One", "Two"]); + assertEquals(tasks[0].raw, "(A) One"); +}); + +Deno.test("formatTask round-trips and moves the priority of done tasks to pri:", () => { + const lines = [ + "(A) 2026-10-01 Call Mom +Family @phone due:2026-10-30", + "x 2026-10-27 2026-10-01 Pay rent pri:B", + "Plain task", + ]; + for (const l of lines) assertEquals(formatTask(parseTask(l)), l); + assertEquals( + formatTask({ done: true, completed: "2026-10-28", created: "2026-10-01", priority: "A", text: "Do it" }), + "x 2026-10-28 2026-10-01 Do it pri:A", + ); + // A creation date needs a completion date on a done task. + assertEquals(formatTask({ done: true, created: "2026-10-01", text: "Do it" }), "x Do it"); +}); + +Deno.test("setTag replaces, adds and removes", () => { + assertEquals(setTag("Pay due:2026-01-01 +Home", "due", "2026-02-01"), "Pay due:2026-02-01 +Home"); + assertEquals(setTag("Pay +Home", "due", "2026-02-01"), "Pay +Home due:2026-02-01"); + assertEquals(setTag("Pay due:x due:y", "due", undefined), "Pay"); + assertEquals(setTag("See https://a.b/c", "https", undefined), "See https://a.b/c"); +}); + +Deno.test("calendar arithmetic", () => { + assertEquals(daysBetween("2026-03-28", "2026-03-30"), 2); // across a DST change in Europe + assertEquals(daysBetween("2024-02-28", "2024-03-01"), 2); // leap year + assertEquals(addMonths("2026-01-31", 1), "2026-02-28"); + assertEquals(addMonths("2024-01-31", 1), "2024-02-29"); + assertEquals(addMonths("2026-11-15", 3), "2027-02-15"); + assertEquals(addMonths("2024-02-29", 12), "2025-02-28"); + assertEquals(addBusinessDays("2026-10-30", 1), "2026-11-02"); // Friday → Monday + assertEquals(addBusinessDays("2026-10-28", 3), "2026-11-02"); +}); + +Deno.test("recurrence values", () => { + assertEquals(parseRecurrence("1w"), { strict: false, amount: 1, unit: "w" }); + assertEquals(parseRecurrence("+2m"), { strict: true, amount: 2, unit: "m" }); + assertEquals(parseRecurrence("d"), { strict: false, amount: 1, unit: "d" }); + assertEquals(parseRecurrence("5B"), { strict: false, amount: 5, unit: "b" }); + assertEquals(parseRecurrence("0d"), undefined); + assertEquals(parseRecurrence("weekly"), undefined); + assertEquals(describeRecurrence("+2w"), "Every 2 weeks, from the due date"); + assertEquals(describeRecurrence("1d"), "Every day, from completion"); +}); + +Deno.test("completing a plain task", () => { + const t = parseTask("(A) 2026-10-01 Call Mom +Family"); + assertEquals(completeTask(t, TODAY), { done: "x 2026-10-28 2026-10-01 Call Mom +Family pri:A" }); +}); + +Deno.test("completing a recurring task: from completion or strictly from the due date", () => { + const loose = parseTask("(B) 2026-10-01 Water plants due:2026-10-20 rec:1w"); + assertEquals(completeTask(loose, TODAY), { + done: "x 2026-10-28 2026-10-01 Water plants due:2026-10-20 rec:1w pri:B", + next: "(B) 2026-10-28 Water plants due:2026-11-04 rec:1w", + }); + const strict = parseTask("Pay rent due:2026-10-01 rec:+1m"); + assertEquals(completeTask(strict, TODAY).next, "Pay rent due:2026-11-01 rec:+1m"); + // Month ends are kept sensible. + assertEquals( + completeTask(parseTask("Report due:2026-01-31 rec:+1m"), "2026-01-31").next, + "Report due:2026-02-28 rec:+1m", + ); + // Yearly, across a leap day. + assertEquals( + completeTask(parseTask("Birthday due:2024-02-29 rec:+1y"), TODAY).next, + "Birthday due:2025-02-28 rec:+1y", + ); +}); + +Deno.test("recurrence keeps the threshold's distance and handles tasks without dates", () => { + const t = parseTask("Invoice due:2026-10-31 t:2026-10-25 rec:+1m"); + assertEquals(completeTask(t, TODAY).next, "Invoice due:2026-11-30 t:2026-11-24 rec:+1m"); + assertEquals(completeTask(parseTask("Stretch t:2026-10-28 rec:2d"), TODAY).next, "Stretch t:2026-10-30 rec:2d"); + assertEquals(completeTask(parseTask("Backup rec:1w"), TODAY).next, "Backup rec:1w due:2026-11-04"); + assertEquals(completeTask(parseTask("Standup due:2026-10-30 rec:+1b"), TODAY).next, "Standup due:2026-11-02 rec:+1b"); + // An invalid rec: is just completed. + assertEquals(completeTask(parseTask("Oops rec:often"), TODAY).next, undefined); +}); + +Deno.test("reopening a task restores its priority", () => { + assertEquals(uncompleteTask(parseTask("x 2026-10-27 2026-10-01 Pay rent pri:B")), "(B) 2026-10-01 Pay rent"); + assertEquals(uncompleteTask(parseTask("x Buy milk")), "Buy milk"); +}); + +Deno.test("priority and due date changes", () => { + const t = parseTask("(C) 2026-10-01 Read +Books"); + assertEquals(setPriority(t, "A"), "(A) 2026-10-01 Read +Books"); + assertEquals(setPriority(t, undefined), "2026-10-01 Read +Books"); + assertEquals(setPriority(parseTask("x 2026-10-27 Done thing pri:C"), "A"), "x 2026-10-27 Done thing pri:A"); + assertEquals(setDue(t, "2026-11-01"), "(C) 2026-10-01 Read +Books due:2026-11-01"); + assertEquals(setDue(parseTask("Read due:2026-11-01"), undefined), "Read"); +}); + +Deno.test("dates typed by a person", () => { + assertEquals(parseDateInput("2026-11-03", TODAY), "2026-11-03"); + assertEquals(parseDateInput("today", TODAY), TODAY); + assertEquals(parseDateInput("Tomorrow", TODAY), "2026-10-29"); + assertEquals(parseDateInput("fri", TODAY), "2026-10-30"); + assertEquals(parseDateInput("wednesday", TODAY), "2026-11-04"); // never today + assertEquals(parseDateInput("mon", TODAY), "2026-11-02"); + assertEquals(parseDateInput("3d", TODAY), "2026-10-31"); + assertEquals(parseDateInput("+2w", TODAY), "2026-11-11"); + assertEquals(parseDateInput("1m", TODAY), "2026-11-28"); + assertEquals(parseDateInput("next week", TODAY), "2026-11-04"); + assertEquals(parseDateInput("someday", TODAY), undefined); + assertEquals(parseDateInput("mo", TODAY), undefined); + assertEquals(parseDateInput("2026-02-30", TODAY), undefined); +}); + +Deno.test("new tasks get today's date and absolute due dates", () => { + assertEquals( + normalizeNewTask("(A) Call Bob +Work due:fri", TODAY, true), + "(A) 2026-10-28 Call Bob +Work due:2026-10-30", + ); + assertEquals(normalizeNewTask("Call Bob t:tomorrow", TODAY, false), "Call Bob t:2026-10-29"); + assertEquals(normalizeNewTask("2026-01-01 Old idea due:soon", TODAY, true), "2026-01-01 Old idea due:soon"); + assertEquals(normalizeNewTask("x Already done", TODAY, true), "x 2026-10-28 Already done"); +}); + +Deno.test("the edit form splits dates out and puts them back", () => { + const t = parseTask("(B) 2026-10-01 Call Mom +Family due:2026-10-30 rec:1w note:x"); + const v = editValues(t); + assertEquals(v, { + description: "Call Mom +Family note:x", + priority: "B", + due: "2026-10-30", + threshold: "", + rec: "1w", + }); + assertEquals(applyEdit(t, v, TODAY, true), "(B) 2026-10-01 Call Mom +Family note:x due:2026-10-30 rec:1w"); + assertEquals( + applyEdit(t, { ...v, priority: "", due: "tomorrow", rec: "" }, TODAY, true), + "2026-10-01 Call Mom +Family note:x due:2026-10-29", + ); + assertEquals( + applyEdit( + undefined, + { description: "New one due:fri", priority: "a", due: "", threshold: "", rec: "" }, + TODAY, + true, + ), + "(A) 2026-10-28 New one due:2026-10-30", + ); + assertThrows(() => applyEdit(t, { ...v, due: "whenever" }, TODAY, true), Error, "not a date"); + assertThrows(() => applyEdit(t, { ...v, rec: "often" }, TODAY, true), Error, "Recurrence"); + assertThrows(() => applyEdit(t, { ...v, description: " " }, TODAY, true), Error, "description"); + // Editing a done task keeps it done. + const done = parseTask("x 2026-10-27 2026-10-01 Pay rent pri:B"); + assertEquals(applyEdit(done, editValues(done), TODAY, true), "x 2026-10-27 2026-10-01 Pay rent pri:B"); +}); + +Deno.test("sections and sorting", () => { + const lines = [ + "Someday", + "(C) Later due:2026-11-20", + "x 2026-10-20 Old", + "(A) Late due:2026-10-01", + "(B) Now due:2026-10-28", + "(A) Now first due:2026-10-28", + "Hidden for now t:2026-11-01", + "(A) No date but urgent", + ]; + const rows = parseFile(lines.join("\n")).map((task, index) => ({ task, index })); + assertEquals(rows.map((r) => sectionOf(r.task, TODAY)), [ + "No Date", + "Upcoming", + "Completed", + "Overdue", + "Today", + "Today", + "Upcoming", + "No Date", + ]); + assertEquals(sortTasks(rows, TODAY).map((r) => r.task.title), [ + "Late", + "Now first", + "Now", + "Hidden for now", + "Later", + "No date but urgent", + "Someday", + "Old", + ]); + assertEquals(dueSummary(rows.map((r) => r.task), TODAY), "1 overdue · 2 due today"); + assertEquals(dueSummary([parseTask("Nothing due")], TODAY), undefined); +}); + +Deno.test("filters and the labels offered as chips", () => { + const tasks = parseFile([ + "(A) Call +Work @phone due:2026-10-30", + "Mail +Work @computer", + "Garden +Home due:2026-12-01", + "x Done +Work", + ].join("\n")); + const pick = (f: string) => tasks.filter((t) => matchesFilter(t, f, TODAY)).map((t) => t.title); + assertEquals(pick("all"), ["Call", "Mail", "Garden"]); + assertEquals(pick("due"), ["Call"]); + assertEquals(pick("pri"), ["Call"]); + assertEquals(pick("+work"), ["Call", "Mail"]); + assertEquals(pick("@phone"), ["Call"]); + assertEquals(pick("done"), ["Done"]); + assertEquals(topLabels(tasks), { projects: ["Work", "Home"], contexts: ["computer", "phone"] }); +}); + +Deno.test("relative days", () => { + assertEquals(relativeDay(TODAY, TODAY), "today"); + assertEquals(relativeDay("2026-10-29", TODAY), "tomorrow"); + assertEquals(relativeDay("2026-10-27", TODAY), "yesterday"); + assertEquals(relativeDay("2026-11-01", TODAY), "in 4 days"); + assertEquals(relativeDay("2026-10-20", TODAY), "8 days ago"); + assertEquals(relativeDay("2026-12-25", TODAY), "Dec 25"); + assertEquals(relativeDay("2027-01-02", TODAY), "Jan 2, 2027"); +}); + +Deno.test("replaceLine keeps line endings and detects concurrent edits", () => { + assertEquals(replaceLine("a\nb\nc\n", "b", ["B"]), "a\nB\nc\n"); + assertEquals(replaceLine("a\r\nb\r\n", "b", []), "a\r\n"); + assertEquals(replaceLine("a\nb", "b", ["x", "y"]), "a\nx\ny\n"); // a final line break is added + assertEquals(replaceLine("", undefined, ["first"]), "first\n"); + assertEquals(replaceLine("a\n", undefined, ["b"]), "a\nb\n"); + assertEquals(replaceLine("a\n", "a", []), ""); + assertEquals(replaceLine("a\n", "gone", ["x"]), undefined); + assert(replaceLine("a \n", "a", ["b"]) === "b\n"); +}); + +Deno.test("completing on disk archives to done.txt and adds the next occurrence", async () => { + const { complete, loadTasks, locate, changeLine } = await import("./todo.ts"); + const dir = await Deno.makeTempDir(); + try { + await Deno.writeTextFile(`${dir}/todo.txt`, "(B) 2026-10-01 Water plants due:2026-10-28 rec:+1w\nBuy milk\n"); + const files = await locate({ preferences: { file: `${dir}/todo.txt` } }); + assertEquals(files.done, `${dir}/done.txt`); + const [water] = (await loadTasks(files)).map((r) => r.task); + const message = await complete(files, water, true); + assert(message.startsWith("Done; next one")); + const todo = await Deno.readTextFile(`${dir}/todo.txt`); + assert(todo.startsWith("(B) ")); + assert(todo.includes("Water plants due:2026-11-04 rec:+1w")); + assert(todo.endsWith("Buy milk\n")); + assert( + (await Deno.readTextFile(`${dir}/done.txt`)).match(/^x \d{4}-\d{2}-\d{2} 2026-10-01 Water plants .* pri:B\n$/), + ); + // A line changed elsewhere meanwhile is not overwritten. + let error = ""; + await changeLine(files, "Not there", ["x"]).catch((e) => (error = e.message)); + assert(error.includes("changed")); + const withDone = await loadTasks(files, true); + assertEquals(withDone.filter((r) => r.archived).length, 1); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/extensions/todo-txt/todo.ts b/extensions/todo-txt/todo.ts new file mode 100644 index 0000000..e52b144 --- /dev/null +++ b/extensions/todo-txt/todo.ts @@ -0,0 +1,382 @@ +// The todo.txt file on disk, and its tasks as launcher items. + +import { + type Action, + action, + close, + type Context, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + type Metadata, + open, + pop, + submenu, + toast, +} from "@backslash/api"; +import { + addDays, + applyEdit, + completeTask, + describeRecurrence, + dirname, + editValues, + expandHome, + isoDay, + longDate, + parseFile, + priorityColor, + relativeDay, + replaceLine, + sectionOf, + setDue, + setPriority, + type Task, + tildify, + uncompleteTask, + weekday, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +/** Where todo.txt is looked for, in order; the same places as `[permissions]`. */ +export const CANDIDATES = ["~/todo.txt", "~/Documents/todo.txt", "~/Dropbox/todo/todo.txt", "~/.todo/todo.txt"]; +export const ALLOWED = "~/todo.txt, ~/Documents/todo.txt, or a file in ~/Dropbox/todo or ~/.todo"; + +export interface Files { + todo: string; + done: string; + exists: boolean; +} + +const exists = (path: string) => Deno.stat(path).then((s) => s.isFile, () => false); + +export async function locate(ctx: Pick): Promise { + const pref = String(ctx.preferences.file ?? "").trim(); + if (pref) { + const todo = expandHome(pref, HOME); + return { todo, done: `${dirname(todo)}/done.txt`, exists: await exists(todo) }; + } + for (const c of CANDIDATES) { + const todo = expandHome(c, HOME); + if (await exists(todo)) return { todo, done: `${dirname(todo)}/done.txt`, exists: true }; + } + const todo = expandHome(CANDIDATES[0], HOME); + return { todo, done: `${dirname(todo)}/done.txt`, exists: false }; +} + +function explain(e: unknown, path: string): never { + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new Error( + `Todo.txt may only use ${ALLOWED}; ${tildify(path, HOME)} is elsewhere. ` + + "Change the file in Settings → Extensions → Todo.txt.", + ); + } + throw e; +} + +export async function readText(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch (e) { + if (e instanceof Deno.errors.NotFound) return ""; + explain(e, path); + } +} + +async function writeText(path: string, text: string) { + try { + await Deno.mkdir(dirname(path), { recursive: true }).catch(() => {}); + await Deno.writeTextFile(path, text); + } catch (e) { + explain(e, path); + } +} + +export interface Row { + task: Task; + index: number; + /** Lines of done.txt are shown, not changed. */ + archived?: boolean; +} + +export async function loadTasks(files: Files, withDone = false): Promise { + const rows = parseFile(await readText(files.todo)).map((task, index) => ({ task, index })); + if (!withDone) return rows; + const archived = parseFile(await readText(files.done)).slice(-200).map((task, index) => ({ + task, + index: -1000 + index, + archived: true, + })); + return [...rows, ...archived]; +} + +/** Changes one line of todo.txt; fails when it was edited elsewhere meanwhile. */ +export async function changeLine(files: Files, old: string | undefined, lines: string[]) { + const content = await readText(files.todo); + const next = replaceLine(content, old, lines); + if (next === undefined) throw new Error("This task changed in todo.txt meanwhile; the list was reloaded"); + await writeText(files.todo, next); + notifyChanged(); +} + +export async function appendDone(files: Files, line: string) { + const content = await readText(files.done); + await writeText(files.done, replaceLine(content, undefined, [line])!); +} + +export const today = () => isoDay(new Date()); + +/** Completes a task: archived to done.txt (or marked `x` in place), the next occurrence added for `rec:`. */ +export async function complete(files: Files, task: Task, archive: boolean): Promise { + const { done, next } = completeTask(task, today()); + if (archive) { + await appendDone(files, done); + await changeLine(files, task.raw, next ? [next] : []); + } else { + await changeLine(files, task.raw, next ? [done, next] : [done]); + } + return next ? `Done; next one ${relativeDay(nextDue(next) ?? today(), today())}` : "Done"; +} + +const nextDue = (line: string) => parseFile(line)[0]?.due ?? parseFile(line)[0]?.threshold; + +// --- Live reload ------------------------------------------------------------- + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Yields `load()`, again after each change made here or to the file elsewhere, until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal, watch?: string): AsyncGenerator { + let watcher: Deno.FsWatcher | undefined; + let fsChanged = () => {}; + if (watch) { + try { + watcher = Deno.watchFs(watch); + (async () => { + for await (const ev of watcher!) if (ev.kind !== "access") fsChanged(); + })().catch(() => {}); + } catch { /* no inotify, or the file doesn't exist yet */ } + } + try { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + let timer: ReturnType | undefined; + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + fsChanged = () => {}; + clearTimeout(timer); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + // Editors write in several steps: wait for them to settle. + fsChanged = () => { + clearTimeout(timer); + timer = setTimeout(done, 150); + }; + }); + } + } finally { + watcher?.close(); + } +} + +// --- Items ------------------------------------------------------------------- + +const PRIORITIES = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); + +export function taskForm( + files: Files, + o: { task?: Task; text?: string; addCreated: boolean; standalone?: boolean }, +): FormSpec { + const v = o.task ? editValues(o.task) : { description: o.text ?? "", priority: "", due: "", threshold: "", rec: "" }; + return form({ + title: o.task ? "Edit Task" : "New Task", + submitTitle: o.task ? "Save Task" : "Add Task", + fields: [ + field.text("description", "Task", { + required: true, + placeholder: "Call the plumber +House @phone", + value: v.description, + }), + field.dropdown("priority", "Priority", [ + { value: "", title: "None" }, + ...PRIORITIES.map((p) => ({ value: p, title: `(${p})` })), + ], { value: v.priority }), + field.text("due", "Due", { placeholder: "2026-11-03, tomorrow, fri, 3d", value: v.due }), + field.text("threshold", "Not before (t:)", { + placeholder: "Hidden from today until this date", + value: v.threshold, + }), + field.text("rec", "Repeat (rec:)", { + placeholder: "1w, +1m (from the due date), 5b (business days)", + value: v.rec, + }), + ], + async submit(values) { + const line = applyEdit( + o.task, + { + description: String(values.description ?? ""), + priority: String(values.priority ?? ""), + due: String(values.due ?? ""), + threshold: String(values.threshold ?? ""), + rec: String(values.rec ?? ""), + }, + today(), + o.addCreated, + ); + await changeLine(files, o.task?.raw, [line]); + await toast(o.task ? "Task saved" : "Task added", { style: "success" }); + return o.standalone ? close("Task added") : pop(); + }, + }); +} + +function taskMetadata(row: Row, files: Files, now: string): Metadata[] { + const t = row.task; + const lines: Metadata[] = []; + if (t.priority) { + lines.push(meta.text("Priority", `(${t.priority})`, { icon: Icons.flag, color: priorityColor(t.priority) })); + } + if (t.due) { + const section = sectionOf(t, now); + lines.push(meta.text("Due", `${longDate(t.due)} · ${relativeDay(t.due, now)}`, { + icon: Icons.calendar, + color: t.done ? undefined : section === "Overdue" ? "red" : section === "Today" ? "orange" : undefined, + })); + } + if (t.threshold) lines.push(meta.text("Not before", longDate(t.threshold), { icon: Icons.hourglass })); + if (t.rec) lines.push(meta.text("Repeats", describeRecurrence(t.rec) ?? t.rec, { icon: Icons.repeat })); + if (t.projects.length) { + lines.push(meta.tags("Projects", t.projects.map((p) => ({ text: `+${p}`, color: "purple" as const })))); + } + if (t.contexts.length) { + lines.push(meta.tags("Contexts", t.contexts.map((c) => ({ text: `@${c}`, color: "teal" as const })))); + } + const other = Object.entries(t.tags).filter(([k]) => !["due", "t", "rec", "pri"].includes(k)); + for (const [k, v] of other.slice(0, 6)) lines.push(meta.text(k, v, { icon: Icons.tag })); + lines.push(meta.separator()); + if (t.created) lines.push(meta.text("Created", `${longDate(t.created)} · ${relativeDay(t.created, now)}`)); + if (t.completed) lines.push(meta.text("Completed", longDate(t.completed), { icon: Icons.done, color: "green" })); + const file = row.archived ? files.done : files.todo; + lines.push(meta.text(row.archived ? "In" : "Line", row.archived ? tildify(file, HOME) : `${row.index + 1}`)); + return lines; +} + +function dueAccessory(t: Task, now: string) { + const day = t.due ?? (t.threshold && t.threshold > now ? t.threshold : undefined); + if (!day || t.done) return []; + const section = sectionOf(t, now); + return [{ + icon: t.due ? Icons.calendar : Icons.hourglass, + text: relativeDay(day, now), + color: section === "Overdue" ? "red" as const : section === "Today" ? "orange" as const : undefined, + tooltip: `${t.due ? "Due" : "Not before"} ${longDate(day)}`, + }]; +} + +const nextMonday = (now: string) => addDays(now, ((8 - weekday(now)) % 7) || 7); + +export function taskItem(row: Row, files: Files, o: { archive: boolean; addCreated: boolean; now: string }): Item { + const t = row.task; + const now = o.now; + const section = sectionOf(t, now); + const edit = (line: string, message: string) => async () => { + await changeLine(files, t.raw, [line]); + await toast(message, { style: "success" }); + }; + const actions: Action[] = []; + if (!row.archived) { + actions.push( + t.done + ? action("Mark as Not Done", edit(uncompleteTask(t), "Reopened"), { icon: Icons.todo }) + : action("Complete Task", async () => { + await toast(await complete(files, t, o.archive), { style: "success" }); + }, { icon: Icons.done }), + action("Edit Task", () => taskForm(files, { task: t, addCreated: o.addCreated }), { + icon: Icons.text, + shortcut: "e", + }), + submenu("Set Priority", [ + ...PRIORITIES.slice(0, 5).map((p) => + action(`(${p})`, edit(setPriority(t, p), `Priority (${p})`), { + icon: { A: Icons.urgent, B: Icons.high, C: Icons.medium, D: Icons.low }[p] ?? Icons.low, + }) + ), + action("No Priority", edit(setPriority(t, undefined), "Priority removed"), { icon: Icons.noPriority }), + ], { icon: Icons.flag, shortcut: "p" }), + submenu("Set Due Date", [ + action("Today", edit(setDue(t, now), "Due today"), { icon: Icons.calendar }), + action("Tomorrow", edit(setDue(t, addDays(now, 1)), "Due tomorrow"), { icon: Icons.calendar }), + action("Next Monday", edit(setDue(t, nextMonday(now)), "Due next Monday"), { icon: Icons.calendar }), + action("In a Week", edit(setDue(t, addDays(now, 7)), "Due in a week"), { icon: Icons.calendar }), + ...(t.due + ? [action("Remove Due Date", edit(setDue(t, undefined), "Due date removed"), { icon: Icons.cross })] + : []), + ], { icon: Icons.calendar, shortcut: "d" }), + ); + } + actions.push( + copy(t.title, "Copy Task", { shortcut: "c", section: "Copy" }), + copy(t.raw, "Copy todo.txt Line", { section: "Copy" }), + action("New Task", () => taskForm(files, { addCreated: o.addCreated }), { + icon: Icons.plus, + shortcut: "n", + section: "File", + }), + open(row.archived ? files.done : files.todo, `Open ${row.archived ? "done.txt" : "todo.txt"}`, { + icon: Icons.file, + shortcut: "o", + section: "File", + }), + ); + if (!row.archived) { + actions.push(action("Delete Task", async () => { + await changeLine(files, t.raw, []); + await toast("Task deleted", { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${t.title}”? It is removed from todo.txt.`, + section: "Danger", + shortcut: "Delete", + })); + } + return { + id: row.archived ? `done:${row.index}:${t.raw}` : t.raw, + title: t.title, + icon: t.done ? { source: Icons.done, tint: "green" } : { source: Icons.todo, tint: priorityColor(t.priority) }, + section: row.archived ? "Archived (done.txt)" : section, + keywords: [ + ...t.projects.map((p) => `+${p}`), + ...t.contexts.map((c) => `@${c}`), + ...(t.priority ? [`(${t.priority})`] : []), + ], + accessories: [ + ...(t.priority && !t.done ? [{ tag: `(${t.priority})`, color: priorityColor(t.priority) }] : []), + ...t.projects.slice(0, 2).map((p) => ({ tag: `+${p}`, color: "purple" as const })), + ...t.contexts.slice(0, 2).map((c) => ({ tag: `@${c}`, color: "teal" as const })), + ...(t.rec ? [{ icon: Icons.repeat, tooltip: describeRecurrence(t.rec) }] : []), + ...dueAccessory(t, now), + ...(t.done && t.completed ? [{ text: relativeDay(t.completed, now), tooltip: "Completed" }] : []), + ], + detail: { + markdown: `### ${t.title.replace(/[#*_`[\]]/g, "\\$&")}\n\n\`${t.raw.replace(/`/g, "'")}\``, + metadata: taskMetadata(row, files, now), + }, + actions, + }; +} + +export const prefs = (ctx: Pick) => ({ + archive: ctx.preferences.archive !== false, + addCreated: ctx.preferences.createdDate !== false, +}); diff --git a/extensions/zotero/backslash.toml b/extensions/zotero/backslash.toml new file mode 100644 index 0000000..3e77dc8 --- /dev/null +++ b/extensions/zotero/backslash.toml @@ -0,0 +1,26 @@ +[extension] +id = "zotero" +title = "Zotero" +description = "Search your Zotero library by title, author or tag, copy APA citations and BibTeX, and open PDFs" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# While Zotero 7 runs with its local API on, items come from 127.0.0.1:23119. +# Otherwise ~/Zotero/zotero.sqlite (Zotero's default data directory) is +# copied to ~/.cache/backslash-zotero, because Zotero locks the original, and +# read with the sqlite3 program; nothing is ever written to the library. +[permissions] +net = ["127.0.0.1:23119"] +run = ["sqlite3"] +read = ["~/Zotero", "~/.cache/backslash-zotero", "/etc/os-release"] +write = ["~/.cache/backslash-zotero"] +env = ["HOME"] + +[[commands]] +name = "search" +title = "Search Zotero" +description = "Your references with authors, year and attachments; cite, open the PDF" +icon = "bs-book" +keywords = ["zotero", "citation", "bibliography", "reference", "paper", "bibtex", "pdf", "research", "biblio", "référence", "citer", "article"] diff --git a/extensions/zotero/commands/search.ts b/extensions/zotero/commands/search.ts new file mode 100644 index 0000000..569e737 --- /dev/null +++ b/extensions/zotero/commands/search.ts @@ -0,0 +1,27 @@ +import { Icons, list } from "@backslash/api"; +import { find, SetupError } from "../source.ts"; +import { setupItems, zoteroItem } from "../zotero.ts"; + +let footer = ""; + +export default list({ + placeholder: "Search by title, author, year or #tag…", + empty: { title: "No matching item", description: "Titles, creators and years; #tag for tags", icon: Icons.book }, + status: () => footer, + async search(query, { signal }) { + try { + const { items, kind, total } = await find(query, signal); + const source = kind === "api" + ? "Zotero local API" + : `Zotero database${total ? ` · ${total.toLocaleString("en")} items` : ""}`; + footer = `${ + query.trim() ? `${items.length} result${items.length === 1 ? "" : "s"}` : "Recently added" + } · ${source}`; + return items.map((i) => ({ ...zoteroItem(i, kind), section: query.trim() ? "Items" : "Recently Added" })); + } catch (e) { + if (!(e instanceof SetupError)) throw e; + footer = ""; + return await setupItems(e); + } + }, +}); diff --git a/extensions/zotero/lib.ts b/extensions/zotero/lib.ts new file mode 100644 index 0000000..1721617 --- /dev/null +++ b/extensions/zotero/lib.ts @@ -0,0 +1,541 @@ +// Zotero items from the local API (Zotero 7) or from the zotero.sqlite +// database, and their citations (APA-like and BibTeX). Pure functions. + +export const API = "http://127.0.0.1:23119/api"; + +export interface Creator { + creatorType: string; + firstName?: string; + lastName?: string; + /** Single-field names: organizations. */ + name?: string; +} + +export interface Attachment { + key: string; + title: string; + contentType?: string; + linkMode?: string; + /** Absolute path of the file, when it is on this computer. */ + path?: string; +} + +export interface ZItem { + key: string; + /** Zotero's group id for group libraries; undefined for "My Library". */ + groupId?: number; + itemType: string; + title: string; + creators: Creator[]; + fields: Record; + tags: string[]; + attachments: Attachment[]; + dateAdded?: string; + dateModified?: string; +} + +// --- Mapping ----------------------------------------------------------------- + +const NOT_FIELDS = new Set([ + "key", + "version", + "itemType", + "creators", + "tags", + "collections", + "relations", + "dateAdded", + "dateModified", + "parentItem", + "deleted", +]); + +/** An item of the local (or web) API, `format=json`. */ +export function fromApi(raw: any, children: any[] = [], storageDir = ""): ZItem { + const d = raw?.data ?? {}; + const fields: Record = {}; + for (const [k, v] of Object.entries(d)) { + if (!NOT_FIELDS.has(k) && typeof v === "string" && v) fields[k] = v; + } + const library = raw?.library ?? {}; + return { + key: String(raw?.key ?? d.key ?? ""), + groupId: library.type === "group" ? Number(library.id) : undefined, + itemType: String(d.itemType ?? "document"), + title: String(d.title ?? d.caseName ?? d.subject ?? d.nameOfAct ?? ""), + creators: Array.isArray(d.creators) ? d.creators : [], + fields, + tags: Array.isArray(d.tags) ? d.tags.map((t: any) => String(t.tag ?? t)).filter(Boolean) : [], + attachments: children.filter((c) => c?.data?.itemType === "attachment").map((c) => + attachmentFrom({ + key: c.key ?? c.data.key, + title: c.data.title, + contentType: c.data.contentType, + linkMode: c.data.linkMode, + path: c.data.path, + filename: c.data.filename, + }, storageDir) + ), + dateAdded: d.dateAdded, + dateModified: d.dateModified, + }; +} + +/** Where an attachment's file is: `storage:paper.pdf` and imported files are in `/storage//`. */ +export function attachmentFrom( + a: { + key: string; + title?: string; + contentType?: string; + linkMode?: string | number; + path?: string; + filename?: string; + }, + storageDir: string, +): Attachment { + const mode = typeof a.linkMode === "number" ? LINK_MODES[a.linkMode] : a.linkMode; + let path: string | undefined; + if (a.path?.startsWith("storage:")) path = `${storageDir}/${a.key}/${a.path.slice(8)}`; + else if (a.path?.startsWith("/")) path = a.path; + else if (a.filename && (mode === "imported_file" || mode === "imported_url")) { + path = `${storageDir}/${a.key}/${a.filename}`; + } + return { + key: a.key, + title: a.title || a.filename || a.path?.replace(/^storage:/, "") || "Attachment", + contentType: a.contentType || undefined, + linkMode: mode, + path, + }; +} + +/** Zotero's `itemAttachments.linkMode` numbers. */ +const LINK_MODES = ["imported_file", "imported_url", "linked_file", "linked_url", "embedded_image"]; + +/** A row of the SQL query in `db.ts` (JSON columns as text, as `sqlite3 -json` prints them). */ +export interface DbRow { + key: string; + groupID: number | null; + typeName: string; + fields: string | null; + creators: string | null; + tags: string | null; + attachments: string | null; + dateAdded: string; + dateModified: string; +} + +const json = (s: string | null | undefined, fallback: T): T => { + try { + return s ? JSON.parse(s) ?? fallback : fallback; + } catch { + return fallback; + } +}; + +export function fromDb(row: DbRow, storageDir: string): ZItem { + const fields = json>(row.fields, {}); + const creators = json<{ first: string; last: string; mode: number; type: string; i: number }[]>(row.creators, []) + .sort((a, b) => a.i - b.i) + .map((c) => + c.mode === 1 + ? { creatorType: c.type, name: c.last } + : { creatorType: c.type, firstName: c.first, lastName: c.last } + ); + const attachments = json<{ key: string; path: string; contentType: string; linkMode: number; title: string }[]>( + row.attachments, + [], + ).map((a) => attachmentFrom(a, storageDir)); + const title = fields.title ?? fields.caseName ?? fields.subject ?? fields.nameOfAct ?? ""; + return { + key: row.key, + groupId: row.groupID ?? undefined, + itemType: row.typeName, + title, + creators, + fields, + tags: json(row.tags, []).filter(Boolean).sort((a, b) => a.localeCompare(b)), + attachments, + dateAdded: row.dateAdded ? `${row.dateAdded.replace(" ", "T")}Z` : undefined, + dateModified: row.dateModified ? `${row.dateModified.replace(" ", "T")}Z` : undefined, + }; +} + +// --- Fields ------------------------------------------------------------------ + +/** `2019-03-00 March 2019` (Zotero's stored dates), `2019-03-15`, `March 2019` → `2019`. */ +export function year(item: Pick): string | undefined { + return /\b(1[5-9]\d\d|2\d\d\d)\b/.exec(item.fields.date ?? "")?.[1]; +} + +/** Where it was published: the journal, book, proceedings, website… */ +export function publication(item: Pick): string | undefined { + const f = item.fields; + return f.publicationTitle ?? f.bookTitle ?? f.proceedingsTitle ?? f.websiteTitle ?? f.blogTitle ?? f.forumTitle ?? + f.encyclopediaTitle ?? f.dictionaryTitle ?? f.programTitle ?? f.seriesTitle ?? + (item.itemType === "thesis" ? f.university : undefined) ?? (item.itemType === "book" ? f.publisher : undefined); +} + +const TYPE_NAMES: Record = { + journalArticle: "Journal Article", + bookSection: "Book Section", + conferencePaper: "Conference Paper", + webpage: "Web Page", + blogPost: "Blog Post", + forumPost: "Forum Post", + magazineArticle: "Magazine Article", + newspaperArticle: "Newspaper Article", + computerProgram: "Software", + preprint: "Preprint", + tvBroadcast: "TV Broadcast", +}; + +export function typeName(t: string): string { + return TYPE_NAMES[t] ?? t.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()); +} + +/** The people credited first: authors, else editors, else whoever comes first. */ +export function mainCreators(item: Pick): Creator[] { + const pick = (types: string[]) => item.creators.filter((c) => types.includes(c.creatorType)); + const a = pick([ + "author", + "inventor", + "artist", + "director", + "programmer", + "podcaster", + "presenter", + "sponsor", + "cartographer", + "composer", + "performer", + "interviewee", + ]); + if (a.length) return a; + const e = pick(["editor"]); + return e.length ? e : item.creators.slice(0, 1); +} + +export const fullName = (c: Creator) => c.name ?? [c.firstName, c.lastName].filter(Boolean).join(" "); + +/** "Lovelace", "Lovelace and Babbage", "Lovelace et al.", as Zotero shows them. */ +export function creatorSummary(item: Pick): string { + const people = mainCreators(item).map((c) => c.lastName || c.name || c.firstName || "").filter(Boolean); + if (people.length === 0) return ""; + if (people.length === 1) return people[0]; + if (people.length === 2) return `${people[0]} and ${people[1]}`; + return `${people[0]} et al.`; +} + +/** Zotero stores dates as `1843-00-00 1843` (sortable, then as typed): the typed part. */ +export function displayDate(date: string | undefined): string | undefined { + if (!date) return undefined; + const m = /^\d{4}-\d{2}-\d{2} (.+)$/.exec(date); + return m ? m[1] : date; +} + +// --- Citations --------------------------------------------------------------- + +/** `Ada King` → `A. K.`; `Jean-Paul` → `J.-P.` */ +export function initials(first: string): string { + return first.trim().split(/\s+/).filter(Boolean).map((w) => + w.split("-").map((p) => (p ? `${[...p][0].toUpperCase()}.` : "")).join("-") + ).join(" "); +} + +function apaName(c: Creator): string { + if (c.name || !c.lastName) return c.name ?? c.firstName ?? ""; + return c.firstName ? `${c.lastName}, ${initials(c.firstName)}` : c.lastName; +} + +/** APA 7: up to 20 names, the last one after “&”; 21+: the first 19, “…”, the last. */ +export function apaAuthors(creators: Creator[]): string { + const names = creators.map(apaName).filter(Boolean); + if (names.length === 0) return ""; + if (names.length === 1) return names[0]; + if (names.length > 20) return `${names.slice(0, 19).join(", ")}, … ${names[names.length - 1]}`; + return `${names.slice(0, -1).join(", ")}, & ${names[names.length - 1]}`; +} + +const end = (s: string) => (/[.?!]$/.test(s) ? s : `${s}.`); + +function doiUrl(doi: string | undefined): string | undefined { + if (!doi) return undefined; + const clean = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, "").replace(/^doi:\s*/i, ""); + return `https://doi.org/${clean}`; +} + +/** + * An APA-style reference in plain text, close to what Zotero's APA 7 style + * gives for the common types (articles, books, chapters, papers, pages). + */ +export function apaCitation(item: ZItem): string { + const f = item.fields; + const authors = mainCreators(item); + const editorsOnly = authors.length > 0 && authors.every((c) => c.creatorType === "editor"); + let who = apaAuthors(authors); + if (who && editorsOnly) who += authors.length > 1 ? " (Eds.)" : " (Ed.)"; + const y = year(item) ?? "n.d."; + const title = (item.title || "Untitled").trim(); + const parts: string[] = []; + const dateText = `(${y}).`; + if (who) parts.push(end(who), dateText, end(title)); + else parts.push(end(title), dateText); + const pub = publication(item); + const pages = f.pages?.replace(/-+/g, "–"); + switch (item.itemType) { + case "journalArticle": + case "magazineArticle": + case "newspaperArticle": { + let source = pub ?? ""; + if (f.volume) source += `${source ? ", " : ""}${f.volume}`; + if (f.issue) source += `(${f.issue})`; + if (pages) source += `${source ? ", " : ""}${pages}`; + if (source) parts.push(end(source)); + break; + } + case "book": { + if (f.edition && !/^1(st)?$/i.test(f.edition)) { + parts[parts.length - 1] = `${title} (${ordinalEdition(f.edition)} ed.).`; + } + if (f.publisher) parts.push(end(f.publisher)); + break; + } + case "bookSection": + case "encyclopediaArticle": + case "dictionaryEntry": { + const editors = item.creators.filter((c) => c.creatorType === "editor"); + const eds = editors.length + ? `${ + editors.map((c) => [c.firstName ? initials(c.firstName) : "", c.lastName ?? c.name].filter(Boolean).join(" ")) + .join(", ") + } (${editors.length > 1 ? "Eds." : "Ed."}), ` + : ""; + if (pub) parts.push(`In ${eds}${pub}${pages ? ` (pp. ${pages})` : ""}.`); + if (f.publisher) parts.push(end(f.publisher)); + break; + } + case "conferencePaper": { + if (pub) parts.push(`${pub}${pages ? ` (pp. ${pages})` : ""}.`); + if (f.publisher) parts.push(end(f.publisher)); + break; + } + case "thesis": { + const kind = f.thesisType || "Thesis"; + parts[parts.length - 1] = `${title} [${kind}${f.university ? `, ${f.university}` : ""}].`; + break; + } + case "report": { + if (f.reportNumber) parts[parts.length - 1] = `${title} (${f.reportNumber}).`; + if (f.institution) parts.push(end(f.institution)); + break; + } + default: + if (pub) parts.push(end(pub)); + else if (f.publisher) parts.push(end(f.publisher)); + } + const link = doiUrl(f.DOI) ?? f.url; + if (link) parts.push(link); + return parts.join(" ").replace(/\s+/g, " ").trim(); +} + +function ordinalEdition(e: string): string { + if (!/^\d+$/.test(e)) return e; + const n = Number(e); + const s = n % 100 >= 11 && n % 100 <= 13 + ? "th" + : ({ 1: "st", 2: "nd", 3: "rd" } as Record)[n % 10] ?? "th"; + return `${n}${s}`; +} + +/** Author-year in the text: `(Lovelace, 1843)`, `(Lovelace & Babbage, 1843)`, `(Lovelace et al., 1843)`. */ +export function inTextCitation(item: ZItem): string { + const people = mainCreators(item).map((c) => c.lastName || c.name || "").filter(Boolean); + const who = people.length === 0 + ? item.title.split(/\s+/).slice(0, 3).join(" ") + : people.length === 1 + ? people[0] + : people.length === 2 + ? `${people[0]} & ${people[1]}` + : `${people[0]} et al.`; + return `(${who}, ${year(item) ?? "n.d."})`; +} + +const BIBTEX_TYPES: Record = { + journalArticle: "article", + magazineArticle: "article", + newspaperArticle: "article", + book: "book", + bookSection: "incollection", + conferencePaper: "inproceedings", + thesis: "phdthesis", + report: "techreport", + manuscript: "unpublished", + preprint: "misc", +}; + +/** A citation key: `Citation Key:` in Extra (Better BibTeX), else `lovelace1843sketch`. */ +export function citationKey(item: ZItem): string { + const pinned = item.fields.citationKey ?? /^\s*citation key:\s*(\S+)/im.exec(item.fields.extra ?? "")?.[1]; + if (pinned) return pinned; + const ascii = (s: string) => + s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]/g, ""); + const first = mainCreators(item)[0]; + const who = ascii(first?.lastName ?? first?.name?.split(/\s+/)[0] ?? "") || "anon"; + const STOP = new Set([ + "a", + "an", + "the", + "on", + "of", + "in", + "and", + "for", + "to", + "le", + "la", + "les", + "un", + "une", + "de", + "der", + "die", + "das", + ]); + const word = item.title.split(/\s+/).map(ascii).find((w) => w && !STOP.has(w)) ?? ""; + return `${who}${year(item) ?? ""}${word}`; +} + +/** Braces for case, and LaTeX's special characters escaped. */ +function bibValue(s: string): string { + return s.replace(/\\/g, "\\textbackslash{}").replace(/([{}&%$#_])/g, "\\$1").replace(/~/g, "\\textasciitilde{}") + .replace(/\^/g, "\\textasciicircum{}"); +} + +const bibName = (c: Creator) => + c.name ? `{${bibValue(c.name)}}` : [c.lastName, c.firstName].filter(Boolean).map((s) => bibValue(s!)).join(", "); + +export function bibtex(item: ZItem): string { + const f = item.fields; + const type = item.itemType === "thesis" && /master/i.test(f.thesisType ?? "") + ? "mastersthesis" + : BIBTEX_TYPES[item.itemType] ?? "misc"; + const authors = item.creators.filter((c) => c.creatorType !== "editor" && c.creatorType !== "seriesEditor"); + const editors = item.creators.filter((c) => c.creatorType === "editor"); + const pub = publication(item); + const entries: [string, string | undefined][] = [ + ["title", item.title ? `{${bibValue(item.title)}}` : undefined], + ["author", authors.length ? authors.map(bibName).join(" and ") : undefined], + ["editor", editors.length ? editors.map(bibName).join(" and ") : undefined], + [ + type === "article" + ? "journal" + : type === "incollection" || type === "inproceedings" + ? "booktitle" + : "howpublished", + type === "book" || type.endsWith("thesis") ? undefined : pub, + ], + ["year", year(item)], + ["volume", f.volume], + ["number", f.issue ?? f.reportNumber], + ["pages", f.pages?.replace(/[–—-]+/g, "--")], + ["edition", f.edition], + ["publisher", f.publisher], + ["address", f.place], + ["school", type.endsWith("thesis") ? f.university : undefined], + ["institution", type === "techreport" ? f.institution : undefined], + ["doi", f.DOI], + ["isbn", f.ISBN], + ["issn", f.ISSN], + ["url", f.url], + ["urldate", f.accessDate?.slice(0, 10)], + ["keywords", item.tags.length ? item.tags.join(", ") : undefined], + ]; + const lines = entries + .filter(([, v]) => v) + .map(([k, v]) => + ` ${k} = {${ + k === "url" || k === "doi" ? v : k === "title" || k === "author" || k === "editor" ? v : bibValue(v!) + }},` + ); + return `@${type}{${citationKey(item)},\n${lines.join("\n")}\n}`; +} + +// --- Searching (database mode) ---------------------------------------------- + +const fold = (s: string) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); + +/** + * Every word must match the title, a creator, the year, a tag or the + * publication; `#word` must match a tag. Title matches rank first. + */ +export function matchScore(item: ZItem, query: string): number { + const words = fold(query).split(/\s+/).filter(Boolean); + if (!words.length) return 1; + const title = fold(item.title); + const people = fold(item.creators.map(fullName).join(" ")); + const tags = item.tags.map(fold); + const rest = fold([year(item), publication(item), item.fields.DOI, citationKey(item)].filter(Boolean).join(" ")); + let score = 0; + for (const w of words) { + if (w.startsWith("#")) { + const t = w.slice(1); + if (!t) continue; + if (tags.some((x) => x === t)) score += 4; + else if (tags.some((x) => x.startsWith(t))) score += 2; + else return 0; + } else if (new RegExp(`(^|\\W)${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(title)) score += 5; + else if (title.includes(w)) score += 3; + else if (people.includes(w)) score += 4; + else if (tags.some((x) => x.includes(w))) score += 2; + else if (rest.includes(w)) score += 1; + else return 0; + } + return score; +} + +export function search(items: ZItem[], query: string, limit = 50): ZItem[] { + if (!query.trim()) { + return [...items].sort((a, b) => (b.dateAdded ?? "").localeCompare(a.dateAdded ?? "")).slice(0, limit); + } + return items + .map((item) => ({ item, s: matchScore(item, query) })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || (b.item.dateModified ?? "").localeCompare(a.item.dateModified ?? "")) + .slice(0, limit) + .map((x) => x.item); +} + +/** Local API parameters for what is typed: `#tag` words become tag filters. */ +export function apiParams(query: string): Record { + const words = query.trim().split(/\s+/).filter(Boolean); + const tags = words.filter((w) => w.startsWith("#") && w.length > 1).map((w) => w.slice(1)); + const text = words.filter((w) => !w.startsWith("#")).join(" "); + const params: Record = { format: "json", limit: "40", itemType: "-attachment" }; + if (text) Object.assign(params, { q: text, qmode: "titleCreatorYear", sort: "dateModified", direction: "desc" }); + else Object.assign(params, { sort: "dateAdded", direction: "desc" }); + if (tags.length) params.tag = tags; + return params; +} + +// --- Links ------------------------------------------------------------------- + +export function selectUrl(item: Pick): string { + return item.groupId + ? `zotero://select/groups/${item.groupId}/items/${item.key}` + : `zotero://select/library/items/${item.key}`; +} + +export function openPdfUrl(item: Pick, attachment: Pick): string { + return item.groupId + ? `zotero://open-pdf/groups/${item.groupId}/items/${attachment.key}` + : `zotero://open-pdf/library/items/${attachment.key}`; +} + +/** The PDF to open: the first one with a file, else the first PDF. */ +export function mainPdf(item: Pick): Attachment | undefined { + const pdfs = item.attachments.filter((a) => a.contentType === "application/pdf" || /\.pdf$/i.test(a.path ?? "")); + return pdfs.find((a) => a.path) ?? pdfs[0]; +} + +export { doiUrl }; diff --git a/extensions/zotero/lib_test.ts b/extensions/zotero/lib_test.ts new file mode 100644 index 0000000..6417bc7 --- /dev/null +++ b/extensions/zotero/lib_test.ts @@ -0,0 +1,366 @@ +import { assert, assertEquals } from "@std/assert"; +import { + apaAuthors, + apaCitation, + apiParams, + attachmentFrom, + bibtex, + citationKey, + creatorSummary, + type DbRow, + displayDate, + fromApi, + fromDb, + initials, + inTextCitation, + mainPdf, + matchScore, + openPdfUrl, + publication, + search, + selectUrl, + typeName, + year, + type ZItem, +} from "./lib.ts"; +import { queryFor } from "./source.ts"; +import { sqliteInstall, zoteroItem } from "./zotero.ts"; + +const STORAGE = "/home/me/Zotero/storage"; + +// A row as `sqlite3 -json` prints it for the query in source.ts. +const ROW: DbRow = { + key: "ABCD1234", + groupID: null, + typeName: "journalArticle", + fields: JSON.stringify({ + title: "Sketch of the Analytical Engine", + date: "1843-00-00 1843", + publicationTitle: "Scientific Memoirs", + volume: "3", + issue: "2", + pages: "666-731", + DOI: "https://doi.org/10.1000/xyz123", + }), + creators: JSON.stringify([ + { first: "Luigi F.", last: "Menabrea", mode: 0, type: "author", i: 1 }, + { first: "Ada", last: "Lovelace", mode: 0, type: "author", i: 0 }, + ]), + tags: JSON.stringify(["history", "computing"]), + attachments: JSON.stringify([ + { key: "ATT1", path: "storage:Sketch.pdf", contentType: "application/pdf", linkMode: 0, title: "Full Text PDF" }, + { key: "ATT2", path: null, contentType: "text/html", linkMode: 3, title: "Snapshot link" }, + ]), + dateAdded: "2026-10-01 10:00:00", + dateModified: "2026-10-02 10:00:00", +}; + +const article = fromDb(ROW, STORAGE); + +const book = (extra: Partial = {}): ZItem => ({ + key: "BOOK0001", + itemType: "book", + title: "The Art of Computer Programming", + creators: [{ creatorType: "author", firstName: "Donald E.", lastName: "Knuth" }], + fields: { date: "1997", publisher: "Addison-Wesley", place: "Reading, MA", edition: "3" }, + tags: [], + attachments: [], + ...extra, +}); + +Deno.test("database rows: creators in order, dates, attachments in storage", () => { + assertEquals(article.creators.map((c) => c.lastName), ["Lovelace", "Menabrea"]); + assertEquals(article.tags, ["computing", "history"]); + assertEquals(article.attachments[0], { + key: "ATT1", + title: "Full Text PDF", + contentType: "application/pdf", + linkMode: "imported_file", + path: `${STORAGE}/ATT1/Sketch.pdf`, + }); + assertEquals(article.attachments[1].path, undefined); + assertEquals(article.dateAdded, "2026-10-01T10:00:00Z"); + assertEquals(year(article), "1843"); + assertEquals(displayDate(article.fields.date), "1843"); + assertEquals(displayDate("March 2019"), "March 2019"); + // Organizations (single-field names) and broken JSON. + const org = fromDb({ + ...ROW, + creators: JSON.stringify([{ first: "", last: "World Health Organization", mode: 1, type: "author", i: 0 }]), + tags: "oops", + }, STORAGE); + assertEquals(org.creators, [{ creatorType: "author", name: "World Health Organization" }]); + assertEquals(org.tags, []); + assertEquals(fromDb({ ...ROW, groupID: 4242 }, STORAGE).groupId, 4242); +}); + +Deno.test("local API items", () => { + const raw = { + key: "ABCD1234", + library: { type: "group", id: 7 }, + meta: { numChildren: 1 }, + data: { + key: "ABCD1234", + itemType: "journalArticle", + title: "T", + creators: [{ creatorType: "author", firstName: "Ada", lastName: "Lovelace" }], + date: "1843", + tags: [{ tag: "computing" }, { tag: "history", type: 1 }], + collections: ["X"], + relations: {}, + extra: "", + }, + }; + const child = { + key: "ATT1", + data: { + itemType: "attachment", + linkMode: "imported_file", + filename: "a.pdf", + contentType: "application/pdf", + title: "PDF", + }, + }; + const linked = { + key: "ATT2", + data: { + itemType: "attachment", + linkMode: "linked_file", + path: "/home/me/Papers/b.pdf", + contentType: "application/pdf", + title: "B", + }, + }; + const note = { key: "N", data: { itemType: "note" } }; + const item = fromApi(raw, [child, linked, note], STORAGE); + assertEquals(item.groupId, 7); + assertEquals(item.tags, ["computing", "history"]); + assertEquals(item.fields, { title: "T", date: "1843" }); + assertEquals(item.attachments.map((a) => a.path), [`${STORAGE}/ATT1/a.pdf`, "/home/me/Papers/b.pdf"]); + assertEquals(attachmentFrom({ key: "K", linkMode: "linked_url", title: "Site" }, STORAGE).path, undefined); +}); + +Deno.test("names, types and publications", () => { + assertEquals(initials("Ada"), "A."); + assertEquals(initials("Luigi F."), "L. F."); + assertEquals(initials("Jean-Paul"), "J.-P."); + assertEquals(initials("Émile"), "É."); + assertEquals(creatorSummary(article), "Lovelace and Menabrea"); + assertEquals(creatorSummary(book()), "Knuth"); + assertEquals( + creatorSummary({ creators: [1, 2, 3].map((n) => ({ creatorType: "author", lastName: `L${n}` })) }), + "L1 et al.", + ); + assertEquals(typeName("journalArticle"), "Journal Article"); + assertEquals(typeName("videoRecording"), "Video Recording"); + assertEquals(publication(article), "Scientific Memoirs"); + assertEquals(publication(book()), "Addison-Wesley"); +}); + +Deno.test("APA authors: ampersand, 20 names, organizations", () => { + const people = (n: number) => + Array.from({ length: n }, (_, i) => ({ creatorType: "author", firstName: "A", lastName: `L${i + 1}` })); + assertEquals(apaAuthors(people(1)), "L1, A."); + assertEquals(apaAuthors(people(2)), "L1, A., & L2, A."); + assertEquals(apaAuthors(people(3)), "L1, A., L2, A., & L3, A."); + const many = apaAuthors(people(25)); + assert(many.startsWith("L1, A., L2, A.,")); + assert(many.endsWith("L19, A., … L25, A.")); + assertEquals(apaAuthors([{ creatorType: "author", name: "World Health Organization" }]), "World Health Organization"); +}); + +Deno.test("APA references for common types", () => { + assertEquals( + apaCitation(article), + "Lovelace, A., & Menabrea, L. F. (1843). Sketch of the Analytical Engine. Scientific Memoirs, 3(2), 666–731. https://doi.org/10.1000/xyz123", + ); + assertEquals(apaCitation(book()), "Knuth, D. E. (1997). The Art of Computer Programming (3rd ed.). Addison-Wesley."); + assertEquals( + apaCitation({ + key: "S", + itemType: "bookSection", + title: "On Computable Numbers", + creators: [{ creatorType: "author", firstName: "Alan", lastName: "Turing" }, { + creatorType: "editor", + firstName: "B. Jack", + lastName: "Copeland", + }], + fields: { date: "2004", bookTitle: "The Essential Turing", pages: "58-90", publisher: "Oxford University Press" }, + tags: [], + attachments: [], + }), + "Turing, A. (2004). On Computable Numbers. In B. J. Copeland (Ed.), The Essential Turing (pp. 58–90). Oxford University Press.", + ); + assertEquals( + apaCitation({ + key: "W", + itemType: "webpage", + title: "Zotero Documentation", + creators: [], + fields: { url: "https://www.zotero.org/support/", websiteTitle: "Zotero" }, + tags: [], + attachments: [], + }), + "Zotero Documentation. (n.d.). Zotero. https://www.zotero.org/support/", + ); + assertEquals( + apaCitation({ + key: "T", + itemType: "thesis", + title: "Deep Things", + creators: [{ creatorType: "author", firstName: "Grace", lastName: "Hopper" }], + fields: { date: "1934", thesisType: "Doctoral dissertation", university: "Yale University" }, + tags: [], + attachments: [], + }), + "Hopper, G. (1934). Deep Things [Doctoral dissertation, Yale University].", + ); + assertEquals( + apaCitation( + book({ creators: [{ creatorType: "editor", firstName: "Ann", lastName: "Ed" }], fields: { date: "2001" } }), + ), + "Ed, A. (Ed.). (2001). The Art of Computer Programming.", + ); + assertEquals(inTextCitation(article), "(Lovelace & Menabrea, 1843)"); + assertEquals(inTextCitation(book({ fields: {} })), "(Knuth, n.d.)"); +}); + +Deno.test("citation keys and BibTeX", () => { + assertEquals(citationKey(article), "lovelace1843sketch"); + assertEquals(citationKey(book()), "knuth1997art"); + assertEquals(citationKey(book({ fields: { extra: "Citation Key: knuthTAOCP\nother" } })), "knuthTAOCP"); + assertEquals( + citationKey( + book({ + creators: [{ creatorType: "author", firstName: "Kurt", lastName: "Gödel" }], + title: "Über formal unentscheidbare Sätze", + fields: { date: "1931" }, + }), + ), + "godel1931uber", + ); + assertEquals( + bibtex(article), + [ + "@article{lovelace1843sketch,", + " title = {{Sketch of the Analytical Engine}},", + " author = {Lovelace, Ada and Menabrea, Luigi F.},", + " journal = {Scientific Memoirs},", + " year = {1843},", + " volume = {3},", + " number = {2},", + " pages = {666--731},", + " doi = {https://doi.org/10.1000/xyz123},", + " keywords = {computing, history},", + "}", + ].join("\n"), + ); + const b = bibtex(book({ title: "R&D: 100% {fun}" })); + assert(b.startsWith("@book{knuth1997rd,")); + assert(b.includes("title = {{R\\&D: 100\\% \\{fun\\}}},")); + assert(b.includes("publisher = {Addison-Wesley},")); + assert(b.includes("address = {Reading, MA},")); + assert(!b.includes("howpublished")); + assert( + bibtex({ ...book(), itemType: "thesis", fields: { thesisType: "Master's thesis", university: "MIT" } }).startsWith( + "@mastersthesis{", + ), + ); +}); + +Deno.test("searching the database copy", () => { + const items = [article, book({ tags: ["algorithms"], dateAdded: "2026-10-20T08:00:00Z" })]; + assertEquals(search(items, "").map((i) => i.key), ["BOOK0001", "ABCD1234"]); + assertEquals(search(items, "lovelace").map((i) => i.key), ["ABCD1234"]); + assertEquals(search(items, "analytical eng").map((i) => i.key), ["ABCD1234"]); + assertEquals(search(items, "#algo").map((i) => i.key), ["BOOK0001"]); + assertEquals(search(items, "knuth 1997").map((i) => i.key), ["BOOK0001"]); + assertEquals(search(items, "knuth 1843"), []); + assertEquals(matchScore(article, "#nope"), 0); + assert(matchScore(article, "sketch") > matchScore(article, "memoirs")); +}); + +Deno.test("local API parameters", () => { + assertEquals(apiParams("lovelace #computing #history"), { + format: "json", + limit: "40", + itemType: "-attachment", + q: "lovelace", + qmode: "titleCreatorYear", + sort: "dateModified", + direction: "desc", + tag: ["computing", "history"], + }); + assertEquals(apiParams("").sort, "dateAdded"); +}); + +Deno.test("links and PDFs", () => { + assertEquals(selectUrl(article), "zotero://select/library/items/ABCD1234"); + assertEquals(selectUrl({ key: "K", groupId: 9 }), "zotero://select/groups/9/items/K"); + assertEquals(mainPdf(article)?.key, "ATT1"); + assertEquals(openPdfUrl(article, mainPdf(article)!), "zotero://open-pdf/library/items/ATT1"); + assertEquals(mainPdf(book()), undefined); +}); + +Deno.test("item and setup", () => { + const item = zoteroItem(article, "db"); + assertEquals(item.subtitle, "Lovelace and Menabrea · Scientific Memoirs"); + assertEquals(item.actions?.slice(0, 4).map((a) => a.title), [ + "Show in Zotero", + "Open PDF in Zotero", + "Open PDF in Default App", + "Copy Citation", + ]); + assertEquals(sqliteInstall("ID=fedora"), "sudo dnf install sqlite"); + assertEquals(sqliteInstall('ID=pop\nID_LIKE="ubuntu debian"'), "sudo apt install sqlite3"); + assertEquals(sqliteInstall("ID=arch"), "sudo pacman -S sqlite"); +}); + +Deno.test("the SQL query runs against a Zotero-shaped database", async () => { + // Only when a sqlite3 program is installed (CI may not have it). + let ok = false; + try { + ok = (await new Deno.Command("sqlite3", { args: ["-version"], stdout: "null", stderr: "null" }).output()).success; + } catch { /* not installed */ } + if (!ok) return; + const dir = await Deno.makeTempDir(); + try { + const schema = ` +CREATE TABLE itemTypesCombined (itemTypeID INT, typeName TEXT); +CREATE TABLE fieldsCombined (fieldID INT, fieldName TEXT); +CREATE TABLE items (itemID INTEGER PRIMARY KEY, itemTypeID INT, dateAdded TEXT, dateModified TEXT, libraryID INT, key TEXT); +CREATE TABLE itemDataValues (valueID INTEGER PRIMARY KEY, value); +CREATE TABLE itemData (itemID INT, fieldID INT, valueID INT); +CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, fieldMode INT); +CREATE TABLE creatorTypes (creatorTypeID INT, creatorType TEXT); +CREATE TABLE itemCreators (itemID INT, creatorID INT, creatorTypeID INT, orderIndex INT); +CREATE TABLE tags (tagID INTEGER PRIMARY KEY, name TEXT); +CREATE TABLE itemTags (itemID INT, tagID INT, type INT); +CREATE TABLE itemAttachments (itemID INT, parentItemID INT, linkMode INT, contentType TEXT, path TEXT); +CREATE TABLE deletedItems (itemID INT); +CREATE TABLE groups (groupID INT, libraryID INT); +INSERT INTO itemTypesCombined VALUES (1, 'book'), (2, 'attachment'); +INSERT INTO fieldsCombined VALUES (1, 'title'); +INSERT INTO items VALUES (1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', 1, 'BOOK'), (2, 2, '', '', 1, 'ATT'), (3, 1, '', '', 1, 'GONE'); +INSERT INTO itemDataValues VALUES (1, 'A Book'), (2, 'PDF'); +INSERT INTO itemData VALUES (1, 1, 1), (2, 1, 2); +INSERT INTO creators VALUES (1, 'Ada', 'Lovelace', 0); +INSERT INTO creatorTypes VALUES (1, 'author'); +INSERT INTO itemCreators VALUES (1, 1, 1, 0); +INSERT INTO itemAttachments VALUES (2, 1, 0, 'application/pdf', 'storage:a.pdf'); +INSERT INTO deletedItems VALUES (3);`; + const db = `${dir}/z.sqlite`; + await new Deno.Command("sqlite3", { args: [db, schema] }).output(); + const out = await new Deno.Command("sqlite3", { args: ["-json", db], stdin: "piped", stdout: "piped" }).spawn(); + const w = out.stdin.getWriter(); + await w.write(new TextEncoder().encode(queryFor(true))); + await w.close(); + const rows = JSON.parse(new TextDecoder().decode((await out.output()).stdout)) as DbRow[]; + const items = rows.map((r) => fromDb(r, STORAGE)); + assertEquals(items.map((i) => [i.key, i.title, i.creators[0]?.lastName, i.attachments[0]?.path]), [ + ["BOOK", "A Book", "Lovelace", `${STORAGE}/ATT/a.pdf`], + ]); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/extensions/zotero/source.ts b/extensions/zotero/source.ts new file mode 100644 index 0000000..467c3f8 --- /dev/null +++ b/extensions/zotero/source.ts @@ -0,0 +1,156 @@ +// Where items come from: Zotero 7's local API while Zotero runs with it +// enabled, else a copy of zotero.sqlite read with the sqlite3 program. + +import { exec } from "@backslash/api"; +import { API, apiParams, type DbRow, fromApi, fromDb, search, type ZItem } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +export const DATA_DIR = `${HOME}/Zotero`; +export const STORAGE_DIR = `${DATA_DIR}/storage`; +const DB = `${DATA_DIR}/zotero.sqlite`; +/** Zotero locks its database while it runs: it is read from a copy, refreshed when it changes. */ +const COPY_DIR = `${HOME}/.cache/backslash-zotero`; + +export type Kind = "api" | "db"; + +export class SetupError extends Error { + constructor(message: string, readonly reason: "no-sqlite" | "no-library") { + super(message); + } +} + +// --- Local API ---------------------------------------------------------------- + +/** `undefined` when the local API can't be used (Zotero closed, too old, or the API is off). */ +export async function apiSearch(query: string, signal?: AbortSignal): Promise { + const url = new URL(`${API}/users/0/items/top`); + for (const [k, v] of Object.entries(apiParams(query))) { + for (const x of Array.isArray(v) ? v : [v]) url.searchParams.append(k, x); + } + let res: Response; + try { + res = await fetch(url, { signal: signal ?? AbortSignal.timeout(5000), headers: { "Zotero-API-Version": "3" } }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError" && signal?.aborted) throw e; + return undefined; + } + if (!res.ok) { + await res.body?.cancel(); + return undefined; // 403 "Local API is not enabled", or 404 before Zotero 7 + } + const raw = (await res.json()) as any[]; + const items = raw.filter((r) => !["note", "attachment", "annotation"].includes(r?.data?.itemType)); + const children = await Promise.all(items.map(async (r) => { + if (!r?.meta?.numChildren) return []; + const c = await fetch(`${API}/users/0/items/${r.key}/children?format=json`, { signal }).catch(() => undefined); + return c?.ok ? await c.json() as any[] : []; + })); + return items.map((r, i) => fromApi(r, children[i], STORAGE_DIR)); +} + +/** Zotero's own BibTeX export of an item, if the local API is on. */ +export async function apiBibtex(item: ZItem): Promise { + if (item.groupId) return undefined; + try { + const res = await fetch(`${API}/users/0/items/${item.key}?format=bibtex`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return undefined; + const text = (await res.text()).trim(); + return text.startsWith("@") ? text : undefined; + } catch { + return undefined; + } +} + +// --- Database ----------------------------------------------------------------- + +const VALUE = (field: string, item: string) => + `(SELECT v.value FROM itemData d JOIN FIELDS f ON f.fieldID = d.fieldID JOIN itemDataValues v ON v.valueID = d.valueID WHERE d.itemID = ${item} AND f.fieldName = '${field}')`; + +/** Top-level items of every library, not in the trash, with their fields, creators, tags and attachments. */ +export const QUERY = ` +SELECT i.key AS key, g.groupID AS groupID, t.typeName AS typeName, + (SELECT json_group_object(f.fieldName, v.value) FROM itemData d + JOIN FIELDS f ON f.fieldID = d.fieldID JOIN itemDataValues v ON v.valueID = d.valueID + WHERE d.itemID = i.itemID) AS fields, + (SELECT json_group_array(json_object('first', c.firstName, 'last', c.lastName, 'mode', c.fieldMode, + 'type', ct.creatorType, 'i', ic.orderIndex)) FROM itemCreators ic + JOIN creators c ON c.creatorID = ic.creatorID JOIN creatorTypes ct ON ct.creatorTypeID = ic.creatorTypeID + WHERE ic.itemID = i.itemID) AS creators, + (SELECT json_group_array(tg.name) FROM itemTags it JOIN tags tg ON tg.tagID = it.tagID + WHERE it.itemID = i.itemID) AS tags, + (SELECT json_group_array(json_object('key', ai.key, 'path', a.path, 'contentType', a.contentType, + 'linkMode', a.linkMode, 'title', ${VALUE("title", "a.itemID")})) FROM itemAttachments a + JOIN items ai ON ai.itemID = a.itemID + WHERE a.parentItemID = i.itemID AND a.itemID NOT IN (SELECT itemID FROM deletedItems)) AS attachments, + i.dateAdded AS dateAdded, i.dateModified AS dateModified +FROM items i +JOIN TYPES t ON t.itemTypeID = i.itemTypeID +LEFT JOIN groups g ON g.libraryID = i.libraryID +WHERE t.typeName NOT IN ('attachment', 'note', 'annotation') + AND i.itemID NOT IN (SELECT itemID FROM deletedItems); +`; + +/** Zotero 5+ keeps custom fields and types in the *Combined tables; older schemas only have the plain ones. */ +export const queryFor = (combined: boolean) => + QUERY.replaceAll("JOIN FIELDS", combined ? "JOIN fieldsCombined" : "JOIN fields") + .replaceAll("JOIN TYPES", combined ? "JOIN itemTypesCombined" : "JOIN itemTypes"); + +const mtime = (path: string) => Deno.stat(path).then((s) => s.mtime?.getTime() ?? 0, () => -1); + +/** A fresh copy of the database (and its write-ahead log), made only when Zotero changed it. */ +async function copyDb(): Promise { + const source = await mtime(DB); + if (source < 0) throw new SetupError("No Zotero library in ~/Zotero", "no-library"); + const copy = `${COPY_DIR}/zotero.sqlite`; + const wal = Math.max(await mtime(`${DB}-wal`), source); + if ((await mtime(copy)) >= wal) return copy; + await Deno.mkdir(COPY_DIR, { recursive: true }); + await Deno.copyFile(DB, `${copy}.tmp`); + await Deno.remove(`${copy}-wal`).catch(() => {}); + await Deno.remove(`${copy}-shm`).catch(() => {}); + if ((await mtime(`${DB}-wal`)) >= 0) await Deno.copyFile(`${DB}-wal`, `${copy}-wal`); + await Deno.rename(`${copy}.tmp`, copy); + return copy; +} + +async function sqlite(path: string, sql: string, signal?: AbortSignal): Promise { + let out: string; + try { + out = await exec("sqlite3", ["-json", path], { stdin: sql, signal }); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new SetupError("Reading the Zotero database needs the sqlite3 program", "no-sqlite"); + } + throw e; + } + return out ? JSON.parse(out) as DbRow[] : []; +} + +let cached: { stamp: number; items: ZItem[] } | undefined; + +export async function dbItems(signal?: AbortSignal): Promise { + const path = await copyDb(); + const stamp = await mtime(path); + if (cached?.stamp === stamp) return cached.items; + let rows: DbRow[]; + try { + rows = await sqlite(path, queryFor(true), signal); + } catch (e) { + if (!(e instanceof Error) || !/no such table/i.test(e.message)) throw e; + rows = await sqlite(path, queryFor(false), signal); + } + const items = rows.map((r) => fromDb(r, STORAGE_DIR)); + cached = { stamp, items }; + return items; +} + +/** Results for what is typed, and where they came from. */ +export async function find( + query: string, + signal?: AbortSignal, +): Promise<{ items: ZItem[]; kind: Kind; total?: number }> { + const viaApi = await apiSearch(query, signal); + if (viaApi) return { items: viaApi, kind: "api" }; + const all = await dbItems(signal); + return { items: search(all, query, 50), kind: "db", total: all.length }; +} diff --git a/extensions/zotero/zotero.ts b/extensions/zotero/zotero.ts new file mode 100644 index 0000000..56657d5 --- /dev/null +++ b/extensions/zotero/zotero.ts @@ -0,0 +1,248 @@ +// Zotero items as launcher items. + +import { + type Action, + action, + clipboard, + close, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + paste, +} from "@backslash/api"; +import { + apaCitation, + bibtex, + citationKey, + creatorSummary, + displayDate, + doiUrl, + fullName, + inTextCitation, + mainPdf, + openPdfUrl, + publication, + selectUrl, + typeName, + year, + type ZItem, +} from "./lib.ts"; +import { apiBibtex, type Kind, SetupError } from "./source.ts"; + +const TYPE_ICONS: Record = { + book: Icons.book, + bookSection: Icons.book, + thesis: Icons.archive, + webpage: Icons.globe, + blogPost: Icons.rss, + forumPost: Icons.comment, + videoRecording: Icons.video, + film: Icons.film, + audioRecording: Icons.music, + podcast: Icons.mic, + radioBroadcast: Icons.radio, + tvBroadcast: Icons.monitor, + computerProgram: Icons.code, + dataset: Icons.database, + presentation: Icons.monitor, + map: Icons.globe, + artwork: Icons.image, + email: Icons.mail, + letter: Icons.mail, + patent: Icons.shield, + statute: Icons.shield, + case: Icons.shield, + conferencePaper: Icons.person, + report: Icons.file, + preprint: Icons.file, +}; + +const pages = (item: ZItem) => { + const f = item.fields; + return [f.volume ? `Vol. ${f.volume}` : "", f.issue ? `No. ${f.issue}` : "", f.pages ? `pp. ${f.pages}` : ""] + .filter(Boolean) + .join(", "); +}; + +function itemMetadata(item: ZItem): Metadata[] { + const f = item.fields; + const lines: Metadata[] = [ + meta.text("Type", typeName(item.itemType), { icon: TYPE_ICONS[item.itemType] ?? Icons.file }), + ]; + for (const c of item.creators.slice(0, 6)) { + lines.push(meta.person(c.creatorType === "author" ? "Author" : typeName(c.creatorType), fullName(c))); + } + if (item.creators.length > 6) lines.push(meta.text("", `and ${item.creators.length - 6} more`)); + if (f.date) lines.push(meta.text("Date", displayDate(f.date)!, { icon: Icons.calendar })); + const pub = publication(item); + if (pub) lines.push(meta.text("Published in", pub, { icon: Icons.book })); + if (pages(item)) lines.push(meta.text("Volume", pages(item))); + if (f.publisher && f.publisher !== pub) { + lines.push(meta.text("Publisher", [f.publisher, f.place].filter(Boolean).join(", "))); + } + if (f.DOI) lines.push(meta.link("DOI", f.DOI, doiUrl(f.DOI)!)); + else if (f.url) lines.push(meta.link("URL", f.url.replace(/^https?:\/\/(www\.)?/, "").slice(0, 50), f.url)); + if (f.ISBN) lines.push(meta.text("ISBN", f.ISBN)); + if (item.tags.length) { + lines.push(meta.tags("Tags", item.tags.slice(0, 10).map((t) => ({ text: t, color: "orange" as const })))); + } + lines.push(meta.separator()); + if (item.attachments.length) { + lines.push(meta.tags( + "Attachments", + item.attachments.slice(0, 4).map((a) => ({ + text: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title, + color: a.contentType === "application/pdf" ? "red" as const : undefined, + })), + )); + } + lines.push(meta.text("Citation key", citationKey(item), { icon: Icons.braces })); + if (item.groupId) lines.push(meta.text("Library", `Group ${item.groupId}`, { icon: Icons.person })); + if (item.dateAdded) { + lines.push( + meta.text("Added", new Date(item.dateAdded).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.plus, + }), + ); + } + return lines; +} + +function itemMarkdown(item: ZItem): string { + const abstract = item.fields.abstractNote?.trim(); + const short = abstract && abstract.length > 1200 ? `${abstract.slice(0, 1200).replace(/\s+\S*$/, "")}…` : abstract; + return [ + `### ${(item.title || "Untitled").replace(/[#*_`[\]]/g, "\\$&")}`, + "", + `> ${apaCitation(item).replace(/[*_`[\]]/g, "\\$&")}`, + ...(short ? ["", "**Abstract**", "", short] : []), + ].join("\n"); +} + +function itemActions(item: ZItem, kind: Kind): Action[] { + const pdf = mainPdf(item); + const f = item.fields; + const link = doiUrl(f.DOI) ?? f.url; + const citation = apaCitation(item); + return [ + open(selectUrl(item), "Show in Zotero", { icon: Icons.external }), + ...(pdf + ? [ + open(openPdfUrl(item, pdf), "Open PDF in Zotero", { icon: Icons.file, shortcut: "o" }), + ...(pdf.path + ? [open(pdf.path, "Open PDF in Default App", { icon: Icons.file, shortcut: "o" })] + : []), + ] + : []), + copy(citation, "Copy Citation", { shortcut: "c", section: "Cite", icon: Icons.copy }), + paste(citation, "Paste Citation", { shortcut: "v", section: "Cite" }), + copy(inTextCitation(item), "Copy In-Text Citation", { section: "Cite" }), + action("Copy BibTeX", async () => { + // Zotero's own export when the local API is on; the built one otherwise. + const text = (kind === "api" ? await apiBibtex(item) : undefined) ?? bibtex(item); + return close(await copyText(text, "BibTeX copied")); + }, { icon: Icons.braces, shortcut: "b", section: "Cite" }), + copy(citationKey(item), "Copy Citation Key", { section: "Cite" }), + ...(link + ? [ + open(link, f.DOI ? "Open DOI" : "Open URL", { icon: Icons.globe, section: "Links" }), + copy(link, f.DOI ? "Copy DOI Link" : "Copy URL", { icon: Icons.link, section: "Links" }), + ] + : []), + copy(selectUrl(item), "Copy Zotero Link", { icon: Icons.link, section: "Links" }), + copy(item.title, "Copy Title", { section: "Links" }), + ...item.attachments.filter((a) => a !== pdf && a.path).slice(0, 3).map((a) => + open(a.path!, `Open “${a.title.length > 30 ? `${a.title.slice(0, 29)}…` : a.title}”`, { + icon: Icons.file, + section: "Attachments", + }) + ), + ]; +} + +async function copyText(text: string, message: string): Promise { + await clipboard.write(text); + return message; +} + +export function zoteroItem(item: ZItem, kind: Kind): Item { + const pdf = mainPdf(item); + const y = year(item); + const who = creatorSummary(item); + return { + id: `${item.groupId ?? 0}/${item.key}`, + title: item.title || "Untitled", + subtitle: [who, publication(item)].filter(Boolean).join(" · ") || undefined, + icon: { source: TYPE_ICONS[item.itemType] ?? Icons.file, tint: item.itemType === "book" ? "orange" : "blue" }, + accessories: [ + ...item.tags.slice(0, 1).map((t) => ({ tag: t, color: "orange" as const })), + ...(pdf ? [{ icon: { source: Icons.file, tint: "red" as const }, tooltip: "PDF attached" }] : []), + ...(y ? [{ text: y, tooltip: displayDate(item.fields.date) }] : []), + ], + detail: { markdown: itemMarkdown(item), metadata: itemMetadata(item) }, + actions: itemActions(item, kind), + }; +} + +export async function setupItems(e: SetupError): Promise { + const osRelease = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const enable = [ + "**Turn on Zotero's local API** (Zotero 7): **Edit → Settings → Advanced → Miscellaneous →", + "Allow other applications on this computer to communicate with Zotero**. Backslash then searches", + "through Zotero while it runs.", + ]; + if (e.reason === "no-sqlite") { + return [{ + id: "setup", + title: "Turn on Zotero's local API, or install sqlite3", + subtitle: "Zotero 7 → Settings → Advanced → Allow other applications…", + icon: { source: Icons.plug, tint: "orange" }, + detail: { + markdown: [ + "## Connect Zotero", + "", + ...enable, + "", + "**Or install sqlite3** to read your library even when Zotero is closed:", + "", + `\`${sqliteInstall(osRelease)}\``, + ].join("\n"), + }, + actions: [ + copy(sqliteInstall(osRelease), "Copy Install Command"), + open("zotero://select/library", "Open Zotero", { icon: Icons.external }), + ], + }]; + } + return [{ + id: "setup", + title: "No Zotero library found", + subtitle: "Backslash reads ~/Zotero, Zotero's default data directory", + icon: { source: Icons.book, tint: "orange" }, + detail: { + markdown: [ + "## No Zotero library", + "", + "Zotero keeps its library in **~/Zotero** unless you moved it (Settings → Advanced → Files and Folders).", + "Backslash reads that folder only.", + "", + ...enable, + ].join("\n"), + }, + actions: [open("https://www.zotero.org/download/", "Download Zotero", { icon: Icons.download })], + }]; +} + +/** `ID` and `ID_LIKE` from /etc/os-release, to pick the package manager. */ +export function sqliteInstall(osRelease: string): string { + const get = (k: string) => new RegExp(`^${k}=(.*)$`, "m").exec(osRelease)?.[1]?.replace(/["']/g, "") ?? ""; + const ids = `${get("ID")} ${get("ID_LIKE")}`.toLowerCase().split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros"].includes(i))) return "sudo pacman -S sqlite"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "sudo dnf install sqlite"; + if (ids.some((i) => ["opensuse", "suse", "opensuse-tumbleweed"].includes(i))) return "sudo zypper install sqlite3"; + if (ids.includes("alpine")) return "sudo apk add sqlite"; + return "sudo apt install sqlite3"; +} From 093653e6600fb751964f1da8bad8172ff074ac94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:37:27 +0000 Subject: [PATCH 04/10] Databases, AWS, Google Cloud, Cloudflare, Ollama Models and Nerd Font Icons Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/aws/aws.ts | 229 +++++++ extensions/aws/backslash.toml | 67 ++ extensions/aws/commands/ec2.ts | 155 +++++ extensions/aws/commands/logs.ts | 116 ++++ extensions/aws/commands/profiles.ts | 152 +++++ extensions/aws/commands/s3.ts | 186 ++++++ extensions/aws/lib.ts | 434 +++++++++++++ extensions/aws/lib_test.ts | 212 +++++++ extensions/aws/terminal.ts | 89 +++ extensions/cloudflare/api.ts | 120 ++++ extensions/cloudflare/backslash.toml | 39 ++ extensions/cloudflare/commands/dns.ts | 6 + extensions/cloudflare/commands/pages.ts | 166 +++++ extensions/cloudflare/commands/zones.ts | 71 +++ extensions/cloudflare/lib.ts | 362 +++++++++++ extensions/cloudflare/lib_test.ts | 204 ++++++ extensions/cloudflare/views.ts | 263 ++++++++ extensions/databases/backslash.toml | 78 +++ extensions/databases/commands/add.ts | 6 + extensions/databases/commands/connections.ts | 241 ++++++++ extensions/databases/db.ts | 275 +++++++++ extensions/databases/lib.ts | 583 ++++++++++++++++++ extensions/databases/lib_test.ts | 313 ++++++++++ extensions/databases/terminal.ts | 89 +++ extensions/google-cloud/backslash.toml | 53 ++ extensions/google-cloud/commands/instances.ts | 139 +++++ extensions/google-cloud/commands/projects.ts | 73 +++ extensions/google-cloud/commands/run.ts | 130 ++++ extensions/google-cloud/gcp.ts | 164 +++++ extensions/google-cloud/lib.ts | 336 ++++++++++ extensions/google-cloud/lib_test.ts | 180 ++++++ extensions/google-cloud/terminal.ts | 89 +++ extensions/nerd-fonts/backslash.toml | 19 + extensions/nerd-fonts/commands/search.ts | 154 +++++ extensions/nerd-fonts/fallback.json | 321 ++++++++++ extensions/nerd-fonts/glyphs.ts | 56 ++ extensions/nerd-fonts/lib.ts | 166 +++++ extensions/nerd-fonts/lib_test.ts | 116 ++++ extensions/ollama/backslash.toml | 34 + extensions/ollama/commands/models.ts | 232 +++++++ extensions/ollama/commands/pull.ts | 9 + extensions/ollama/lib.ts | 248 ++++++++ extensions/ollama/lib_test.ts | 144 +++++ extensions/ollama/ollama.ts | 346 +++++++++++ 44 files changed, 7465 insertions(+) create mode 100644 extensions/aws/aws.ts create mode 100644 extensions/aws/backslash.toml create mode 100644 extensions/aws/commands/ec2.ts create mode 100644 extensions/aws/commands/logs.ts create mode 100644 extensions/aws/commands/profiles.ts create mode 100644 extensions/aws/commands/s3.ts create mode 100644 extensions/aws/lib.ts create mode 100644 extensions/aws/lib_test.ts create mode 100644 extensions/aws/terminal.ts create mode 100644 extensions/cloudflare/api.ts create mode 100644 extensions/cloudflare/backslash.toml create mode 100644 extensions/cloudflare/commands/dns.ts create mode 100644 extensions/cloudflare/commands/pages.ts create mode 100644 extensions/cloudflare/commands/zones.ts create mode 100644 extensions/cloudflare/lib.ts create mode 100644 extensions/cloudflare/lib_test.ts create mode 100644 extensions/cloudflare/views.ts create mode 100644 extensions/databases/backslash.toml create mode 100644 extensions/databases/commands/add.ts create mode 100644 extensions/databases/commands/connections.ts create mode 100644 extensions/databases/db.ts create mode 100644 extensions/databases/lib.ts create mode 100644 extensions/databases/lib_test.ts create mode 100644 extensions/databases/terminal.ts create mode 100644 extensions/google-cloud/backslash.toml create mode 100644 extensions/google-cloud/commands/instances.ts create mode 100644 extensions/google-cloud/commands/projects.ts create mode 100644 extensions/google-cloud/commands/run.ts create mode 100644 extensions/google-cloud/gcp.ts create mode 100644 extensions/google-cloud/lib.ts create mode 100644 extensions/google-cloud/lib_test.ts create mode 100644 extensions/google-cloud/terminal.ts create mode 100644 extensions/nerd-fonts/backslash.toml create mode 100644 extensions/nerd-fonts/commands/search.ts create mode 100644 extensions/nerd-fonts/fallback.json create mode 100644 extensions/nerd-fonts/glyphs.ts create mode 100644 extensions/nerd-fonts/lib.ts create mode 100644 extensions/nerd-fonts/lib_test.ts create mode 100644 extensions/ollama/backslash.toml create mode 100644 extensions/ollama/commands/models.ts create mode 100644 extensions/ollama/commands/pull.ts create mode 100644 extensions/ollama/lib.ts create mode 100644 extensions/ollama/lib_test.ts create mode 100644 extensions/ollama/ollama.ts diff --git a/extensions/aws/aws.ts b/extensions/aws/aws.ts new file mode 100644 index 0000000..cdf1cf1 --- /dev/null +++ b/extensions/aws/aws.ts @@ -0,0 +1,229 @@ +// Calling the aws CLI with the profile picked in Backslash, and the items +// every command shares (missing CLI, errors, profile switching). + +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + open, + refresh, + storage, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + activeProfile, + classifyError, + installCommand, + osFamily, + parseProfiles, + type Profile, + regionsFor, +} from "./lib.ts"; +import { holdOpen, runInTerminal } from "./terminal.ts"; + +export interface Prefs { + regions?: string; + terminal?: string; +} + +const HOME = Deno.env.get("HOME") ?? ""; +const read = (path: string) => Deno.readTextFile(path).catch(() => ""); + +export interface Session { + profiles: Profile[]; + /** The profile commands run with; undefined when there is no config at all. */ + profile?: string; + /** Where the choice came from. */ + origin: "backslash" | "env" | "default"; + regions: string[]; +} + +export async function session(prefs: Prefs): Promise { + const [config, credentials, picked] = await Promise.all([ + read(`${HOME}/.aws/config`), + read(`${HOME}/.aws/credentials`), + storage.get("profile").catch(() => undefined), + ]); + const profiles = parseProfiles(config, credentials); + const env = Deno.env.get("AWS_PROFILE") || undefined; + const profile = activeProfile(profiles, picked ?? undefined, env); + const origin = picked && profile === picked ? "backslash" : env && profile === env ? "env" : "default"; + const current = profiles.find((p) => p.name === profile); + const envRegion = Deno.env.get("AWS_REGION") || Deno.env.get("AWS_DEFAULT_REGION") || undefined; + return { profiles, profile, origin, regions: regionsFor(prefs.regions, current?.region, envRegion) }; +} + +/** Runs `aws` with the profile and region, JSON output and no pager. */ +export function aws( + args: string[], + o: { profile?: string; region?: string; signal?: AbortSignal; json?: boolean } = {}, +): Promise { + return exec("aws", [ + ...args, + ...(o.profile ? ["--profile", o.profile] : []), + ...(o.region ? ["--region", o.region] : []), + ...(o.json === false ? [] : ["--output", "json"]), + "--no-cli-pager", + ], { signal: o.signal }); +} + +/** The same command line, for terminals and "Copy Command". */ +export const awsArgv = (args: string[], profile?: string, region?: string) => [ + "aws", + ...args, + ...(profile ? ["--profile", profile] : []), + ...(region ? ["--region", region] : []), +]; + +export const isMissing = (err: unknown) => err instanceof Deno.errors.NotFound; + +export async function useProfile(name: string) { + await storage.set("profile", name); + await toast(`Backslash now uses ${name}`, { + style: "success", + message: "Your shells keep their own AWS_PROFILE", + }); +} + +export async function showProfile(s: Session, extra?: string) { + await updateCommandMetadata({ + subtitle: s.profile ? [s.profile, s.regions.join(", "), extra].filter(Boolean).join(" · ") : null, + }).catch(() => {}); +} + +export function inTerminal( + title: string, + argv: string[], + prefs: Prefs, + o: { icon?: string; shortcut?: string; section?: string; hold?: boolean } = {}, +): Action { + return action(title, () => { + const term = runInTerminal(o.hold ? holdOpen(argv) : argv, prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: Icons.terminal, shortcut: o.shortcut, section: o.section ?? "Terminal" }); +} + +/** "Switch Profile ›", loaded when opened. */ +export function profileActions(prefs: Prefs): Action[] { + return [ + submenu("Switch Profile", async () => { + const s = await session(prefs); + return s.profiles.map((p) => + action(p.name, async () => { + await useProfile(p.name); + return refresh(); + }, { icon: p.name === s.profile ? Icons.check : Icons.person }) + ); + }, { icon: Icons.person, shortcut: "p", section: "Profile" }), + ]; +} + +let family: Promise | undefined; +export const installHint = () => (family ??= read("/etc/os-release").then((t) => installCommand(osFamily(t)))); + +export async function awsMissing(): Promise { + const cmd = await installHint(); + return { + id: "missing", + title: "The AWS CLI is not installed", + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This extension runs the **aws** command (AWS CLI v2) with your `~/.aws/config` profiles.", + "", + "```sh", + cmd, + "aws configure sso # or: aws configure", + "```", + "", + "SSM sessions also need the **Session Manager plugin**.", + ].join("\n"), + }, + actions: [ + copy(cmd, "Copy Install Command", { icon: Icons.terminal }), + open( + "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html", + "Open Install Guide", + ), + ], + }; +} + +export function noProfiles(prefs: Prefs): Item { + return { + id: "no-profile", + title: "No AWS profile yet", + subtitle: "Configure one with aws configure sso or aws configure", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "No profile in `~/.aws/config` or `~/.aws/credentials`.", + "", + "- **IAM Identity Center (SSO)**: `aws configure sso`", + "- **Access keys**: `aws configure`", + ].join("\n"), + }, + actions: [ + inTerminal("Run aws configure sso", ["aws", "configure", "sso"], prefs, { section: "Setup" }), + inTerminal("Run aws configure", ["aws", "configure"], prefs, { section: "Setup" }), + ], + }; +} + +/** An error as an item, with the way out (SSO login, configure, switch profile). */ +export function errorItem(err: unknown, s: Session, prefs: Prefs, region?: string): Item { + const e = classifyError(err); + const p = s.profile; + const fixes: Action[] = []; + if ( + e.problem === "expired" || (e.problem === "credentials" && s.profiles.find((x) => x.name === p)?.kind === "sso") + ) { + fixes.push(inTerminal("Sign In with aws sso login", awsArgv(["sso", "login"], p), prefs, { + icon: Icons.key, + section: "Fix", + hold: true, + })); + } else if (e.problem === "credentials") { + fixes.push(inTerminal("Run aws configure", awsArgv(["configure"], p), prefs, { icon: Icons.key, section: "Fix" })); + } + return { + id: `error:${region ?? ""}`, + title: e.title, + subtitle: [p, region].filter(Boolean).join(" · "), + icon: { source: Icons.alert, tint: e.problem === "denied" ? "orange" : "red" }, + section: region ? `Errors` : undefined, + detail: { markdown: `\`\`\`\n${e.message}\n\`\`\`` }, + actions: [ + ...fixes, + action("Retry", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + copy(e.message, "Copy Error", { section: "Copy" }), + ], + }; +} + +/** Runs one call per region in parallel; failed regions become error items. */ +export async function perRegion( + s: Session, + prefs: Prefs, + load: (region: string) => Promise, +): Promise<{ results: T[]; errors: Item[] }> { + const settled = await Promise.allSettled(s.regions.map(load)); + const results: T[] = []; + const errors: Item[] = []; + settled.forEach((r, i) => { + if (r.status === "fulfilled") results.push(...r.value); + else if (isMissing(r.reason)) throw r.reason; + else errors.push(errorItem(r.reason, s, prefs, s.regions[i])); + }); + // The same problem in every region (expired SSO…) is shown once. + const unique = errors.filter((e, i) => errors.findIndex((x) => x.title === e.title) === i); + return { results, errors: unique }; +} diff --git a/extensions/aws/backslash.toml b/extensions/aws/backslash.toml new file mode 100644 index 0000000..5f8d7b2 --- /dev/null +++ b/extensions/aws/backslash.toml @@ -0,0 +1,67 @@ +[extension] +id = "aws" +title = "AWS" +description = "Switch AWS profiles, start and stop EC2 instances, open SSM sessions, browse S3 buckets and tail CloudWatch logs with the aws CLI" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +# The aws CLI does the signing and reads its own credentials; the extension +# only reads the profile list (never the secret keys it shows masked). +[permissions] +env = ["HOME", "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION"] +read = ["~/.aws/config", "~/.aws/credentials", "/etc/os-release"] +run = [ + "aws", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "profiles" +title = "AWS Profiles" +description = "Profiles of ~/.aws/config and credentials; pick the one Backslash uses, SSO login, who am I" +icon = "bs-user" +keywords = ["aws", "amazon", "profile", "sso", "credentials", "account", "switch", "profil", "compte", "identifiants"] + +[[commands]] +name = "ec2" +title = "EC2 Instances" +description = "Instances with their state, type and IPs: SSM session, start, stop, reboot, console" +icon = "bs-server" +keywords = ["aws", "ec2", "instance", "vm", "server", "ssm", "amazon", "serveur", "machine virtuelle"] + +[[commands]] +name = "s3" +title = "S3 Buckets" +description = "Buckets and their objects: open in the console, copy URIs and pre-signed URLs" +icon = "bs-archive" +keywords = ["aws", "s3", "bucket", "storage", "objects", "files", "amazon", "stockage", "fichiers"] + +[[commands]] +name = "logs" +title = "CloudWatch Log Groups" +description = "Log groups with their size and retention: tail in a terminal or show the last hour" +icon = "bs-text" +keywords = ["aws", "cloudwatch", "logs", "lambda", "tail", "amazon", "journaux", "surveillance"] + +[[preferences]] +name = "regions" +title = "Regions" +description = "Regions for EC2 and CloudWatch, comma-separated (eu-west-3, us-east-1). Empty: the profile's region" + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For SSM sessions, logs and SSO login. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/aws/commands/ec2.ts b/extensions/aws/commands/ec2.ts new file mode 100644 index 0000000..0e6dd36 --- /dev/null +++ b/extensions/aws/commands/ec2.ts @@ -0,0 +1,155 @@ +import { type Action, action, copy, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { + age, + capitalize, + ec2ConsoleUrl, + filterInstances, + formatDate, + type Instance, + type InstanceFilter, + parseInstances, + sortInstances, + STATE_COLORS, +} from "../lib.ts"; +import { + aws, + awsArgv, + awsMissing, + inTerminal, + isMissing, + noProfiles, + perRegion, + type Prefs, + profileActions, + type Session, + session, + showProfile, +} from "../aws.ts"; +import { shellLine } from "../terminal.ts"; + +function changeState(i: Instance, s: Session, verb: "start" | "stop" | "reboot"): Action { + const label = i.name ?? i.id; + const titles = { start: "Start Instance", stop: "Stop Instance", reboot: "Reboot Instance" }; + const icons = { start: Icons.play, stop: Icons.stop, reboot: Icons.refresh }; + return action(titles[verb], async () => { + await aws(["ec2", `${verb}-instances`, "--instance-ids", i.id], { profile: s.profile, region: i.region }); + await toast(`${verb === "start" ? "Starting" : verb === "stop" ? "Stopping" : "Rebooting"} ${label}`, { + style: "success", + }); + return refresh(); + }, { + icon: icons[verb], + confirm: verb === "start" ? undefined : `${capitalize(verb)} ${label} (${i.id})?`, + destructive: verb !== "start", + section: "Instance", + shortcut: verb === "start" ? "s" : verb === "stop" ? "x" : undefined, + }); +} + +function item(i: Instance, s: Session, prefs: Prefs, multiRegion: boolean): Item { + const color = STATE_COLORS[i.state]; + const ssm = awsArgv(["ssm", "start-session", "--target", i.id], s.profile, i.region); + const actions: Action[] = []; + if (i.state === "running") { + actions.push(inTerminal("Start SSM Session", ssm, prefs, { icon: Icons.terminal, section: "Terminal" })); + } + actions.push(open(ec2ConsoleUrl(i), "Open in AWS Console", { icon: Icons.globe, shortcut: "o" })); + actions.push( + copy(i.id, "Copy Instance ID", { shortcut: "period", section: "Copy" }), + ...(i.publicIp ? [copy(i.publicIp, "Copy Public IP", { icon: Icons.globe, section: "Copy" })] : []), + ...(i.privateIp ? [copy(i.privateIp, "Copy Private IP", { icon: Icons.server, section: "Copy" })] : []), + ...(i.publicDns ? [copy(i.publicDns, "Copy Public DNS", { icon: Icons.link, section: "Copy" })] : []), + copy(shellLine(ssm), "Copy SSM Command", { icon: Icons.terminal, section: "Copy" }), + ); + if (i.state === "stopped") actions.push(changeState(i, s, "start")); + if (i.state === "running") actions.push(changeState(i, s, "stop"), changeState(i, s, "reboot")); + actions.push( + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + ); + + const extraTags = Object.entries(i.tags).filter(([k]) => k !== "Name" && !k.startsWith("aws:")).slice(0, 8); + return { + id: `${i.region}/${i.id}`, + title: i.name ?? i.id, + subtitle: i.name ? i.id : undefined, + icon: { source: Icons.server, tint: color }, + section: multiRegion ? i.region : capitalize(i.state), + keywords: [i.id, i.type, i.state, i.privateIp ?? "", i.publicIp ?? "", i.region, ...Object.values(i.tags)], + accessories: [ + ...(i.publicIp ? [{ icon: Icons.globe, text: i.publicIp, tooltip: "Public IP" }] : []), + { text: i.type, tooltip: "Instance type" }, + { tag: capitalize(i.state), color }, + ], + detail: { + stats: [ + { label: "state", value: capitalize(i.state), color }, + { label: "type", value: i.type }, + { label: "up for", value: i.state === "running" ? age(i.launched) || "—" : "—" }, + ], + metadata: [ + meta.text("Instance ID", i.id, { icon: Icons.hash }), + ...(i.publicIp ? [meta.text("Public IP", i.publicIp, { icon: Icons.globe })] : []), + ...(i.privateIp ? [meta.text("Private IP", i.privateIp, { icon: Icons.server })] : []), + ...(i.publicDns ? [meta.text("Public DNS", i.publicDns)] : []), + meta.text("Zone", i.zone ?? i.region, { icon: Icons.pin }), + meta.text("Platform", `${i.platform}${i.architecture ? ` · ${i.architecture}` : ""}`, { icon: Icons.cpu }), + ...(i.imageId ? [meta.text("AMI", i.imageId)] : []), + ...(i.keyName ? [meta.text("Key pair", i.keyName, { icon: Icons.key })] : []), + ...(i.iamProfile ? [meta.text("IAM profile", i.iamProfile, { icon: Icons.shield })] : []), + ...(i.securityGroups.length ? [meta.tags("Security groups", i.securityGroups)] : []), + meta.separator(), + ...(i.vpcId ? [meta.text("VPC", i.vpcId, { icon: Icons.layers })] : []), + ...(i.subnetId ? [meta.text("Subnet", i.subnetId)] : []), + ...(extraTags.length ? [meta.tags("Tags", extraTags.map(([k, v]) => `${k}=${v}`))] : []), + ...(i.launched ? [meta.text("Launched", formatDate(i.launched), { icon: Icons.calendar })] : []), + ...(i.stateReason && i.state !== "running" ? [meta.text("Reason", i.stateReason, { icon: Icons.info })] : []), + meta.text("Profile", s.profile ?? "default", { icon: Icons.person }), + ], + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter instances by name, ID, IP, type, tag…", + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "stopped", title: "Stopped" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "No instances", description: "Nothing matches in these regions", icon: Icons.server }, + status: () => summary, + async items({ preferences, filter, signal }) { + const prefs = preferences as Prefs; + summary = ""; + const s = await session(prefs); + let loaded; + try { + loaded = await perRegion(s, prefs, async (region) => + parseInstances( + await aws(["ec2", "describe-instances"], { profile: s.profile, region, signal }), + region, + )); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + throw err; + } + // Without any profile, credentials may still come from the environment. + if (!s.profiles.length && loaded.errors.length) return [noProfiles(prefs)]; + const all = loaded.results; + const running = all.filter((i) => i.state === "running").length; + summary = `${s.profile ?? "default"} · ${s.regions.join(", ")} · ${all.length} instance${ + all.length === 1 ? "" : "s" + } · ${running} running`; + await showProfile(s, loaded.errors.length ? undefined : `${running} running`); + const multi = s.regions.length > 1; + const shown = sortInstances(filterInstances(all, (filter ?? "all") as InstanceFilter)); + return [...loaded.errors, ...shown.map((i) => item(i, s, prefs, multi))]; + }, +}); diff --git a/extensions/aws/commands/logs.ts b/extensions/aws/commands/logs.ts new file mode 100644 index 0000000..de08b50 --- /dev/null +++ b/extensions/aws/commands/logs.ts @@ -0,0 +1,116 @@ +import { action, copy, detail, type DetailSpec, Icons, type Item, list, meta, open, refresh } from "@backslash/api"; +import { age, bytes, formatDate, type LogGroup, logGroupConsoleUrl, logSource, parseLogGroups } from "../lib.ts"; +import { + aws, + awsArgv, + awsMissing, + inTerminal, + isMissing, + noProfiles, + perRegion, + type Prefs, + profileActions, + type Session, + session, + showProfile, +} from "../aws.ts"; +import { shellLine } from "../terminal.ts"; + +const fence = (text: string) => `\`\`\`\n${text.replace(/```/g, "ʼʼʼ") || "(no events in the last hour)"}\n\`\`\``; + +async function recent(g: LogGroup, s: Session, prefs: Prefs): Promise { + const text = await aws(["logs", "tail", g.name, "--since", "1h", "--format", "short"], { + profile: s.profile, + region: g.region, + json: false, + }).catch((e) => String(e.message)); + const lines = text.split("\n"); + const last = lines.slice(-200).join("\n"); + return detail({ + title: g.name, + markdown: fence(last), + status: `Last hour · ${lines.length > 200 ? `last 200 of ${lines.length}` : lines.length} lines`, + metadata: metadata(g, s), + actions: [ + inTerminal("Follow in Terminal", tailArgv(g, s), prefs, { shortcut: "t" }), + copy(last, "Copy Events", { shortcut: "c" }), + action("Reload", () => recent(g, s, prefs), { icon: Icons.refresh, shortcut: "r" }), + ], + }); +} + +const tailArgv = (g: LogGroup, s: Session) => + awsArgv(["logs", "tail", g.name, "--follow", "--since", "10m", "--format", "short"], s.profile, g.region); + +function metadata(g: LogGroup, s: Session) { + return [ + meta.text("Source", logSource(g.name), { icon: Icons.layers }), + meta.text("Stored", bytes(g.storedBytes), { icon: Icons.archive }), + meta.text("Retention", g.retentionDays ? `${g.retentionDays} days` : "Never expires", { + icon: Icons.clock, + color: g.retentionDays ? undefined : "orange", + }), + ...(g.className ? [meta.tags("Class", [g.className])] : []), + meta.separator(), + meta.text("Region", g.region, { icon: Icons.globe }), + ...(g.created ? [meta.text("Created", formatDate(g.created), { icon: Icons.calendar })] : []), + meta.text("Profile", s.profile ?? "default", { icon: Icons.person }), + ]; +} + +function item(g: LogGroup, s: Session, prefs: Prefs): Item { + const tail = tailArgv(g, s); + return { + id: `${g.region}:${g.name}`, + title: g.name, + icon: { source: Icons.text, tint: "purple" }, + section: s.regions.length > 1 ? g.region : logSource(g.name), + keywords: [logSource(g.name), g.region], + accessories: [ + { text: bytes(g.storedBytes), tooltip: "Stored" }, + ...(g.retentionDays ? [{ icon: Icons.clock, text: `${g.retentionDays}d`, tooltip: "Retention" }] : []), + ...(g.created ? [{ text: age(g.created), tooltip: `Created ${formatDate(g.created)}` }] : []), + ], + detail: { metadata: metadata(g, s) }, + actions: [ + inTerminal("Tail in Terminal", tail, prefs, { section: "Logs" }), + action("Show Last Hour", () => recent(g, s, prefs), { icon: Icons.text, shortcut: "l", section: "Logs" }), + open(logGroupConsoleUrl(g), "Open in AWS Console", { icon: Icons.globe, shortcut: "o" }), + copy(g.name, "Copy Log Group Name", { shortcut: "period", section: "Copy" }), + copy(shellLine(tail), "Copy Tail Command", { icon: Icons.terminal, section: "Copy" }), + ...(g.arn ? [copy(g.arn, "Copy ARN", { section: "Copy" })] : []), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter log groups…", + empty: { title: "No log groups", description: "Nothing in these regions", icon: Icons.text }, + status: () => summary, + async items({ preferences, signal }) { + const prefs = preferences as Prefs; + summary = ""; + const s = await session(prefs); + let loaded; + try { + loaded = await perRegion(s, prefs, async (region) => + parseLogGroups( + await aws(["logs", "describe-log-groups"], { profile: s.profile, region, signal }), + region, + )); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + throw err; + } + if (!s.profiles.length && loaded.errors.length) return [noProfiles(prefs)]; + const groups = loaded.results.sort((a, b) => a.name.localeCompare(b.name)); + const total = groups.reduce((n, g) => n + g.storedBytes, 0); + summary = `${s.profile ?? "default"} · ${s.regions.join(", ")} · ${groups.length} log groups · ${bytes(total)}`; + await showProfile(s); + return [...loaded.errors, ...groups.map((g) => item(g, s, prefs))]; + }, +}); diff --git a/extensions/aws/commands/profiles.ts b/extensions/aws/commands/profiles.ts new file mode 100644 index 0000000..e22e101 --- /dev/null +++ b/extensions/aws/commands/profiles.ts @@ -0,0 +1,152 @@ +import { action, copy, detail, Icons, type Item, list, meta, type Metadata, open, refresh } from "@backslash/api"; +import { consoleHome, KIND_NAMES, maskKey, type Profile, type ProfileKind } from "../lib.ts"; +import { + aws, + awsArgv, + awsMissing, + errorItem, + inTerminal, + isMissing, + noProfiles, + type Prefs, + session, + showProfile, + useProfile, +} from "../aws.ts"; + +const KIND_ICONS: Record = { + sso: Icons.shield, + role: Icons.person, + keys: Icons.key, + process: Icons.terminal, + "web-identity": Icons.globe, + other: Icons.settings, +}; + +const ORIGIN = { + backslash: "Picked in Backslash", + env: "From $AWS_PROFILE", + default: "Default profile", +}; + +const EXPLAIN = [ + "Backslash cannot change the environment of shells that are already open.", + "**Use This Profile** makes this extension's commands (EC2, S3, CloudWatch) run with `--profile`;", + "for your terminals, copy the `export AWS_PROFILE=…` line.", +].join(" "); + +async function whoAmI(p: Profile, prefs: Prefs) { + const region = p.region ?? "us-east-1"; + let markdown: string; + let metadata: Metadata[] = []; + try { + const id = JSON.parse(await aws(["sts", "get-caller-identity"], { profile: p.name, region })) as { + Account: string; + Arn: string; + UserId: string; + }; + markdown = `## ${id.Arn.split("/").slice(1).join("/") || id.Arn}\n\n\`${id.Arn}\``; + metadata = [ + meta.text("Account", id.Account, { icon: Icons.home }), + meta.text("User ID", id.UserId, { icon: Icons.person }), + meta.text("Profile", p.name, { icon: Icons.key }), + ]; + return detail({ + title: `Who am I · ${p.name}`, + markdown, + metadata, + actions: [copy(id.Account, "Copy Account ID"), copy(id.Arn, "Copy ARN")], + }); + } catch (err) { + const item = errorItem(err, { profiles: [p], profile: p.name, origin: "default", regions: [region] }, prefs); + return detail({ + title: item.title, + markdown: `## ${item.title}\n\n${item.detail?.markdown ?? ""}`, + actions: item.actions, + }); + } +} + +function item(p: Profile, current: boolean, origin: keyof typeof ORIGIN, prefs: Prefs): Item { + const lines: Metadata[] = [ + meta.text("Credentials", KIND_NAMES[p.kind], { icon: KIND_ICONS[p.kind] }), + ...(p.region ? [meta.text("Region", p.region, { icon: Icons.globe })] : []), + ...(p.ssoAccountId ? [meta.text("Account", p.ssoAccountId, { icon: Icons.home })] : []), + ...(p.ssoRoleName ? [meta.text("Role", p.ssoRoleName, { icon: Icons.person })] : []), + ...(p.ssoStartUrl ? [meta.link("SSO portal", new URL(p.ssoStartUrl).host, p.ssoStartUrl)] : []), + ...(p.ssoSession ? [meta.text("SSO session", p.ssoSession)] : []), + ...(p.roleArn ? [meta.text("Role ARN", p.roleArn, { icon: Icons.person })] : []), + ...(p.sourceProfile ? [meta.text("Source profile", p.sourceProfile)] : []), + ...(p.accessKeyId + ? [ + meta.text("Access key", maskKey(p.accessKeyId), { icon: Icons.key, color: p.temporary ? "yellow" : undefined }), + ] + : []), + ...(p.output ? [meta.text("Output", p.output)] : []), + meta.separator(), + meta.tags("Defined in", p.files.map((f) => `~/.aws/${f}`)), + ...(current ? [meta.text("Status", ORIGIN[origin], { icon: Icons.check, color: "green" })] : []), + ]; + const region = p.region ?? "us-east-1"; + return { + id: p.name, + title: p.name, + subtitle: [KIND_NAMES[p.kind], p.ssoAccountId ?? p.roleArn?.split(":")[4]].filter(Boolean).join(" · "), + icon: { source: current ? Icons.check : KIND_ICONS[p.kind], tint: current ? "green" : "orange" }, + section: current ? "Current Profile" : "Profiles", + keywords: [p.kind, p.region ?? "", p.ssoAccountId ?? "", p.ssoRoleName ?? "", p.roleArn ?? ""], + accessories: [ + ...(p.region ? [{ icon: Icons.globe, text: p.region, tooltip: "Region" }] : []), + ...(current ? [{ tag: origin === "backslash" ? "In use" : "Current", color: "green" as const }] : []), + ], + detail: { markdown: current ? EXPLAIN : undefined, metadata: lines }, + actions: [ + ...(current && origin === "backslash" ? [] : [action("Use This Profile", async () => { + await useProfile(p.name); + return refresh(); + }, { icon: Icons.arrowRight })]), + action("Who Am I", () => whoAmI(p, prefs), { icon: Icons.person, shortcut: "i" }), + ...(p.kind === "sso" + ? [inTerminal("Sign In with aws sso login", awsArgv(["sso", "login"], p.name), prefs, { + icon: Icons.key, + shortcut: "l", + hold: true, + })] + : []), + open(consoleHome(region), "Open AWS Console", { icon: Icons.globe, shortcut: "o" }), + copy(`export AWS_PROFILE=${p.name}`, "Copy export AWS_PROFILE", { + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(p.name, "Copy Profile Name", { shortcut: "period", section: "Copy" }), + ...(p.ssoAccountId ? [copy(p.ssoAccountId, "Copy Account ID", { section: "Copy" })] : []), + ...(p.roleArn ? [copy(p.roleArn, "Copy Role ARN", { section: "Copy" })] : []), + ], + }; +} + +export default list({ + placeholder: "Switch AWS profile…", + empty: { title: "No profiles", description: "Run aws configure sso or aws configure", icon: Icons.key }, + status: (items) => { + const n = items.filter((i) => !["missing", "no-profile"].includes(i.id ?? "")).length; + return n ? `${n} profile${n === 1 ? "" : "s"} · ~/.aws/config` : ""; + }, + async items({ preferences }) { + const prefs = preferences as Prefs; + const s = await session(prefs); + await showProfile(s); + if (!s.profiles.length) { + try { + await aws(["--version"], { json: false }); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + } + return [noProfiles(prefs)]; + } + return s.profiles + .map((p) => item(p, p.name === s.profile, s.origin, prefs)) + .sort((a, b) => Number(b.section === "Current Profile") - Number(a.section === "Current Profile")); + }, +}); diff --git a/extensions/aws/commands/s3.ts b/extensions/aws/commands/s3.ts new file mode 100644 index 0000000..a622efe --- /dev/null +++ b/extensions/aws/commands/s3.ts @@ -0,0 +1,186 @@ +import { + action, + clipboard, + close, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, +} from "@backslash/api"; +import { + age, + type Bucket, + bytes, + formatDate, + parseBuckets, + parseObjects, + relativeKey, + s3ConsoleUrl, + type S3Entry, +} from "../lib.ts"; +import { + aws, + awsMissing, + errorItem, + isMissing, + noProfiles, + type Prefs, + profileActions, + type Session, + session, + showProfile, +} from "../aws.ts"; + +const MAX_KEYS = 300; + +/** One level of a bucket: its "folders" (common prefixes) and objects. */ +function browse(bucket: Bucket, prefix: string, s: Session, prefs: Prefs): ListSpec { + let truncated = false; + return list({ + title: `s3://${bucket.name}/${prefix}`, + placeholder: `Filter s3://${bucket.name}/${prefix}…`, + empty: { title: "Empty", description: "No objects under this prefix", icon: Icons.folder }, + status: (items) => `${items.length}${truncated ? `+` : ""} entries · s3://${bucket.name}/${prefix}`, + async items({ signal }) { + let entries: S3Entry[]; + try { + const out = await aws([ + "s3api", + "list-objects-v2", + "--bucket", + bucket.name, + "--prefix", + prefix, + "--delimiter", + "/", + "--max-items", + String(MAX_KEYS), + ], { profile: s.profile, region: bucket.region, signal }); + entries = parseObjects(out); + truncated = /"NextToken"/.test(out); + } catch (err) { + return [errorItem(err, s, prefs, bucket.region)]; + } + return entries.map((e) => entryItem(bucket, prefix, e, s, prefs)); + }, + }); +} + +function entryItem(bucket: Bucket, prefix: string, e: S3Entry, s: Session, prefs: Prefs): Item { + const uri = `s3://${bucket.name}/${e.key}`; + const name = relativeKey(e.key, prefix); + if (e.folder) { + return { + id: e.key, + title: name, + icon: { source: Icons.folder, tint: "blue" }, + section: "Folders", + actions: [ + action("Open Folder", () => browse(bucket, e.key, s, prefs), { icon: Icons.folder }), + open(s3ConsoleUrl(bucket.name, e.key, bucket.region), "Open in AWS Console", { + icon: Icons.globe, + shortcut: "o", + }), + copy(uri, "Copy S3 URI", { shortcut: "c", section: "Copy" }), + ], + }; + } + return { + id: e.key, + title: name, + icon: { source: Icons.file, tint: "gray" }, + section: "Objects", + accessories: [ + ...(e.size !== undefined ? [{ text: bytes(e.size), tooltip: "Size" }] : []), + ...(e.modified ? [{ text: age(e.modified), tooltip: formatDate(e.modified) }] : []), + ], + detail: { + metadata: [ + meta.text("Key", e.key, { icon: Icons.file }), + ...(e.size !== undefined ? [meta.text("Size", bytes(e.size))] : []), + ...(e.modified ? [meta.text("Modified", formatDate(e.modified), { icon: Icons.calendar })] : []), + ...(e.storageClass ? [meta.tags("Storage class", [e.storageClass])] : []), + meta.text("Bucket", bucket.name, { icon: Icons.archive }), + ], + }, + actions: [ + copy(uri, "Copy S3 URI", { shortcut: "c" }), + action("Copy Pre-signed URL (1 hour)", async () => { + const url = await aws(["s3", "presign", uri, "--expires-in", "3600"], { + profile: s.profile, + region: bucket.region, + json: false, + }); + // Anyone holding it can download the object: keep it out of clipboard histories. + await clipboard.write(url, { concealed: true }); + return close("Pre-signed URL copied, valid 1 hour"); + }, { icon: Icons.link, shortcut: "l" }), + open(s3ConsoleUrl(bucket.name, prefix, bucket.region), "Open Folder in AWS Console", { + icon: Icons.globe, + shortcut: "o", + }), + copy(e.key, "Copy Key", { section: "Copy" }), + copy(`arn:aws:s3:::${bucket.name}/${e.key}`, "Copy ARN", { section: "Copy" }), + ], + }; +} + +function bucketItem(b: Bucket, s: Session, prefs: Prefs): Item { + return { + id: b.name, + title: b.name, + icon: { source: Icons.archive, tint: "green" }, + section: b.region ?? "Buckets", + keywords: [b.region ?? ""], + accessories: [ + ...(b.region ? [{ icon: Icons.globe, text: b.region, tooltip: "Region" }] : []), + ...(b.created ? [{ text: age(b.created), tooltip: `Created ${formatDate(b.created)}` }] : []), + ], + detail: { + metadata: [ + meta.text("Bucket", b.name, { icon: Icons.archive }), + ...(b.region ? [meta.text("Region", b.region, { icon: Icons.globe })] : []), + ...(b.created ? [meta.text("Created", formatDate(b.created), { icon: Icons.calendar })] : []), + meta.text("Profile", s.profile ?? "default", { icon: Icons.person }), + ], + }, + actions: [ + open(s3ConsoleUrl(b.name, "", b.region), "Open in AWS Console", { icon: Icons.globe }), + action("Browse Objects", () => browse(b, "", s, prefs), { icon: Icons.folder, shortcut: "b" }), + copy(`s3://${b.name}`, "Copy S3 URI", { shortcut: "c", section: "Copy" }), + copy(b.name, "Copy Bucket Name", { shortcut: "period", section: "Copy" }), + copy(`arn:aws:s3:::${b.name}`, "Copy ARN", { section: "Copy" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + ], + }; +} + +export default list({ + placeholder: "Filter S3 buckets…", + empty: { title: "No buckets", description: "This account has no S3 bucket", icon: Icons.archive }, + status: (items) => { + const n = items.filter((i) => !i.id?.startsWith("error") && i.id !== "missing").length; + return n ? `${n} bucket${n === 1 ? "" : "s"}` : ""; + }, + async items({ preferences, signal }) { + const prefs = preferences as Prefs; + const s = await session(prefs); + try { + // list-buckets is global; the region only picks the endpoint. + const buckets = parseBuckets( + await aws(["s3api", "list-buckets"], { profile: s.profile, region: s.regions[0], signal }), + ); + await showProfile(s, `${buckets.length} buckets`); + return buckets.map((b) => bucketItem(b, s, prefs)); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + if (!s.profiles.length) return [noProfiles(prefs)]; + return [errorItem(err, s, prefs)]; + } + }, +}); diff --git a/extensions/aws/lib.ts b/extensions/aws/lib.ts new file mode 100644 index 0000000..f9e7224 --- /dev/null +++ b/extensions/aws/lib.ts @@ -0,0 +1,434 @@ +// Pure logic of the AWS extension: ~/.aws parsers, aws CLI output mapping, +// console URLs, error classification and formatting. No I/O here. + +// --- ~/.aws/config and ~/.aws/credentials ------------------------------------------ + +export type Ini = Map>; + +export function parseIni(text: string): Ini { + const out: Ini = new Map(); + let cur: Record | undefined; + let lastKey = ""; + for (const raw of text.split("\n")) { + const line = raw.replace(/\r$/, ""); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue; + const head = /^\[\s*([^\]]+?)\s*\]$/.exec(trimmed); + if (head) { + cur = out.get(head[1]) ?? {}; + out.set(head[1], cur); + continue; + } + if (!cur) continue; + // Indented lines continue a nested value (`s3 =` then ` max_concurrent_requests = 20`). + if (/^\s/.test(line) && lastKey) { + cur[lastKey] = `${cur[lastKey]}\n${trimmed}`.trim(); + continue; + } + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + lastKey = trimmed.slice(0, eq).trim().toLowerCase(); + cur[lastKey] = trimmed.slice(eq + 1).trim(); + } + return out; +} + +export type ProfileKind = "sso" | "role" | "keys" | "process" | "web-identity" | "other"; + +export interface Profile { + name: string; + kind: ProfileKind; + region?: string; + output?: string; + ssoStartUrl?: string; + ssoSession?: string; + ssoAccountId?: string; + ssoRoleName?: string; + roleArn?: string; + sourceProfile?: string; + /** Static access key id, never the secret. */ + accessKeyId?: string; + /** Static keys with a session token (temporary credentials). */ + temporary?: boolean; + /** Where the profile was found. */ + files: ("config" | "credentials")[]; +} + +export function parseProfiles(configText: string, credentialsText: string): Profile[] { + const config = parseIni(configText); + const creds = parseIni(credentialsText); + const byName = new Map; creds?: Record }>(); + for (const [section, values] of config) { + const name = section === "default" ? "default" : /^profile\s+(.+)$/.exec(section)?.[1]?.trim(); + if (!name) continue; // [sso-session x], [services x]… + byName.set(name, { ...byName.get(name), config: values }); + } + for (const [name, values] of creds) byName.set(name, { ...byName.get(name), creds: values }); + const sessions = new Map>(); + for (const [section, values] of config) { + const m = /^sso-session\s+(.+)$/.exec(section); + if (m) sessions.set(m[1].trim(), values); + } + const out: Profile[] = []; + for (const [name, { config: c = {}, creds: k = {} }] of byName) { + const all = { ...k, ...c }; + const session = c.sso_session ? sessions.get(c.sso_session) : undefined; + const kind: ProfileKind = c.sso_session || c.sso_start_url + ? "sso" + : all.role_arn && all.web_identity_token_file + ? "web-identity" + : all.role_arn + ? "role" + : all.credential_process + ? "process" + : all.aws_access_key_id + ? "keys" + : "other"; + out.push({ + name, + kind, + region: all.region || session?.sso_region || undefined, + output: all.output || undefined, + ssoStartUrl: c.sso_start_url || session?.sso_start_url || undefined, + ssoSession: c.sso_session || undefined, + ssoAccountId: c.sso_account_id || undefined, + ssoRoleName: c.sso_role_name || undefined, + roleArn: all.role_arn || undefined, + sourceProfile: all.source_profile || undefined, + accessKeyId: all.aws_access_key_id || undefined, + temporary: !!all.aws_session_token, + files: [ + ...(byName.get(name)?.config ? ["config" as const] : []), + ...(k.aws_access_key_id ? ["credentials" as const] : []), + ], + }); + } + return out.sort((a, b) => + Number(b.name === "default") - Number(a.name === "default") || a.name.localeCompare(b.name) + ); +} + +export const KIND_NAMES: Record = { + sso: "IAM Identity Center (SSO)", + role: "Assumed role", + keys: "Access keys", + process: "Credential process", + "web-identity": "Web identity", + other: "Settings only", +}; + +/** `AKIAIOSFODNN7EXAMPLE` → `AKIA…MPLE`. */ +export const maskKey = (id: string) => id.length > 8 ? `${id.slice(0, 4)}…${id.slice(-4)}` : "****"; + +/** The profile used: picked in Backslash, then `$AWS_PROFILE`, then `default`, then the first. */ +export function activeProfile(profiles: Profile[], picked?: string, env?: string): string | undefined { + for (const name of [picked, env]) if (name && profiles.some((p) => p.name === name)) return name; + if (profiles.some((p) => p.name === "default")) return "default"; + return env || profiles[0]?.name; +} + +/** The regions to query: the preference (comma or space separated), else the profile's, else us-east-1. */ +export function regionsFor(pref: unknown, profileRegion?: string, envRegion?: string): string[] { + const listed = String(pref ?? "").split(/[\s,]+/).map((r) => r.trim().toLowerCase()).filter((r) => + /^[a-z]{2}(-gov)?-[a-z]+-\d$/.test(r) + ); + if (listed.length) return [...new Set(listed)]; + return [profileRegion || envRegion || "us-east-1"]; +} + +// --- EC2 --------------------------------------------------------------------------- + +export type InstanceState = "pending" | "running" | "shutting-down" | "terminated" | "stopping" | "stopped"; + +export interface Instance { + id: string; + name?: string; + state: InstanceState; + type: string; + region: string; + zone?: string; + privateIp?: string; + publicIp?: string; + publicDns?: string; + imageId?: string; + keyName?: string; + vpcId?: string; + subnetId?: string; + platform: string; + architecture?: string; + launched?: Date; + securityGroups: string[]; + tags: Record; + iamProfile?: string; + stateReason?: string; +} + +export function parseInstances(json: string, region: string): Instance[] { + const data = JSON.parse(json || "{}") as { Reservations?: { Instances?: any[] }[] }; + const out: Instance[] = []; + for (const r of data.Reservations ?? []) { + for (const i of r.Instances ?? []) { + const tags = Object.fromEntries( + ((i.Tags ?? []) as { Key: string; Value: string }[]).map((t) => [t.Key, t.Value]), + ); + out.push({ + id: i.InstanceId, + name: tags.Name || undefined, + state: (i.State?.Name ?? "pending") as InstanceState, + type: i.InstanceType ?? "", + region, + zone: i.Placement?.AvailabilityZone, + privateIp: i.PrivateIpAddress || undefined, + publicIp: i.PublicIpAddress || undefined, + publicDns: i.PublicDnsName || undefined, + imageId: i.ImageId, + keyName: i.KeyName, + vpcId: i.VpcId, + subnetId: i.SubnetId, + platform: i.PlatformDetails ?? (i.Platform === "windows" ? "Windows" : "Linux/UNIX"), + architecture: i.Architecture, + launched: i.LaunchTime ? new Date(i.LaunchTime) : undefined, + securityGroups: ((i.SecurityGroups ?? []) as { GroupName: string }[]).map((g) => g.GroupName), + tags, + iamProfile: i.IamInstanceProfile?.Arn?.split("/").pop(), + stateReason: i.StateTransitionReason || undefined, + }); + } + } + return out; +} + +export const STATE_COLORS = { + running: "green", + pending: "yellow", + stopping: "orange", + "shutting-down": "orange", + stopped: "gray", + terminated: "red", +} as const; + +const STATE_ORDER: InstanceState[] = ["running", "pending", "stopping", "shutting-down", "stopped", "terminated"]; + +export function sortInstances(all: Instance[]): Instance[] { + return [...all].sort((a, b) => + STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state) || + (a.name ?? a.id).localeCompare(b.name ?? b.id) + ); +} + +export type InstanceFilter = "running" | "stopped" | "all"; + +export function filterInstances(all: Instance[], filter: InstanceFilter): Instance[] { + if (filter === "running") return all.filter((i) => i.state === "running" || i.state === "pending"); + if (filter === "stopped") return all.filter((i) => i.state === "stopped" || i.state === "stopping"); + return all; +} + +export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, " "); + +// --- S3 ---------------------------------------------------------------------------- + +export interface Bucket { + name: string; + created?: Date; + region?: string; +} + +export function parseBuckets(json: string): Bucket[] { + const data = JSON.parse(json || "{}") as { + Buckets?: { Name: string; CreationDate?: string; BucketRegion?: string }[]; + }; + return (data.Buckets ?? []).map((b) => ({ + name: b.Name, + created: b.CreationDate ? new Date(b.CreationDate) : undefined, + region: b.BucketRegion || undefined, + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface S3Entry { + key: string; + folder: boolean; + size?: number; + modified?: Date; + storageClass?: string; +} + +export function parseObjects(json: string): S3Entry[] { + const data = JSON.parse(json || "{}") as { + CommonPrefixes?: { Prefix: string }[]; + Contents?: { Key: string; Size?: number; LastModified?: string; StorageClass?: string }[]; + }; + return [ + ...(data.CommonPrefixes ?? []).map((p) => ({ key: p.Prefix, folder: true })), + ...(data.Contents ?? []).filter((o) => !o.Key.endsWith("/")).map((o) => ({ + key: o.Key, + folder: false, + size: o.Size, + modified: o.LastModified ? new Date(o.LastModified) : undefined, + storageClass: o.StorageClass, + })), + ]; +} + +/** `logs/2026/app.log` under `logs/` → `2026/app.log`. */ +export const relativeKey = (key: string, prefix: string) => key.startsWith(prefix) ? key.slice(prefix.length) : key; + +// --- CloudWatch Logs ----------------------------------------------------------------- + +export interface LogGroup { + name: string; + region: string; + storedBytes: number; + retentionDays?: number; + created?: Date; + className?: string; + arn?: string; +} + +export function parseLogGroups(json: string, region: string): LogGroup[] { + const data = JSON.parse(json || "{}") as { logGroups?: any[] }; + return (data.logGroups ?? []).map((g) => ({ + name: g.logGroupName, + region, + storedBytes: g.storedBytes ?? 0, + retentionDays: g.retentionInDays, + created: g.creationTime ? new Date(g.creationTime) : undefined, + className: g.logGroupClass, + arn: g.logGroupArn ?? g.arn?.replace(/:\*$/, ""), + })); +} + +/** `/aws/lambda/checkout` → `Lambda`; the kind of resource writing the group. */ +export function logSource(name: string): string { + const m = /^\/aws\/([^/]+)/.exec(name); + if (!m) return "Custom"; + const known: Record = { + lambda: "Lambda", + ecs: "ECS", + eks: "EKS", + rds: "RDS", + apigateway: "API Gateway", + codebuild: "CodeBuild", + "vendedlogs": "Vended", + events: "EventBridge", + "api-gateway": "API Gateway", + }; + return known[m[1]] ?? m[1]; +} + +// --- Console URLs --------------------------------------------------------------------- + +export const ec2ConsoleUrl = (i: Instance) => + `https://${i.region}.console.aws.amazon.com/ec2/home?region=${i.region}#InstanceDetails:instanceId=${i.id}`; + +export const s3ConsoleUrl = (bucket: string, prefix = "", region?: string) => + `https://s3.console.aws.amazon.com/s3/buckets/${encodeURIComponent(bucket)}?${ + region ? `region=${region}&` : "" + }prefix=${encodeURIComponent(prefix)}&showversions=false`; + +/** The CloudWatch console escapes the group name twice, with `$` for `%`. */ +export const logGroupConsoleUrl = (g: { name: string; region: string }) => + `https://${g.region}.console.aws.amazon.com/cloudwatch/home?region=${g.region}#logsV2:log-groups/log-group/${ + encodeURIComponent(encodeURIComponent(g.name)).replace(/%/g, "$") + }`; + +export const consoleHome = (region: string) => `https://${region}.console.aws.amazon.com/console/home?region=${region}`; + +// --- Errors --------------------------------------------------------------------------- + +export type Problem = "credentials" | "expired" | "denied" | "network" | "profile" | "plugin" | "other"; + +export interface Classified { + problem: Problem; + title: string; + message: string; +} + +export function classifyError(err: unknown): Classified { + const message = (err instanceof Error ? err.message : String(err)).replace(/^aws exited with \d+: /, "").trim(); + const is = (re: RegExp) => re.test(message); + if (is(/SessionManagerPlugin is not found|session-manager-plugin/i)) { + return { problem: "plugin", title: "The Session Manager plugin is missing", message }; + } + if ( + is( + /Token has expired|SSO session .*expired|ExpiredToken|Error loading SSO Token|token .* expired|refresh failed|UnauthorizedSSOToken/i, + ) + ) { + return { problem: "expired", title: "Session expired", message }; + } + if (is(/Unable to locate credentials|NoCredentialProviders|You must specify a region|partial credentials/i)) { + return { problem: "credentials", title: "No credentials for this profile", message }; + } + if (is(/config profile \(.+\) could not be found/i)) { + return { problem: "profile", title: "Profile not found", message }; + } + if (is(/InvalidClientTokenId|SignatureDoesNotMatch|AuthFailure|security token included in the request is invalid/i)) { + return { problem: "credentials", title: "AWS rejected the credentials", message }; + } + if (is(/UnauthorizedOperation|AccessDenied|not authorized to perform/i)) { + return { problem: "denied", title: "Access denied", message }; + } + if ( + is( + /Could not connect to the endpoint|Connect timeout|Read timeout|Name or service not known|EndpointConnectionError/i, + ) + ) { + return { problem: "network", title: "Cannot reach AWS", message }; + } + return { problem: "other", title: "The aws command failed", message }; +} + +// --- Formatting ------------------------------------------------------------------------- + +export function bytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB", "PB"]; + let v = n; + let i = -1; + do { + v /= 1024; + i++; + } while (v >= 1024 && i < units.length - 1); + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) 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`; +} + +export const formatDate = (d?: Date) => + d && !isNaN(d.getTime()) ? d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : ""; + +// --- Installing the CLI ------------------------------------------------------------------ + +export type Family = "debian" | "fedora" | "arch" | "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 function installCommand(family: Family | undefined): string { + switch (family) { + case "arch": + return "sudo pacman -S aws-cli-v2"; + case "fedora": + return "sudo dnf install awscli2"; + case "suse": + return "sudo zypper install aws-cli"; + default: + return "sudo snap install aws-cli --classic"; + } +} diff --git a/extensions/aws/lib_test.ts b/extensions/aws/lib_test.ts new file mode 100644 index 0000000..13e668b --- /dev/null +++ b/extensions/aws/lib_test.ts @@ -0,0 +1,212 @@ +import { assertEquals } from "@std/assert"; +import { + activeProfile, + age, + bytes, + classifyError, + filterInstances, + installCommand, + logGroupConsoleUrl, + logSource, + maskKey, + osFamily, + parseBuckets, + parseIni, + parseInstances, + parseLogGroups, + parseObjects, + parseProfiles, + regionsFor, + relativeKey, + s3ConsoleUrl, + sortInstances, +} from "./lib.ts"; +import { holdOpen, terminalArgs } from "./terminal.ts"; + +const CONFIG = ` +[default] +region = eu-west-3 +output = json + +[profile prod] +sso_session = acme +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[sso-session acme] +sso_start_url = https://acme.awsapps.com/start +sso_region = eu-west-1 + +[profile ci] +role_arn = arn:aws:iam::210987654321:role/ci-deploy +source_profile = default +s3 = + max_concurrent_requests = 20 + +[services local] +s3 = + endpoint_url = http://localhost:4566 +`; + +const CREDENTIALS = ` +[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +[temp] +aws_access_key_id = ASIAXXXXXXXXXXXX1234 +aws_secret_access_key = x +aws_session_token = y +`; + +Deno.test("ini: nested values and sections", () => { + const ini = parseIni(CONFIG); + assertEquals(ini.get("profile ci")?.s3, "max_concurrent_requests = 20"); + assertEquals(ini.get("sso-session acme")?.sso_region, "eu-west-1"); +}); + +Deno.test("profiles from config and credentials", () => { + const profiles = parseProfiles(CONFIG, CREDENTIALS); + assertEquals(profiles.map((p) => [p.name, p.kind]), [ + ["default", "keys"], + ["ci", "role"], + ["prod", "sso"], + ["temp", "keys"], + ]); + const prod = profiles.find((p) => p.name === "prod")!; + assertEquals(prod.ssoStartUrl, "https://acme.awsapps.com/start"); + // Falls back to the SSO region when the profile has none. + assertEquals(prod.region, "eu-west-1"); + assertEquals(profiles[0].files, ["config", "credentials"]); + assertEquals(profiles[0].accessKeyId, "AKIAIOSFODNN7EXAMPLE"); + assertEquals(profiles.find((p) => p.name === "temp")!.temporary, true); + // The secret never makes it into a profile. + assertEquals(JSON.stringify(profiles).includes("wJalrXUtnFEMI"), false); + assertEquals(maskKey("AKIAIOSFODNN7EXAMPLE"), "AKIA…MPLE"); +}); + +Deno.test("active profile: picked, then $AWS_PROFILE, then default", () => { + const profiles = parseProfiles(CONFIG, CREDENTIALS); + assertEquals(activeProfile(profiles, "prod", "ci"), "prod"); + assertEquals(activeProfile(profiles, "gone", "ci"), "ci"); + assertEquals(activeProfile(profiles, undefined, undefined), "default"); + assertEquals(activeProfile(parseProfiles("[profile a]\nregion=x\n", ""), undefined, undefined), "a"); + assertEquals(activeProfile([], undefined, undefined), undefined); +}); + +Deno.test("regions preference", () => { + assertEquals(regionsFor("eu-west-3, us-east-1 eu-west-3", "x"), ["eu-west-3", "us-east-1"]); + assertEquals(regionsFor("", "ap-south-1"), ["ap-south-1"]); + assertEquals(regionsFor("nonsense", undefined, "us-gov-west-1"), ["us-gov-west-1"]); + assertEquals(regionsFor(undefined), ["us-east-1"]); +}); + +const INSTANCES = JSON.stringify({ + Reservations: [{ + Instances: [ + { + InstanceId: "i-0a1b2c3d4e5f60718", + InstanceType: "t3.medium", + LaunchTime: "2026-10-20T08:12:00+00:00", + Placement: { AvailabilityZone: "eu-west-3a" }, + PrivateIpAddress: "10.0.1.12", + PublicIpAddress: "15.236.10.4", + PublicDnsName: "", + State: { Code: 16, Name: "running" }, + PlatformDetails: "Linux/UNIX", + SecurityGroups: [{ GroupName: "web", GroupId: "sg-1" }], + Tags: [{ Key: "Name", Value: "web-1" }, { Key: "env", Value: "prod" }], + IamInstanceProfile: { Arn: "arn:aws:iam::123456789012:instance-profile/web-role" }, + }, + { InstanceId: "i-2", InstanceType: "m6i.large", State: { Name: "stopped" }, Platform: "windows" }, + { InstanceId: "i-3", InstanceType: "t4g.nano", State: { Name: "pending" }, Tags: [{ Key: "Name", Value: "a" }] }, + ], + }], +}); + +Deno.test("EC2 instances", () => { + const all = parseInstances(INSTANCES, "eu-west-3"); + assertEquals(all[0].name, "web-1"); + assertEquals(all[0].publicDns, undefined); + assertEquals(all[0].iamProfile, "web-role"); + assertEquals(all[0].tags.env, "prod"); + assertEquals(all[1].platform, "Windows"); + assertEquals(sortInstances(all).map((i) => i.id), ["i-0a1b2c3d4e5f60718", "i-3", "i-2"]); + assertEquals(filterInstances(all, "running").map((i) => i.id), ["i-0a1b2c3d4e5f60718", "i-3"]); + assertEquals(filterInstances(all, "stopped").map((i) => i.id), ["i-2"]); + assertEquals(parseInstances("", "x"), []); +}); + +Deno.test("S3 buckets and objects", () => { + const buckets = parseBuckets(JSON.stringify({ + Buckets: [ + { Name: "zeta", CreationDate: "2024-03-01T10:00:00+00:00" }, + { Name: "acme", CreationDate: "2023-01-15T10:00:00+00:00", BucketRegion: "eu-west-3" }, + ], + })); + assertEquals(buckets.map((b) => [b.name, b.region]), [["acme", "eu-west-3"], ["zeta", undefined]]); + const objects = parseObjects(JSON.stringify({ + CommonPrefixes: [{ Prefix: "site/img/" }], + Contents: [{ Key: "site/", Size: 0 }, { Key: "site/index.html", Size: 5120, StorageClass: "STANDARD" }], + })); + assertEquals(objects.map((o) => [o.key, o.folder]), [["site/img/", true], ["site/index.html", false]]); + assertEquals(relativeKey("site/img/", "site/"), "img/"); + assertEquals( + s3ConsoleUrl("acme", "site/", "eu-west-3"), + "https://s3.console.aws.amazon.com/s3/buckets/acme?region=eu-west-3&prefix=site%2F&showversions=false", + ); +}); + +Deno.test("CloudWatch log groups", () => { + const groups = parseLogGroups( + JSON.stringify({ + logGroups: [{ + logGroupName: "/aws/lambda/checkout", + creationTime: 1700000000000, + retentionInDays: 14, + storedBytes: 52428800, + arn: "arn:aws:logs:eu-west-3:123:log-group:/aws/lambda/checkout:*", + }, { logGroupName: "app/api" }], + }), + "eu-west-3", + ); + assertEquals(groups[0].arn, "arn:aws:logs:eu-west-3:123:log-group:/aws/lambda/checkout"); + assertEquals(groups[1].storedBytes, 0); + assertEquals(logSource(groups[0].name), "Lambda"); + assertEquals(logSource("app/api"), "Custom"); + assertEquals( + logGroupConsoleUrl(groups[0]), + "https://eu-west-3.console.aws.amazon.com/cloudwatch/home?region=eu-west-3#logsV2:log-groups/log-group/$252Faws$252Flambda$252Fcheckout", + ); +}); + +Deno.test("errors are classified with a way out", () => { + const c = (m: string) => classifyError(new Error(`aws exited with 255: ${m}`)).problem; + assertEquals(c("Error when retrieving token from sso: Token has expired and refresh failed"), "expired"); + assertEquals( + c('Unable to locate credentials. You can configure credentials by running "aws configure".'), + "credentials", + ); + assertEquals(c("The config profile (nope) could not be found"), "profile"); + assertEquals(c("An error occurred (UnauthorizedOperation) when calling the DescribeInstances operation"), "denied"); + assertEquals(c('Could not connect to the endpoint URL: "https://ec2.eu-west-3.amazonaws.com/"'), "network"); + assertEquals(c("SessionManagerPlugin is not found. Please refer to SessionManager Documentation"), "plugin"); + assertEquals(classifyError(new Error("aws exited with 1: boom")).message, "boom"); +}); + +Deno.test("formatting", () => { + assertEquals(bytes(512), "512 B"); + assertEquals(bytes(52428800), "50.0 MB"); + assertEquals(bytes(1536 * 1024 * 1024 * 1024), "1.5 TB"); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals(age(new Date("2026-10-20T08:12:00Z"), now), "8d"); + assertEquals(age(new Date("2026-10-28T11:30:00Z"), now), "30m"); + assertEquals(age(undefined, now), ""); +}); + +Deno.test("install hints and terminals", () => { + assertEquals(installCommand(osFamily("ID=arch\n")), "sudo pacman -S aws-cli-v2"); + assertEquals(installCommand(osFamily('ID=ubuntu\nID_LIKE="debian"\n')), "sudo snap install aws-cli --classic"); + assertEquals(terminalArgs("konsole", ["aws", "sso", "login"]), ["-e", "aws", "sso", "login"]); + assertEquals(holdOpen(["aws", "sso", "login"])[0], "sh"); +}); diff --git a/extensions/aws/terminal.ts b/extensions/aws/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/aws/terminal.ts @@ -0,0 +1,89 @@ +// 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]; + } +} + +/** + * Wraps a command that ends by itself (a log dump, a login) so the terminal + * stays open on its output until the user presses Enter. + */ +export function holdOpen(argv: string[]): string[] { + return ["sh", "-c", `${shellLine(argv)}; printf '\\n[Press Enter to close] '; read -r _`]; +} + +/** + * Starts `argv` in a new terminal window that outlives the command. `env` is + * added to the terminal's environment (secrets go there, never in `argv`). + */ +export function runInTerminal(argv: string[], preferred?: string, env?: Record): 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", + env, + }).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/cloudflare/api.ts b/extensions/cloudflare/api.ts new file mode 100644 index 0000000..035000a --- /dev/null +++ b/extensions/cloudflare/api.ts @@ -0,0 +1,120 @@ +// Cloudflare API v4 with an API token, and the "connect" row shown without one. + +import { action, copy, Icons, type Item, open, refresh } from "@backslash/api"; +import { apiError, type Envelope, hasNextPage, mapZone, type Zone } from "./lib.ts"; + +export const API = "https://api.cloudflare.com/client/v4"; +export const TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens"; + +export class AuthError extends Error {} + +/** The token from the preference, else `$CLOUDFLARE_API_TOKEN`. */ +export function token(preferences: Record): string { + const t = String(preferences.token ?? "").trim() || Deno.env.get("CLOUDFLARE_API_TOKEN")?.trim() || ""; + if (!t) throw new AuthError("Connect your Cloudflare account"); + return t; +} + +export async function cf( + tok: 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 ${tok}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const body = await res.json().catch(() => undefined) as Envelope | undefined; + if (res.status === 401 || body?.errors?.some((e) => e.code === 10000 || e.code === 6003 || e.code === 9109)) { + throw new AuthError("Cloudflare rejected the API token"); + } + if (!res.ok || !body?.success) { + const msg = apiError(body, res.status); + if (res.status === 403) throw new Error(`${msg}. The token lacks a permission for this (see its scopes)`); + throw new Error(msg); + } + return body; +} + +/** Every page of a list endpoint (up to `max` items). */ +export async function cfAll( + tok: string, + path: string, + signal?: AbortSignal, + perPage = 50, + max = 2000, +): Promise { + const out: T[] = []; + for (let page = 1; out.length < max; page++) { + const sep = path.includes("?") ? "&" : "?"; + const res = await cf(tok, `${path}${sep}page=${page}&per_page=${perPage}`, { signal }); + out.push(...res.result); + if (!hasNextPage(res.result_info) || !res.result.length) break; + } + return out; +} + +export async function zones(tok: string, signal?: AbortSignal): Promise { + return (await cfAll(tok, "/zones?order=name", signal)).map(mapZone); +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "Create an API token, then paste it in the extension's preferences", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Cloudflare", + "", + "1. Open **My Profile → API Tokens** (Enter) and **Create Token** → **Create Custom Token** with:", + " - Zone → **Zone** → Read", + " - Zone → **DNS** → Edit", + " - Zone → **Cache Purge** → Purge", + " - Account → **Cloudflare Pages** → Read (Edit to retry deployments)", + " - Account → **Account Settings** → Read", + "2. Paste it in **Settings → Extensions → Cloudflare → Configure → API token**", + " (or export `CLOUDFLARE_API_TOKEN` before starting Backslash).", + ].join("\n"), + }, + actions: [ + open(TOKEN_URL, "Create API Token", { icon: Icons.key }), + copy("Zone:Read, DNS:Edit, Cache Purge:Purge, Pages:Read, Account Settings:Read", "Copy Needed Permissions", { + section: "Copy", + }), + ], + }; +} + +export function errorItem(err: unknown): Item { + const message = err instanceof Error ? err.message : String(err); + return { + id: "error", + title: /fetch|network|dns|connect/i.test(message) ? "Cannot reach Cloudflare" : "Cloudflare failed", + subtitle: message, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`\`\`\n${message}\n\`\`\`` }, + actions: [ + action("Retry", () => refresh(), { icon: Icons.refresh }), + open("https://www.cloudflarestatus.com", "Open Cloudflare Status", { icon: Icons.activity }), + copy(message, "Copy Error", { section: "Copy" }), + ], + }; +} + +/** Turns a missing token or an API failure into one helpful row. */ +export async function guarded(load: () => Promise, signal?: AbortSignal): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + if (signal?.aborted) throw e; + return [errorItem(e)]; + } +} diff --git a/extensions/cloudflare/backslash.toml b/extensions/cloudflare/backslash.toml new file mode 100644 index 0000000..3d6c32d --- /dev/null +++ b/extensions/cloudflare/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "cloudflare" +title = "Cloudflare" +description = "Manage Cloudflare zones and DNS records, purge the cache and follow Pages deployments" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +[permissions] +net = ["api.cloudflare.com"] +env = ["CLOUDFLARE_API_TOKEN"] + +[[commands]] +name = "zones" +title = "Cloudflare Zones" +description = "Your domains with their plan and status: DNS, cache purge, dashboard" +icon = "bs-globe" +keywords = ["cloudflare", "zone", "domain", "cdn", "cache", "purge", "domaine", "vider le cache"] + +[[commands]] +name = "dns" +title = "Cloudflare DNS Records" +description = "Records of all your zones: filter by type or proxy, copy, add, proxy, delete" +icon = "bs-globe" +keywords = ["cloudflare", "dns", "record", "a", "cname", "mx", "txt", "proxy", "enregistrement", "domaine"] + +[[commands]] +name = "pages" +title = "Cloudflare Pages" +description = "Pages projects with their latest deployment: open, deployments, retry" +icon = "bs-rocket" +keywords = ["cloudflare", "pages", "deploy", "deployment", "site", "static", "build", "déploiement"] + +[[preferences]] +name = "token" +title = "API token" +description = "dash.cloudflare.com → My Profile → API Tokens: Zone Read, DNS Edit, Cache Purge, Pages Read, Account Settings Read. Or set CLOUDFLARE_API_TOKEN" +type = "secret" diff --git a/extensions/cloudflare/commands/dns.ts b/extensions/cloudflare/commands/dns.ts new file mode 100644 index 0000000..3b40aef --- /dev/null +++ b/extensions/cloudflare/commands/dns.ts @@ -0,0 +1,6 @@ +import type { Context } from "@backslash/api"; +import { token } from "../api.ts"; +import { dnsView } from "../views.ts"; + +// Records of every zone the token sees, grouped by zone. +export default (ctx: Context) => dnsView(() => token(ctx.preferences)); diff --git a/extensions/cloudflare/commands/pages.ts b/extensions/cloudflare/commands/pages.ts new file mode 100644 index 0000000..0999ecf --- /dev/null +++ b/extensions/cloudflare/commands/pages.ts @@ -0,0 +1,166 @@ +import { + action, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { cf, cfAll, guarded, token } from "../api.ts"; +import { + age, + DEPLOY_COLORS, + DEPLOY_LABELS, + type Deployment, + deploymentDashboardUrl, + deployState, + formatDate, + mapDeployment, + mapProject, + pagesDashboardUrl, + type PagesProject, +} from "../lib.ts"; + +const STAGES: Record = { + queued: "Queued", + initialize: "Initializing", + clone_repo: "Cloning", + build: "Building", + deploy: "Deploying", +}; + +function deploymentMeta(d: Deployment) { + const state = deployState(d); + return [ + meta.text("State", `${DEPLOY_LABELS[state]}${state === "building" ? ` · ${STAGES[d.stage] ?? d.stage}` : ""}`, { + icon: Icons.dot, + color: DEPLOY_COLORS[state], + }), + meta.tags("Environment", [{ text: d.environment, color: d.environment === "production" ? "green" : "blue" }]), + ...(d.branch ? [meta.text("Branch", d.branch, { icon: Icons.branch })] : []), + ...(d.commit + ? [meta.text("Commit", `${d.commit}${d.message ? ` ${d.message}` : ""}`, { icon: Icons.commit })] + : []), + ...(d.url ? [meta.link("Preview", d.url.replace(/^https:\/\//, ""), d.url)] : []), + ...(d.trigger ? [meta.text("Trigger", d.trigger.replace(/_/g, " "), { icon: Icons.bolt })] : []), + ...(d.created ? [meta.text("Created", formatDate(d.created), { icon: Icons.calendar })] : []), + ]; +} + +function retry(tok: string, p: PagesProject, d: Deployment) { + return action("Retry Deployment", async () => { + await cf(tok, `/accounts/${p.accountId}/pages/projects/${p.name}/deployments/${d.id}/retry`, { method: "POST" }); + await toast(`Retrying ${p.name} ${d.shortId}`, { style: "success" }); + return refresh(); + }, { icon: Icons.refresh, shortcut: "r", section: "Deployment" }); +} + +function deploymentsView(tok: string, p: PagesProject): ListSpec { + return list({ + title: `${p.name} · deployments`, + placeholder: "Filter deployments by branch, commit, environment…", + empty: { title: "No deployments", icon: Icons.rocket }, + status: (items) => `${items.length} recent deployments of ${p.name}`, + items: ({ signal }) => + guarded(async () => { + const all = + (await cfAll(tok, `/accounts/${p.accountId}/pages/projects/${p.name}/deployments`, signal, 25, 50)) + .map(mapDeployment); + return all.map((d) => { + const state = deployState(d); + return { + id: d.id, + title: d.message ?? d.shortId, + subtitle: [d.branch, d.commit].filter(Boolean).join(" · "), + icon: { source: Icons.rocket, tint: DEPLOY_COLORS[state] }, + section: d.environment === "production" ? "Production" : "Preview", + keywords: [d.shortId, d.environment, d.branch ?? "", d.commit ?? ""], + accessories: [ + ...(d.created ? [{ text: age(d.created), tooltip: formatDate(d.created) }] : []), + { tag: DEPLOY_LABELS[state], color: DEPLOY_COLORS[state] }, + ], + detail: { metadata: deploymentMeta(d) }, + actions: [ + ...(d.url ? [open(d.url, "Open Deployment", { icon: Icons.globe })] : []), + open(deploymentDashboardUrl(p, d), "Open in Dashboard", { icon: Icons.external, shortcut: "o" }), + ...(d.url ? [copy(d.url, "Copy Deployment URL", { shortcut: "c", section: "Copy" })] : []), + copy(d.id, "Copy Deployment ID", { section: "Copy" }), + ...(state === "failure" || state === "canceled" ? [retry(tok, p, d)] : []), + ], + } satisfies Item; + }); + }, signal), + }); +} + +function item(tok: string, p: PagesProject): Item { + const d = p.latest; + const state = deployState(d); + const color = DEPLOY_COLORS[state]; + const site = `https://${p.domains[0] ?? p.subdomain}`; + return { + id: `${p.accountId}/${p.name}`, + title: p.name, + subtitle: p.domains[0] ?? p.subdomain, + icon: { source: Icons.rocket, tint: color }, + section: state === "failure" ? "Failed" : state === "building" || state === "queued" ? "Building" : "Projects", + keywords: [p.subdomain, ...p.domains, p.repo ?? "", d?.branch ?? ""], + accessories: [ + ...(d?.branch ? [{ icon: Icons.branch, text: d.branch, tooltip: "Branch" }] : []), + ...(d?.created ? [{ text: age(d.created), tooltip: `Deployed ${formatDate(d.created)}` }] : []), + { tag: DEPLOY_LABELS[state], color }, + ], + detail: { + markdown: d?.message ? `**Latest:** ${d.message}` : undefined, + metadata: [ + ...(d ? deploymentMeta(d) : [meta.text("State", "No deployment yet", { icon: Icons.dot, color: "gray" })]), + meta.separator(), + meta.link("Site", site.replace(/^https:\/\//, ""), site), + ...(p.domains.length > 1 ? [meta.tags("Domains", p.domains)] : []), + ...(p.repo ? [meta.link("Repository", p.repo, `https://github.com/${p.repo}`)] : []), + ...(p.productionBranch ? [meta.text("Production branch", p.productionBranch, { icon: Icons.branch })] : []), + ], + }, + actions: [ + open(site, "Open Site", { icon: Icons.globe }), + action("Show Deployments", () => deploymentsView(tok, p), { icon: Icons.history, shortcut: "d" }), + open(pagesDashboardUrl(p), "Open in Dashboard", { icon: Icons.external, shortcut: "o" }), + ...(d?.url ? [open(d.url, "Open Latest Deployment", { icon: Icons.rocket, section: "Open" })] : []), + copy(site, "Copy Site URL", { shortcut: "c", section: "Copy" }), + ...(d?.url ? [copy(d.url, "Copy Deployment URL", { section: "Copy" })] : []), + ...(d && (state === "failure" || state === "canceled") ? [retry(tok, p, d)] : []), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter Pages projects…", + empty: { title: "No Pages projects", description: "This token sees no Cloudflare Pages project", icon: Icons.rocket }, + status: () => summary, + items: ({ preferences, signal }) => + guarded(async () => { + summary = ""; + const tok = token(preferences); + const accounts = await cfAll<{ id: string; name: string }>(tok, "/accounts", signal); + const projects = (await Promise.all(accounts.map(async (a) => + (await cfAll(tok, `/accounts/${a.id}/pages/projects`, signal, 10, 200)).map((p) => mapProject(p, a.id)) + ))).flat(); + const failed = projects.filter((p) => + deployState(p.latest) === "failure" + ).length; + summary = `${projects.length} project${projects.length === 1 ? "" : "s"}${failed ? ` · ${failed} failed` : ""}`; + await updateCommandMetadata({ subtitle: failed ? `${failed} failed` : null }).catch(() => {}); + const order = ["Failed", "Building", "Projects"]; + return projects.map((p) => + item(tok, p) + ).sort((a, b) => order.indexOf(a.section!) - order.indexOf(b.section!) || a.title.localeCompare(b.title)); + }, signal), +}); diff --git a/extensions/cloudflare/commands/zones.ts b/extensions/cloudflare/commands/zones.ts new file mode 100644 index 0000000..9242a83 --- /dev/null +++ b/extensions/cloudflare/commands/zones.ts @@ -0,0 +1,71 @@ +import { action, copy, Icons, type Item, list, meta, open, refresh, updateCommandMetadata } from "@backslash/api"; +import { guarded, token, zones } from "../api.ts"; +import { dnsDashboardUrl, formatDate, type Zone, zoneColor, zoneDashboardUrl, zoneStatusLabel } from "../lib.ts"; +import { dnsView, purgeActions, recordForm } from "../views.ts"; + +function item(z: Zone, tok: string, prefs: Record): Item { + const color = zoneColor(z); + const devMode = z.developmentMode > 0; + return { + id: z.id, + title: z.name, + subtitle: z.plan, + icon: { source: Icons.globe, tint: color }, + section: z.accountName || undefined, + keywords: [z.status, z.plan, z.accountName, ...z.nameServers], + accessories: [ + ...(devMode ? [{ tag: "Dev mode", color: "purple" as const }] : []), + ...(z.type !== "full" ? [{ text: z.type === "partial" ? "CNAME setup" : z.type, tooltip: "Setup" }] : []), + { tag: zoneStatusLabel(z), color }, + ], + detail: { + metadata: [ + meta.text("Status", zoneStatusLabel(z), { icon: Icons.dot, color }), + meta.tags("Plan", [{ text: z.plan || "Unknown", color: /free/i.test(z.plan) ? "gray" : "blue" }]), + ...(z.nameServers.length ? [meta.tags("Name servers", z.nameServers)] : []), + ...(z.originalRegistrar ? [meta.text("Registrar", z.originalRegistrar, { icon: Icons.home })] : []), + ...(devMode + ? [meta.text("Development mode", `${Math.round(z.developmentMode / 60)} min left`, { + icon: Icons.code, + color: "purple", + })] + : []), + meta.separator(), + meta.text("Account", z.accountName, { icon: Icons.person }), + meta.text("Zone ID", z.id, { icon: Icons.hash }), + ...(z.activated ? [meta.text("Active since", formatDate(z.activated), { icon: Icons.calendar })] : []), + ], + }, + actions: [ + action("Show DNS Records", () => dnsView(() => token(prefs), z), { icon: Icons.globe }), + open(zoneDashboardUrl(z), "Open in Dashboard", { icon: Icons.external, shortcut: "o" }), + action("Add DNS Record", () => recordForm(tok, z), { icon: Icons.plus, shortcut: "n" }), + ...purgeActions(tok, z), + open(dnsDashboardUrl(z), "Open DNS in Dashboard", { icon: Icons.globe, section: "Open" }), + copy(z.id, "Copy Zone ID", { shortcut: "period", section: "Copy" }), + copy(z.name, "Copy Domain", { section: "Copy" }), + ...(z.nameServers.length + ? [copy(z.nameServers.join("\n"), "Copy Name Servers", { icon: Icons.server, section: "Copy" })] + : []), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter zones by domain, plan, status…", + empty: { title: "No zones", description: "This token sees no zone: check its Zone resources", icon: Icons.globe }, + status: () => summary, + items: ({ preferences, signal }) => + guarded(async () => { + summary = ""; + const tok = token(preferences); + const all = await zones(tok, signal); + const pending = all.filter((z) => z.status === "pending").length; + summary = `${all.length} zone${all.length === 1 ? "" : "s"}${pending ? ` · ${pending} pending` : ""}`; + await updateCommandMetadata({ subtitle: summary }).catch(() => {}); + return all.map((z) => item(z, tok, preferences)); + }, signal), +}); diff --git a/extensions/cloudflare/lib.ts b/extensions/cloudflare/lib.ts new file mode 100644 index 0000000..1b48ae5 --- /dev/null +++ b/extensions/cloudflare/lib.ts @@ -0,0 +1,362 @@ +// Pure logic of the Cloudflare extension: API response mapping, DNS record +// validation and display, Pages states. No I/O here. + +export interface Envelope { + success: boolean; + errors?: { code: number; message: string }[]; + messages?: unknown[]; + result: T; + result_info?: { page: number; per_page: number; total_pages?: number; count: number; total_count?: number }; +} + +/** The first error of a failed response, readable. */ +export function apiError(body: Partial> | undefined, status: number): string { + const e = body?.errors?.[0]; + return e ? `${e.message}${e.code ? ` (${e.code})` : ""}` : `Cloudflare answered ${status}`; +} + +/** Whether there is a page after `info`. */ +export function hasNextPage(info: Envelope["result_info"]): boolean { + if (!info) return false; + if (info.total_pages !== undefined) return info.page < info.total_pages; + return info.count === info.per_page; +} + +// --- Zones ------------------------------------------------------------------------------- + +export interface Zone { + id: string; + name: string; + status: string; + paused: boolean; + type: string; + plan: string; + accountId: string; + accountName: string; + nameServers: string[]; + originalRegistrar?: string; + developmentMode: number; + activated?: Date; +} + +export function mapZone(z: any): Zone { + return { + id: z.id, + name: z.name, + status: z.status ?? "unknown", + paused: !!z.paused, + type: z.type ?? "full", + plan: z.plan?.name ?? "", + accountId: z.account?.id ?? "", + accountName: z.account?.name ?? "", + nameServers: z.name_servers ?? [], + originalRegistrar: z.original_registrar || undefined, + developmentMode: Number(z.development_mode ?? 0), + activated: z.activated_on ? new Date(z.activated_on) : undefined, + }; +} + +export const ZONE_COLORS: Record = { + active: "green", + pending: "yellow", + initializing: "yellow", + moved: "gray", + deactivated: "gray", + deleted: "red", + read_only: "gray", +}; + +export const zoneColor = (z: Zone) => z.paused ? "orange" as const : ZONE_COLORS[z.status] ?? "gray"; +export const zoneStatusLabel = (z: Zone) => z.paused ? "Paused" : capitalize(z.status.replace(/_/g, " ")); + +export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +export const zoneDashboardUrl = (z: Zone) => `https://dash.cloudflare.com/${z.accountId}/${z.name}`; +export const dnsDashboardUrl = (z: Zone) => `https://dash.cloudflare.com/${z.accountId}/${z.name}/dns/records`; + +// --- DNS records --------------------------------------------------------------------------- + +export interface DnsRecord { + id: string; + zoneId: string; + zoneName: string; + name: string; + type: string; + content: string; + proxied: boolean; + proxiable: boolean; + ttl: number; + priority?: number; + comment?: string; + tags: string[]; + modified?: Date; +} + +export function mapRecord(r: any, zone?: { id: string; name: string }): DnsRecord { + return { + id: r.id, + zoneId: r.zone_id ?? zone?.id ?? "", + zoneName: r.zone_name ?? zone?.name ?? "", + name: r.name, + type: r.type, + content: r.content ?? "", + proxied: !!r.proxied, + proxiable: !!r.proxiable, + ttl: Number(r.ttl ?? 1), + priority: typeof r.priority === "number" ? r.priority : undefined, + comment: r.comment || undefined, + tags: r.tags ?? [], + modified: r.modified_on ? new Date(r.modified_on) : undefined, + }; +} + +export const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "PTR", "HTTPS"]; + +/** Types Cloudflare can proxy. */ +export const PROXIABLE = ["A", "AAAA", "CNAME"]; + +export type RecordFilter = "all" | "address" | "cname" | "mx" | "txt" | "proxied" | "dns-only"; + +export function filterRecords(all: DnsRecord[], filter: RecordFilter): DnsRecord[] { + switch (filter) { + case "address": + return all.filter((r) => r.type === "A" || r.type === "AAAA"); + case "cname": + return all.filter((r) => r.type === "CNAME"); + case "mx": + return all.filter((r) => r.type === "MX"); + case "txt": + return all.filter((r) => r.type === "TXT"); + case "proxied": + return all.filter((r) => r.proxied); + case "dns-only": + return all.filter((r) => r.proxiable && !r.proxied); + default: + return all; + } +} + +const TYPE_ORDER = ["A", "AAAA", "CNAME", "MX", "TXT"]; + +export function sortRecords(all: DnsRecord[]): DnsRecord[] { + const rank = (t: string) => (TYPE_ORDER.indexOf(t) + 1) || 99; + return [...all].sort((a, b) => + a.zoneName.localeCompare(b.zoneName) || + // The apex first, then by name. + Number(b.name === b.zoneName) - Number(a.name === a.zoneName) || + a.name.localeCompare(b.name) || + rank(a.type) - rank(b.type) || + (a.priority ?? 0) - (b.priority ?? 0) + ); +} + +/** `www.example.com` in `example.com` → `www`; the apex → `@`. */ +export function shortName(name: string, zone: string): string { + if (name === zone) return "@"; + return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name; +} + +export const ttlLabel = (ttl: number) => + ttl === 1 + ? "Auto" + : ttl % 86400 === 0 + ? `${ttl / 86400} d` + : ttl % 3600 === 0 + ? `${ttl / 3600} h` + : ttl % 60 === 0 + ? `${ttl / 60} min` + : `${ttl} s`; + +/** A BIND zone file line. */ +export function zoneFileLine(r: DnsRecord): string { + const ttl = r.ttl === 1 ? 300 : r.ttl; + const content = r.type === "TXT" && !r.content.startsWith('"') ? `"${r.content.replace(/"/g, '\\"')}"` : r.content; + const fqdn = (s: string) => + /^[\w.-]+$/.test(s) && ["CNAME", "MX", "NS", "PTR"].includes(r.type) && !s.endsWith(".") ? `${s}.` : s; + return [`${r.name}.`, ttl, "IN", r.type, ...(r.priority !== undefined ? [r.priority] : []), fqdn(content)].join("\t"); +} + +export const TYPE_COLORS: Record = { + A: "blue", + AAAA: "purple", + CNAME: "teal", + MX: "orange", + TXT: "green", +}; + +export interface RecordInput { + type?: string; + name?: string; + content?: string; + ttl?: string; + priority?: string; + proxied?: boolean | string; + comment?: string; +} + +/** Validates the form and builds the API body. */ +export function recordBody(v: RecordInput, zone: string): Record { + const type = (v.type ?? "A").toUpperCase(); + if (!RECORD_TYPES.includes(type)) throw new Error(`Unsupported type ${type}`); + let name = (v.name ?? "").trim().replace(/\.$/, ""); + if (!name || name === "@") name = zone; + else if (name !== zone && !name.endsWith(`.${zone}`)) name = `${name}.${zone}`; + const content = (v.content ?? "").trim(); + if (!content) throw new Error("Content is required"); + if (type === "A" && !/^(\d{1,3}\.){3}\d{1,3}$/.test(content)) throw new Error(`“${content}” is not an IPv4 address`); + if (type === "AAAA" && !/^[0-9a-f:]+$/i.test(content)) throw new Error(`“${content}” is not an IPv6 address`); + const ttlText = (v.ttl ?? "").trim().toLowerCase(); + const ttl = !ttlText || ttlText === "auto" ? 1 : Number(ttlText); + if (!Number.isInteger(ttl) || (ttl !== 1 && (ttl < 60 || ttl > 86400))) { + throw new Error("TTL is “auto” or a number of seconds from 60 to 86400"); + } + const proxied = (v.proxied === true || v.proxied === "true") && PROXIABLE.includes(type); + const body: Record = { type, name, content, ttl: proxied ? 1 : ttl, proxied }; + if (type === "MX" || type === "SRV") { + const p = Number((v.priority ?? "").trim() || "10"); + if (!Number.isInteger(p) || p < 0 || p > 65535) throw new Error("Priority is a number from 0 to 65535"); + body.priority = p; + } + const comment = (v.comment ?? "").trim(); + if (comment) body.comment = comment; + return body; +} + +// --- Cache purge ------------------------------------------------------------------------- + +/** URLs from a text area: one per line or separated by spaces, scheme added when missing. */ +export function purgeUrls(text: string, zone: string): string[] { + const urls = text.split(/[\s,]+/).map((u) => u.trim()).filter(Boolean).map((u) => + /^https?:\/\//i.test(u) ? u : `https://${u.startsWith("/") ? zone + u : u}` + ); + for (const u of urls) { + let host: string; + try { + host = new URL(u).hostname; + } catch { + throw new Error(`“${u}” is not a URL`); + } + if (host !== zone && !host.endsWith(`.${zone}`)) throw new Error(`${host} is not in ${zone}`); + } + if (!urls.length) throw new Error("Enter at least one URL"); + return [...new Set(urls)]; +} + +/** Cloudflare accepts 30 URLs per purge request. */ +export function chunks(all: T[], size = 30): T[][] { + const out: T[][] = []; + for (let i = 0; i < all.length; i += size) out.push(all.slice(i, i + size)); + return out; +} + +// --- Pages ---------------------------------------------------------------------------------- + +export interface Deployment { + id: string; + shortId: string; + url: string; + environment: string; + stage: string; + status: string; + branch?: string; + commit?: string; + message?: string; + trigger?: string; + created?: Date; + aliases: string[]; +} + +export interface PagesProject { + name: string; + accountId: string; + subdomain: string; + domains: string[]; + productionBranch?: string; + repo?: string; + created?: Date; + latest?: Deployment; +} + +export function mapDeployment(d: any): Deployment { + const meta = d.deployment_trigger?.metadata ?? {}; + return { + id: d.id, + shortId: d.short_id ?? String(d.id).slice(0, 8), + url: d.url ?? "", + environment: d.environment ?? "production", + stage: d.latest_stage?.name ?? "", + status: d.latest_stage?.status ?? "", + branch: meta.branch || undefined, + commit: meta.commit_hash ? String(meta.commit_hash).slice(0, 7) : undefined, + message: meta.commit_message ? String(meta.commit_message).split("\n")[0] : undefined, + trigger: d.deployment_trigger?.type, + created: d.created_on ? new Date(d.created_on) : undefined, + aliases: d.aliases ?? [], + }; +} + +export function mapProject(p: any, accountId: string): PagesProject { + const src = p.source?.config; + return { + name: p.name, + accountId, + subdomain: p.subdomain ?? `${p.name}.pages.dev`, + domains: (p.domains ?? []).filter((d: string) => !d.endsWith(".pages.dev")), + productionBranch: p.production_branch || undefined, + repo: src?.owner && src?.repo_name ? `${src.owner}/${src.repo_name}` : undefined, + created: p.created_on ? new Date(p.created_on) : undefined, + latest: p.latest_deployment ? mapDeployment(p.latest_deployment) : undefined, + }; +} + +export type DeployState = "success" | "failure" | "building" | "queued" | "canceled" | "unknown"; + +/** One state from the latest stage: a finished deploy is a success, any failed stage a failure. */ +export function deployState(d: Deployment | undefined): DeployState { + if (!d) return "unknown"; + if (d.status === "failure") return "failure"; + if (d.status === "canceled") return "canceled"; + if (d.status === "success" && d.stage === "deploy") return "success"; + if (d.stage === "queued" || (d.status === "idle" && d.stage !== "deploy")) return "queued"; + if (d.status === "active" || d.status === "success" || d.status === "idle") return "building"; + return "unknown"; +} + +export const DEPLOY_COLORS = { + success: "green", + failure: "red", + building: "yellow", + queued: "blue", + canceled: "gray", + unknown: "gray", +} as const; + +export const DEPLOY_LABELS = { + success: "Deployed", + failure: "Failed", + building: "Building", + queued: "Queued", + canceled: "Canceled", + unknown: "No deployment", +}; + +export const pagesDashboardUrl = (p: { accountId: string; name: string }) => + `https://dash.cloudflare.com/${p.accountId}/pages/view/${p.name}`; + +export const deploymentDashboardUrl = (p: { accountId: string; name: string }, d: Deployment) => + `${pagesDashboardUrl(p)}/${d.id}`; + +// --- Formatting ------------------------------------------------------------------------------- + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) 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`; +} + +export const formatDate = (d?: Date) => + d && !isNaN(d.getTime()) ? d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : ""; diff --git a/extensions/cloudflare/lib_test.ts b/extensions/cloudflare/lib_test.ts new file mode 100644 index 0000000..57f3218 --- /dev/null +++ b/extensions/cloudflare/lib_test.ts @@ -0,0 +1,204 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + apiError, + chunks, + deployState, + filterRecords, + hasNextPage, + mapDeployment, + mapProject, + mapRecord, + mapZone, + purgeUrls, + recordBody, + shortName, + sortRecords, + ttlLabel, + zoneColor, + zoneDashboardUrl, + zoneFileLine, + zoneStatusLabel, +} from "./lib.ts"; + +const ZONE = { + id: "023e105f4ecef8ad9ca31a8372d0c353", + name: "acme.io", + status: "active", + paused: false, + type: "full", + development_mode: 0, + name_servers: ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"], + original_registrar: "gandi sas", + account: { id: "a1b2", name: "Acme" }, + plan: { id: "x", name: "Pro Website" }, + activated_on: "2023-04-01T10:00:00Z", +}; + +Deno.test("zones", () => { + const z = mapZone(ZONE); + assertEquals(z.plan, "Pro Website"); + assertEquals(z.nameServers.length, 2); + assertEquals(zoneColor(z), "green"); + assertEquals(zoneStatusLabel(z), "Active"); + assertEquals(zoneDashboardUrl(z), "https://dash.cloudflare.com/a1b2/acme.io"); + const paused = mapZone({ ...ZONE, paused: true }); + assertEquals([zoneColor(paused), zoneStatusLabel(paused)], ["orange", "Paused"]); + assertEquals(zoneColor(mapZone({ ...ZONE, status: "pending" })), "yellow"); +}); + +Deno.test("pagination and errors", () => { + assertEquals(hasNextPage({ page: 1, per_page: 50, count: 50, total_pages: 2 }), true); + assertEquals(hasNextPage({ page: 2, per_page: 50, count: 3, total_pages: 2 }), false); + // Cursor-style endpoints give no total: a full page means there may be more. + assertEquals(hasNextPage({ page: 1, per_page: 10, count: 10 }), true); + assertEquals(hasNextPage(undefined), false); + assertEquals( + apiError({ errors: [{ code: 81057, message: "Record already exists." }] }, 400), + "Record already exists. (81057)", + ); + assertEquals(apiError(undefined, 502), "Cloudflare answered 502"); +}); + +const zone = { id: "z1", name: "acme.io" }; +const RECORDS = [ + { + id: "r3", + name: "acme.io", + type: "MX", + content: "mx2.mail.example", + priority: 20, + proxiable: false, + proxied: false, + ttl: 3600, + }, + { id: "r1", name: "acme.io", type: "A", content: "203.0.113.10", proxiable: true, proxied: true, ttl: 1 }, + { id: "r2", name: "www.acme.io", type: "CNAME", content: "acme.io", proxiable: true, proxied: true, ttl: 1 }, + { + id: "r5", + name: "acme.io", + type: "MX", + content: "mx1.mail.example", + priority: 10, + proxiable: false, + proxied: false, + ttl: 3600, + }, + { + id: "r4", + name: "acme.io", + type: "TXT", + content: "v=spf1 -all", + proxiable: false, + proxied: false, + ttl: 1, + comment: "SPF", + }, + { id: "r6", name: "ssh.acme.io", type: "A", content: "203.0.113.22", proxiable: true, proxied: false, ttl: 300 }, +].map((r) => mapRecord(r, zone)); + +Deno.test("DNS records: mapping, sort and filters", () => { + assertEquals(RECORDS[0].zoneName, "acme.io"); + assertEquals(RECORDS[4].comment, "SPF"); + assertEquals(sortRecords(RECORDS).map((r) => r.id), ["r1", "r5", "r3", "r4", "r6", "r2"]); + assertEquals(filterRecords(RECORDS, "address").map((r) => r.id), ["r1", "r6"]); + assertEquals(filterRecords(RECORDS, "proxied").map((r) => r.id), ["r1", "r2"]); + assertEquals(filterRecords(RECORDS, "dns-only").map((r) => r.id), ["r6"]); + assertEquals(filterRecords(RECORDS, "mx").length, 2); + assertEquals(shortName("www.acme.io", "acme.io"), "www"); + assertEquals(shortName("acme.io", "acme.io"), "@"); + assertEquals(shortName("other.dev", "acme.io"), "other.dev"); + assertEquals([ttlLabel(1), ttlLabel(300), ttlLabel(3600), ttlLabel(86400), ttlLabel(90)], [ + "Auto", + "5 min", + "1 h", + "1 d", + "90 s", + ]); +}); + +Deno.test("DNS records: zone file lines", () => { + const [mx, a, cname, , txt] = RECORDS; + assertEquals(zoneFileLine(mx), "acme.io.\t3600\tIN\tMX\t20\tmx2.mail.example."); + assertEquals(zoneFileLine(a), "acme.io.\t300\tIN\tA\t203.0.113.10"); + assertEquals(zoneFileLine(cname), "www.acme.io.\t300\tIN\tCNAME\tacme.io."); + assertEquals(zoneFileLine(txt), 'acme.io.\t300\tIN\tTXT\t"v=spf1 -all"'); +}); + +Deno.test("DNS records: form validation", () => { + assertEquals(recordBody({ type: "A", name: "api", content: "198.51.100.7", proxied: true }, "acme.io"), { + type: "A", + name: "api.acme.io", + content: "198.51.100.7", + ttl: 1, + proxied: true, + }); + assertEquals(recordBody({ type: "MX", name: "@", content: "mx.example", proxied: true, ttl: "3600" }, "acme.io"), { + type: "MX", + name: "acme.io", + content: "mx.example", + ttl: 3600, + proxied: false, + priority: 10, + }); + assertEquals( + recordBody({ type: "TXT", name: "_dmarc.acme.io.", content: "v=DMARC1; p=none", comment: " x " }, "acme.io"), + { + type: "TXT", + name: "_dmarc.acme.io", + content: "v=DMARC1; p=none", + ttl: 1, + proxied: false, + comment: "x", + }, + ); + assertThrows(() => recordBody({ type: "A", name: "x", content: "not-an-ip" }, "acme.io"), Error, "IPv4"); + assertThrows(() => recordBody({ type: "A", name: "x", content: "" }, "acme.io"), Error, "Content"); + assertThrows(() => recordBody({ type: "CNAME", name: "x", content: "y", ttl: "5" }, "acme.io"), Error, "TTL"); + assertThrows(() => recordBody({ type: "MX", name: "x", content: "y", priority: "-1" }, "acme.io"), Error, "Priority"); +}); + +Deno.test("cache purge URLs", () => { + assertEquals(purgeUrls("/index.html\nwww.acme.io/a.css https://acme.io/b.js /index.html", "acme.io"), [ + "https://acme.io/index.html", + "https://www.acme.io/a.css", + "https://acme.io/b.js", + ]); + assertThrows(() => purgeUrls("https://evil.com/x", "acme.io"), Error, "not in acme.io"); + assertThrows(() => purgeUrls(" ", "acme.io"), Error, "at least one"); + assertEquals(chunks([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); +}); + +const dep = (status: string, stage: string) => ({ + id: "4d1a6f0e-0000-4000-8000-1234567890ab", + short_id: "4d1a6f0e", + url: "https://4d1a6f0e.marketing.pages.dev", + environment: "production", + created_on: "2026-10-28T09:00:00Z", + latest_stage: { name: stage, status }, + deployment_trigger: { + type: "github:push", + metadata: { branch: "main", commit_hash: "abcdef1234567890", commit_message: "Update pricing\n\nbody" }, + }, +}); + +Deno.test("Pages projects and deployment states", () => { + const p = mapProject({ + name: "marketing", + subdomain: "marketing-7x.pages.dev", + domains: ["marketing-7x.pages.dev", "www.acme.io"], + production_branch: "main", + source: { type: "github", config: { owner: "acme", repo_name: "marketing" } }, + latest_deployment: dep("success", "deploy"), + }, "a1b2"); + assertEquals(p.domains, ["www.acme.io"]); + assertEquals(p.repo, "acme/marketing"); + assertEquals(p.latest?.commit, "abcdef1"); + assertEquals(p.latest?.message, "Update pricing"); + assertEquals(deployState(p.latest), "success"); + assertEquals(deployState(mapDeployment(dep("failure", "build"))), "failure"); + assertEquals(deployState(mapDeployment(dep("active", "build"))), "building"); + assertEquals(deployState(mapDeployment(dep("success", "build"))), "building"); + assertEquals(deployState(mapDeployment(dep("idle", "queued"))), "queued"); + assertEquals(deployState(mapDeployment(dep("canceled", "build"))), "canceled"); + assertEquals(deployState(undefined), "unknown"); +}); diff --git a/extensions/cloudflare/views.ts b/extensions/cloudflare/views.ts new file mode 100644 index 0000000..6395b58 --- /dev/null +++ b/extensions/cloudflare/views.ts @@ -0,0 +1,263 @@ +// DNS records (with their form), cache purge: shared by the zones and DNS commands. + +import { + type Action, + action, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + pop, + submenu, + toast, +} from "@backslash/api"; +import { cf, cfAll, guarded, zones as loadZones } from "./api.ts"; +import { + chunks, + dnsDashboardUrl, + type DnsRecord, + filterRecords, + formatDate, + mapRecord, + purgeUrls, + RECORD_TYPES, + recordBody, + type RecordFilter, + shortName, + sortRecords, + ttlLabel, + TYPE_COLORS, + type Zone, + zoneFileLine, +} from "./lib.ts"; + +// --- Live reload: forms and actions of this process notify the lists ---------------- + +const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +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); + }); + } +} + +// --- Cache purge --------------------------------------------------------------------- + +export function purgeActions(tok: string, z: Zone): Action[] { + return [ + submenu("Purge Cache", [ + action("Purge URLs…", () => purgeForm(tok, z), { icon: Icons.link }), + action("Purge Everything", async () => { + await cf(tok, `/zones/${z.id}/purge_cache`, { method: "POST", body: { purge_everything: true } }); + await toast(`Purged everything on ${z.name}`, { style: "success", message: "Edge caches refill on demand" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Purge every cached file of ${z.name}? Your origin gets the traffic until caches refill.`, + }), + ], { icon: Icons.bolt, shortcut: "p", section: "Cache" }), + ]; +} + +export function purgeForm(tok: string, z: Zone): FormSpec { + return form({ + title: `Purge URLs on ${z.name}`, + submitTitle: "Purge", + fields: [ + field.textArea("urls", "URLs", { + required: true, + placeholder: `https://${z.name}/styles.css\n/index.html`, + }), + ], + async submit(values) { + const urls = purgeUrls(String(values.urls ?? ""), z.name); + for (const files of chunks(urls)) { + await cf(tok, `/zones/${z.id}/purge_cache`, { method: "POST", body: { files } }); + } + await toast(`Purged ${urls.length} URL${urls.length === 1 ? "" : "s"}`, { style: "success" }); + return pop(); + }, + }); +} + +// --- DNS --------------------------------------------------------------------------------- + +export function recordForm(tok: string, z: Zone, type = "A"): FormSpec { + return form({ + title: `New DNS Record · ${z.name}`, + submitTitle: "Create Record", + fields: [ + field.dropdown("type", "Type", RECORD_TYPES, { value: type }), + field.text("name", "Name", { required: true, placeholder: `www, @ for ${z.name}, or a full name` }), + field.text("content", "Content", { + required: true, + placeholder: "IPv4 for A, IPv6 for AAAA, a hostname for CNAME/MX, text for TXT", + }), + field.checkbox("proxied", "Proxied through Cloudflare (A, AAAA, CNAME)", { value: true }), + field.text("ttl", "TTL", { placeholder: "auto, or seconds (60–86400)" }), + field.text("priority", "Priority", { placeholder: "MX and SRV only (default 10)" }), + field.text("comment", "Comment", { placeholder: "Optional note" }), + ], + async submit(values) { + const body = recordBody(values as Record, z.name); + await cf(tok, `/zones/${z.id}/dns_records`, { method: "POST", body }); + changed(); + await toast(`Created ${body.type} ${body.name}`, { style: "success" }); + return pop(); + }, + }); +} + +async function recordsOf(tok: string, z: Zone, signal?: AbortSignal): Promise { + return (await cfAll(tok, `/zones/${z.id}/dns_records`, signal, 100)).map((r) => mapRecord(r, z)); +} + +function recordItem(tok: string, r: DnsRecord, z: Zone, multiZone: boolean): Item { + const short = shortName(r.name, r.zoneName); + const actions: Action[] = [ + copy(r.content, "Copy Content"), + copy(r.name, "Copy Name", { shortcut: "period", section: "Copy" }), + copy(zoneFileLine(r), "Copy as Zone File Line", { icon: Icons.text, section: "Copy" }), + copy(r.id, "Copy Record ID", { icon: Icons.hash, section: "Copy" }), + ]; + if (r.proxiable) { + actions.push(action(r.proxied ? "Switch to DNS Only" : "Proxy Through Cloudflare", async () => { + await cf(tok, `/zones/${r.zoneId}/dns_records/${r.id}`, { method: "PATCH", body: { proxied: !r.proxied } }); + changed(); + await toast(`${r.name} is ${r.proxied ? "DNS only" : "proxied"}`, { style: "success" }); + }, { + icon: Icons.cloud, + shortcut: "x", + section: "Record", + confirm: r.proxied ? `Expose the origin of ${r.name}? DNS only answers with ${r.content}.` : undefined, + })); + } + actions.push( + action("Add Record", () => recordForm(tok, z, r.type), { + icon: Icons.plus, + shortcut: "n", + section: "Record", + }), + open(dnsDashboardUrl(z), "Open DNS in Dashboard", { icon: Icons.globe, shortcut: "o", section: "Record" }), + action("Delete Record", async () => { + await cf(tok, `/zones/${r.zoneId}/dns_records/${r.id}`, { method: "DELETE" }); + changed(); + await toast(`Deleted ${r.type} ${r.name}`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${r.type} ${r.name} → ${r.content}?`, + shortcut: "Delete", + section: "Danger", + }), + ); + return { + id: r.id, + title: short, + subtitle: r.priority !== undefined ? `${r.priority} ${r.content}` : r.content, + icon: { + source: r.proxied ? Icons.cloud : Icons.globe, + tint: r.proxied ? "orange" : "gray", + }, + section: multiZone ? r.zoneName : undefined, + keywords: [r.name, r.type, r.content, r.comment ?? "", ...r.tags, r.proxied ? "proxied" : "dns only"], + accessories: [ + ...(r.comment ? [{ icon: Icons.comment, tooltip: r.comment }] : []), + ...(r.ttl !== 1 ? [{ text: ttlLabel(r.ttl), tooltip: "TTL" }] : []), + { tag: r.type, color: TYPE_COLORS[r.type] ?? "gray" }, + ], + detail: { + markdown: r.type === "TXT" || r.content.length > 60 ? `\`\`\`\n${r.content}\n\`\`\`` : undefined, + metadata: [ + meta.text("Name", r.name, { icon: Icons.globe }), + meta.tags("Type", [{ text: r.type, color: TYPE_COLORS[r.type] ?? "gray" }]), + ...(r.content.length <= 60 ? [meta.text("Content", r.content)] : []), + ...(r.priority !== undefined ? [meta.text("Priority", r.priority)] : []), + meta.text( + "Proxy", + r.proxied ? "Proxied" : r.proxiable ? "DNS only" : "DNS only (not proxiable)", + { icon: Icons.cloud, color: r.proxied ? "orange" : "gray" }, + ), + meta.text("TTL", ttlLabel(r.ttl), { icon: Icons.clock }), + ...(r.comment ? [meta.text("Comment", r.comment, { icon: Icons.comment })] : []), + ...(r.tags.length ? [meta.tags("Tags", r.tags)] : []), + meta.separator(), + meta.text("Zone", r.zoneName, { icon: Icons.layers }), + ...(r.modified ? [meta.text("Modified", formatDate(r.modified), { icon: Icons.calendar })] : []), + ], + }, + actions, + }; +} + +const FILTERS: { value: RecordFilter; title: string }[] = [ + { value: "all", title: "All" }, + { value: "address", title: "A / AAAA" }, + { value: "cname", title: "CNAME" }, + { value: "mx", title: "MX" }, + { value: "txt", title: "TXT" }, + { value: "proxied", title: "Proxied" }, + { value: "dns-only", title: "DNS Only" }, +]; + +/** + * DNS records of one zone, or of every zone when `zone` is undefined. + * Reloads by itself after a create, a proxy toggle or a delete. + */ +export function dnsView(tok: () => string, zone?: Zone): ListSpec { + let summary = ""; + let known: Zone[] = zone ? [zone] : []; + const addActions = (): Action[] => + zone ? [action("Add Record", () => recordForm(tok(), zone), { icon: Icons.plus, shortcut: "n" })] : [ + submenu( + "Add Record", + () => known.map((z) => action(z.name, () => recordForm(tok(), z), { icon: Icons.globe })), + { icon: Icons.plus, shortcut: "n" }, + ), + ]; + return list({ + title: zone ? `DNS · ${zone.name}` : undefined, + placeholder: zone ? `Filter ${zone.name} records by name, type, content…` : "Filter DNS records…", + filter: { options: FILTERS, default: "all" }, + empty: { + title: "No records", + description: "Nothing matches this filter. Press Ctrl+N to add a record.", + icon: Icons.globe, + }, + status: () => summary, + actions: addActions(), + items: ({ signal, filter }) => + live( + () => + guarded(async () => { + const t = tok(); + const zs = zone ? [zone] : (known = (await loadZones(t, signal)).filter((z) => z.status !== "deleted")); + const all = (await Promise.all(zs.map((z) => recordsOf(t, z, signal)))).flat(); + const shown = sortRecords(filterRecords(all, (filter ?? "all") as RecordFilter)); + const proxied = all.filter((r) => r.proxied).length; + summary = `${all.length} record${all.length === 1 ? "" : "s"} · ${proxied} proxied${ + zone ? ` · ${zone.name}` : ` · ${zs.length} zone${zs.length === 1 ? "" : "s"}` + }`; + const byId = new Map(zs.map((z) => [z.id, z])); + return shown.map((r) => recordItem(t, r, byId.get(r.zoneId)!, zs.length > 1)); + }, signal), + signal, + ), + }); +} diff --git a/extensions/databases/backslash.toml b/extensions/databases/backslash.toml new file mode 100644 index 0000000..2b40cd0 --- /dev/null +++ b/extensions/databases/backslash.toml @@ -0,0 +1,78 @@ +[extension] +id = "databases" +title = "Databases" +description = "Open your PostgreSQL, MySQL, SQLite and Redis connections in a terminal, check that they answer and copy their URLs" +version = "1.0.0" +author = "Backslash" +icon = "bs-database" +license = "MIT" + +[permissions] +env = ["HOME"] +# Connection files the clients read themselves; the distribution, for install hints. +read = ["~/.pgpass", "~/.pg_service.conf", "~/.my.cnf", "/etc/os-release"] +run = [ + # Clients (opened in a terminal) and status checks. + "psql", + "pgcli", + "pg_isready", + "mysql", + "mariadb", + "mycli", + "mysqladmin", + "sqlite3", + "litecli", + "redis-cli", + # Terminals. + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "connections" +title = "Database Connections" +description = "Saved connections and those of ~/.pgpass, ~/.pg_service.conf and ~/.my.cnf, with their status" +icon = "bs-database" +keywords = [ + "database", + "db", + "postgres", + "postgresql", + "psql", + "mysql", + "mariadb", + "sqlite", + "redis", + "sql", + "base de données", + "bdd", + "connexion", +] + +[[commands]] +name = "add" +title = "Add Database Connection" +description = "Save a PostgreSQL, MySQL, SQLite or Redis connection (no password stored)" +icon = "bs-plus" +keywords = ["database", "db", "connection", "postgres", "mysql", "redis", "sqlite", "ajouter", "base de données", "connexion"] + +[[preferences]] +name = "passwords" +title = "Passwords" +description = "Passwords of saved connections as name=password pairs separated by ; (escape a ; with \\;). PostgreSQL connections also use ~/.pgpass" +type = "secret" + +[[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/databases/commands/add.ts b/extensions/databases/commands/add.ts new file mode 100644 index 0000000..c28c44c --- /dev/null +++ b/extensions/databases/commands/add.ts @@ -0,0 +1,6 @@ +import type { Context } from "@backslash/api"; +import { connectionForm } from "../db.ts"; + +// Text typed before opening the command becomes the name. +export default (ctx: Context) => + connectionForm({ connection: undefined, standalone: true, name: ctx.query.trim() || undefined }); diff --git a/extensions/databases/commands/connections.ts b/extensions/databases/commands/connections.ts new file mode 100644 index 0000000..4219ece --- /dev/null +++ b/extensions/databases/commands/connections.ts @@ -0,0 +1,241 @@ +import { + type Action, + action, + close, + type Context, + copy, + Icons, + type Item, + list, + meta, + type Metadata, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Client, + clientCommand, + CLIENTS, + type Connection, + connectionUrl, + type DbType, + describe, + type Family, + HEALTH_COLORS, + installCommand, + SOURCE_NAMES, + type Status, + TYPE_COLORS, + TYPE_NAMES, +} from "../lib.ts"; +import { + checkStatus, + connectionForm, + copySecret, + deleteConnection, + distro, + HOME, + installedClients, + loadSources, + missingClientItem, + nextChange, + openIn, + passwordOf, + type Prefs, + savedList, + type Sources, +} from "../db.ts"; +import { shellLine } from "../terminal.ts"; + +const CLIENT_NAMES: Record = { + psql: "psql", + pgcli: "pgcli", + mysql: "mysql", + mariadb: "mariadb", + mycli: "mycli", + sqlite3: "sqlite3", + litecli: "litecli", + "redis-cli": "redis-cli", +}; + +const TYPE_ICON: Record = { + postgres: Icons.database, + mysql: Icons.database, + sqlite: Icons.file, + redis: Icons.bolt, +}; + +function metadata(c: Connection, status: Status | undefined, origin: string | undefined): Metadata[] { + const lines: Metadata[] = [ + meta.tags("Type", [{ text: TYPE_NAMES[c.type], color: TYPE_COLORS[c.type] }]), + meta.text("Status", status?.text ?? "Checking…", { + icon: Icons.dot, + color: status ? HEALTH_COLORS[status.health] : "gray", + }), + ]; + if (c.type === "sqlite") { + lines.push(meta.text("File", describe(c, HOME), { icon: Icons.file })); + } else { + if (c.service) lines.push(meta.text("Service", c.service, { icon: Icons.settings })); + if (c.host || !c.socket) lines.push(meta.text("Host", c.host ?? "localhost", { icon: Icons.server })); + if (c.socket) lines.push(meta.text("Socket", c.socket, { icon: Icons.plug })); + if (c.port) lines.push(meta.text("Port", c.port, { icon: Icons.hash })); + if (c.database) lines.push(meta.text(c.type === "redis" ? "Database #" : "Database", c.database)); + if (c.user) lines.push(meta.person("User", c.user)); + lines.push(meta.text("Password", origin ?? "None known", { + icon: origin ? Icons.lock : Icons.key, + color: origin ? "green" : "gray", + })); + } + lines.push(meta.separator(), meta.text("Source", SOURCE_NAMES[c.source], { icon: Icons.folder })); + return lines; +} + +function item( + c: Connection, + src: Sources, + prefs: Prefs, + clients: Set, + family: Family | undefined, + status?: Status, +): Item { + const { password, origin } = passwordOf(c, src, prefs); + const available = CLIENTS[c.type].filter((cl) => clients.has(cl)); + const [primary, ...others] = available; + const actions: Action[] = []; + const openAction = (client: Client, o: { shortcut?: string } = {}) => + action(`Open in ${CLIENT_NAMES[client]}`, () => { + const term = openIn(c, client, src, prefs); + return close(`${CLIENT_NAMES[client]} in ${term}`); + }, { icon: Icons.terminal, ...o }); + if (primary) { + actions.push(openAction(primary)); + others.forEach((cl, i) => actions.push(openAction(cl, i === 0 ? { shortcut: "o" } : {}))); + } else { + actions.push(copy(installCommand(c.type, family), `Copy ${TYPE_NAMES[c.type]} Client Install Command`, { + icon: Icons.download, + })); + } + actions.push(action("Check Status", () => refresh(), { icon: Icons.activity, shortcut: "r" })); + + const url = connectionUrl(c, password); + actions.push(copy(url, password ? "Copy URL (Password Masked)" : "Copy URL", { + icon: Icons.link, + shortcut: "c", + section: "Copy", + })); + if (password) { + actions.push(copySecret(connectionUrl(c, password, true), "Copy URL with Password", { section: "Copy" })); + actions.push(copySecret(password, "Copy Password", { section: "Copy" })); + } + const shown = primary ?? CLIENTS[c.type][0]; + const cmd = clientCommand(c, shown, undefined).argv; + actions.push(copy(shellLine(cmd), `Copy ${CLIENT_NAMES[shown]} Command`, { icon: Icons.terminal, section: "Copy" })); + if (c.host) actions.push(copy(c.host, "Copy Host", { icon: Icons.server, section: "Copy" })); + if (c.path) actions.push(copy(c.path, "Copy File Path", { icon: Icons.file, section: "Copy" })); + + if (c.source === "saved") { + actions.push( + action("Edit Connection", async () => { + const saved = (await savedList()).find((s) => s.name === c.name); + if (!saved) throw new Error(`${c.name} is no longer saved`); + return connectionForm({ connection: saved }); + }, { icon: Icons.settings, shortcut: "e", section: "Connection" }), + action("Delete Connection", async () => { + await deleteConnection(c.name); + await toast(`Deleted ${c.name}`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete the saved connection “${c.name}”? The database itself is not touched.`, + shortcut: "Delete", + section: "Danger", + }), + ); + } else { + actions.push(open(`${HOME}/${SOURCE_NAMES[c.source].slice(2)}`, `Open ${SOURCE_NAMES[c.source]}`, { + icon: Icons.file, + section: "Connection", + })); + } + + const color = status ? HEALTH_COLORS[status.health] : "gray"; + return { + id: c.id, + title: c.name, + subtitle: describe(c, HOME), + icon: { source: TYPE_ICON[c.type], tint: TYPE_COLORS[c.type] }, + section: SOURCE_NAMES[c.source], + keywords: [c.type, TYPE_NAMES[c.type], c.host ?? "", c.database ?? "", c.user ?? "", c.service ?? ""], + accessories: [ + ...(origin ? [{ icon: Icons.lock, tooltip: `Password in ${origin}` }] : []), + { tag: TYPE_NAMES[c.type].split(" ")[0], color: TYPE_COLORS[c.type] }, + { + icon: { source: Icons.dot, tint: color }, + text: status?.text ?? "…", + tooltip: status ? "Status" : "Checking…", + }, + ], + detail: { metadata: metadata(c, status, origin) }, + actions, + }; +} + +let summary = ""; + +export default (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + const addAction = action("Add Connection", () => connectionForm(), { icon: Icons.plus, shortcut: "n" }); + return list({ + placeholder: "Filter connections by name, host, database…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "postgres", title: "PostgreSQL" }, + { value: "mysql", title: "MySQL" }, + { value: "sqlite", title: "SQLite" }, + { value: "redis", title: "Redis" }, + ], + default: "all", + }, + empty: { + title: "No database connections", + description: "Press Ctrl+N to add one, or describe them in ~/.pgpass, ~/.pg_service.conf or ~/.my.cnf", + icon: Icons.database, + }, + status: () => summary, + actions: [addAction], + async *items({ signal, filter }) { + while (!signal.aborted) { + const [src, clients, family] = await Promise.all([loadSources(), installedClients(), distro()]); + const shown = src.connections.filter((c) => !filter || filter === "all" || c.type === filter); + const missing = await Promise.all( + [...new Set(shown.map((c) => c.type))] + .filter((t) => !CLIENTS[t].some((cl) => clients.has(cl))) + .map(missingClientItem), + ); + const withAdd = (items: Item[]) => [...items, ...missing]; + summary = shown.length ? `${shown.length} connection${shown.length === 1 ? "" : "s"} · checking…` : ""; + yield withAdd(shown.map((c) => item(c, src, prefs, clients, family))); + + const statuses = await Promise.all(shown.map((c) => checkStatus(c, signal))); + if (signal.aborted) return; + const up = statuses.filter((s) => s.health === "up" || s.health === "auth").length; + summary = !shown.length + ? "" + : `${shown.length} connection${shown.length === 1 ? "" : "s"} · ${up} up${ + src.files.length ? ` · ${src.files.join(", ")}` : "" + }`; + if (!filter || filter === "all") { + await updateCommandMetadata({ + subtitle: src.connections.length ? `${src.connections.length} connections · ${up} up` : null, + }).catch(() => {}); + } + yield withAdd(shown.map((c, i) => item(c, src, prefs, clients, family, statuses[i]))); + await nextChange(signal); + } + }, + }); +}; diff --git a/extensions/databases/db.ts b/extensions/databases/db.ts new file mode 100644 index 0000000..8a78d6d --- /dev/null +++ b/extensions/databases/db.ts @@ -0,0 +1,275 @@ +// Reading the connection files, looking up passwords, detecting clients and +// checking servers. Shared by both commands. + +import { + action, + clipboard, + close, + copy, + exec, + field, + form, + type FormSpec, + Icons, + type Item, + pop, + storage, + toast, +} from "@backslash/api"; +import { + type Client, + clientCommand, + CLIENTS, + type Connection, + type DbType, + DEFAULT_PORTS, + type Family, + installCommand, + interpretStatus, + matchPgpass, + myCnfConnections, + osFamily, + parseMyCnf, + parsePasswords, + parsePgpass, + parsePgService, + pgpassConnections, + type PgpassEntry, + type SavedConnection, + savedConnections, + serviceConnections, + sortConnections, + type Status, + statusCommand, + toSaved, + TYPE_NAMES, + TYPES, + upsertSaved, +} from "./lib.ts"; +import { runInTerminal } from "./terminal.ts"; + +export interface Prefs { + passwords?: string; + terminal?: string; +} + +export const HOME = Deno.env.get("HOME") ?? ""; + +const readText = (path: string) => Deno.readTextFile(path).catch(() => ""); + +export interface Sources { + connections: Connection[]; + pgpass: PgpassEntry[]; + /** Passwords found in the user's own files, by connection id. */ + filePasswords: Map; + /** Files that exist, for the empty state. */ + files: string[]; +} + +export async function loadSources(): Promise { + const [pgpassText, serviceText, myCnfText, saved] = await Promise.all([ + readText(`${HOME}/.pgpass`), + readText(`${HOME}/.pg_service.conf`), + readText(`${HOME}/.my.cnf`), + storage.get("connections").catch(() => undefined), + ]); + const pgpass = parsePgpass(pgpassText); + const services = parsePgService(serviceText); + const groups = parseMyCnf(myCnfText); + const filePasswords = new Map(); + for (const s of services) if (s.password) filePasswords.set(`service:${s.name}`, s.password); + for (const g of groups) if (g.password) filePasswords.set(`mycnf:${g.suffix}`, g.password); + const connections = sortConnections([ + ...savedConnections(saved), + ...serviceConnections(services), + ...pgpassConnections(pgpass), + ...myCnfConnections(groups), + ]); + const files = [ + pgpassText && "~/.pgpass", + serviceText && "~/.pg_service.conf", + myCnfText && "~/.my.cnf", + ].filter(Boolean) as string[]; + return { connections, pgpass, filePasswords, files }; +} + +/** Where the password comes from, and the password itself, if any. */ +export function passwordOf( + c: Connection, + src: Sources, + prefs: Prefs, +): { password?: string; origin?: string } { + if (c.source === "saved") { + const pref = parsePasswords(prefs.passwords).get(c.name); + if (pref !== undefined) return { password: pref, origin: "Passwords preference" }; + } + const own = src.filePasswords.get(c.id); + if (own !== undefined) return { password: own, origin: c.source === "mycnf" ? "~/.my.cnf" : "~/.pg_service.conf" }; + if (c.type === "postgres" && !c.service) { + const hit = matchPgpass(src.pgpass, c); + if (hit) return { password: hit.password, origin: "~/.pgpass" }; + } + return {}; +} + +/** Whether the client reads the password from its own file, so none has to be passed. */ +export function readsOwnPassword(origin?: string): boolean { + return origin === "~/.pgpass" || origin === "~/.pg_service.conf" || origin === "~/.my.cnf"; +} + +// --- Clients ------------------------------------------------------------------ + +let installed: Promise> | undefined; + +/** Clients found on this machine (tried once per process). */ +export function installedClients(): Promise> { + installed ??= (async () => { + const all = Object.values(CLIENTS).flat(); + const found = await Promise.all(all.map(async (client) => { + try { + await new Deno.Command(client, { args: ["--version"], stdin: "null", stdout: "null", stderr: "null" }) + .output(); + return client; + } catch { + return undefined; + } + })); + return new Set(found.filter((c): c is Client => !!c)); + })(); + return installed; +} + +let family: Promise | undefined; +export function distro(): Promise { + family ??= readText("/etc/os-release").then(osFamily); + return family; +} + +export function openIn(c: Connection, client: Client, src: Sources, prefs: Prefs): string { + const { password, origin } = passwordOf(c, src, prefs); + const inv = clientCommand(c, client, readsOwnPassword(origin) ? undefined : password); + return runInTerminal(inv.argv, prefs.terminal || undefined, inv.env); +} + +// --- Status ------------------------------------------------------------------- + +/** Asks the server (or the file) whether it answers, in at most 5 s. */ +export async function checkStatus(c: Connection, signal?: AbortSignal): Promise { + const argv = statusCommand(c); + if (!argv) return { health: "unknown", text: "No file" }; + const timeout = AbortSignal.timeout(5000); + try { + const out = await exec(argv[0], argv.slice(1), { signal: signal ? AbortSignal.any([signal, timeout]) : timeout }); + return interpretStatus(c.type, out); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return { health: "missing", text: `${argv[0]} not installed` }; + } + if (timeout.aborted) return { health: "down", text: "No answer in 5 s" }; + return interpretStatus(c.type, undefined, err); + } +} + +// --- Saved connections ---------------------------------------------------------- + +export async function savedList(): Promise { + const all = await storage.get("connections").catch(() => undefined); + return Array.isArray(all) ? all as unknown as SavedConnection[] : []; +} + +export async function saveConnection(next: SavedConnection, previous?: string) { + await storage.set("connections", JSON.parse(JSON.stringify(upsertSaved(await savedList(), next, previous)))); + notifyChanged(); +} + +export async function deleteConnection(name: string) { + await storage.set("connections", JSON.parse(JSON.stringify((await savedList()).filter((s) => s.name !== name)))); + notifyChanged(); +} + +export function connectionForm( + options: { connection?: SavedConnection; type?: DbType; standalone?: boolean; name?: string } = {}, +): FormSpec { + const c = options.connection; + return form({ + title: c ? `Edit “${c.name}”` : "New Database Connection", + submitTitle: c ? "Save Connection" : "Add Connection", + fields: [ + field.text("name", "Name", { + required: true, + placeholder: "prod-db, local cache…", + value: c?.name ?? options.name ?? "", + }), + field.dropdown("type", "Type", TYPES.map((t) => ({ value: t, title: TYPE_NAMES[t] })), { + value: c?.type ?? options.type ?? "postgres", + }), + field.text("host", "Host", { placeholder: "localhost", value: c?.host ?? "" }), + field.text("port", "Port", { + placeholder: "5432, 3306, 6379 (default for the type)", + value: c?.port && c.port !== DEFAULT_PORTS[c.type] ? String(c.port) : "", + }), + field.text("database", "Database", { + placeholder: "Database name; Redis: db number; SQLite: file path", + value: c?.type === "sqlite" ? c.path ?? "" : c?.database ?? "", + }), + field.text("user", "User", { placeholder: "Optional", value: c?.user ?? "" }), + ], + async submit(values) { + const next = toSaved(values as Record, HOME); + await saveConnection(next, c?.name); + await toast(c ? "Connection saved" : `Added ${next.name}`, { + style: "success", + message: next.type === "sqlite" ? undefined : "Passwords go in the Passwords preference or ~/.pgpass", + }); + return options.standalone ? close(`Added ${next.name}`) : pop(); + }, + }); +} + +// --- Live reload ------------------------------------------------------------------ + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Resolves on the next change, or when `signal` aborts. */ +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); + }); +} + +// --- Helpers for items ----------------------------------------------------------- + +export function copySecret(text: string, title: string, o: { shortcut?: string; section?: string } = {}) { + return action(title, async () => { + await clipboard.write(text, { concealed: true }); + return close("Copied"); + }, { icon: Icons.key, ...o }); +} + +export async function missingClientItem(type: DbType): Promise { + const cmd = installCommand(type, await distro()); + return { + id: `missing:${type}`, + title: `No ${TYPE_NAMES[type]} client installed`, + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + section: "Setup", + detail: { + markdown: [ + `Opening ${TYPE_NAMES[type]} connections needs **${CLIENTS[type].join("**, **")}**.`, + "", + "```sh", + cmd, + "```", + ].join("\n"), + }, + actions: [copy(cmd, "Copy Install Command", { icon: Icons.terminal })], + }; +} diff --git a/extensions/databases/lib.ts b/extensions/databases/lib.ts new file mode 100644 index 0000000..cf93c72 --- /dev/null +++ b/extensions/databases/lib.ts @@ -0,0 +1,583 @@ +// Pure logic of the Databases extension: config file parsers, URLs, client +// command lines and status interpretation. No I/O here. + +export type DbType = "postgres" | "mysql" | "sqlite" | "redis"; +export type Source = "saved" | "pgpass" | "service" | "mycnf"; + +export const TYPES: DbType[] = ["postgres", "mysql", "sqlite", "redis"]; + +export const TYPE_NAMES: Record = { + postgres: "PostgreSQL", + mysql: "MySQL / MariaDB", + sqlite: "SQLite", + redis: "Redis", +}; + +export const TYPE_COLORS = { postgres: "blue", mysql: "orange", sqlite: "teal", redis: "red" } as const; + +export const DEFAULT_PORTS: Record = { + postgres: 5432, + mysql: 3306, + sqlite: undefined, + redis: 6379, +}; + +export const SOURCE_NAMES: Record = { + saved: "Saved", + pgpass: "~/.pgpass", + service: "~/.pg_service.conf", + mycnf: "~/.my.cnf", +}; + +/** A connection as listed. Passwords are never part of it: they are looked up when needed. */ +export interface Connection { + id: string; + name: string; + type: DbType; + source: Source; + host?: string; + port?: number; + database?: string; + user?: string; + /** SQLite database file. */ + path?: string; + /** pg_service.conf section. */ + service?: string; + /** my.cnf option group suffix (`_prod` for `[client_prod]`, "" for `[client]`). */ + group?: string; + /** Unix socket (my.cnf). */ + socket?: string; +} + +/** What is kept in storage for a saved connection (no password, ever). */ +export interface SavedConnection { + name: string; + type: DbType; + host?: string; + port?: number; + database?: string; + user?: string; + path?: string; +} + +// --- ~/.pgpass --------------------------------------------------------------- + +export interface PgpassEntry { + host: string; + port: string; + database: string; + user: string; + password: string; +} + +/** Splits a pgpass line on unescaped colons (`\:` and `\\` are escapes). */ +function splitPgpass(line: string): string[] { + const fields: string[] = []; + let cur = ""; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === "\\" && i + 1 < line.length) cur += line[++i]; + else if (c === ":") { + fields.push(cur); + cur = ""; + } else cur += c; + } + fields.push(cur); + return fields; +} + +export function parsePgpass(text: string): PgpassEntry[] { + const out: PgpassEntry[] = []; + for (const raw of text.split("\n")) { + const line = raw.replace(/\r$/, ""); + if (!line.trim() || line.trimStart().startsWith("#")) continue; + const f = splitPgpass(line); + if (f.length < 5) continue; + // A password may itself contain unescaped colons in sloppy files: keep the rest. + out.push({ host: f[0], port: f[1], database: f[2], user: f[3], password: f.slice(4).join(":") }); + } + return out; +} + +/** The first pgpass entry matching a connection, as libpq picks it. */ +export function matchPgpass( + entries: PgpassEntry[], + c: { host?: string; port?: number; database?: string; user?: string }, +): PgpassEntry | undefined { + const host = c.host || "localhost"; + const port = String(c.port ?? 5432); + const user = c.user ?? ""; + const db = c.database || user; + const ok = (pattern: string, value: string) => pattern === "*" || pattern === value; + return entries.find((e) => + ok(e.host, host) && ok(e.port, port) && ok(e.database, db) && (e.user === "*" || !user || e.user === user) + ); +} + +const portOf = (s: string | undefined) => { + const n = Number(s); + return s && Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined; +}; + +/** One connection per pgpass line with a concrete host (wildcard hosts are credential templates). */ +export function pgpassConnections(entries: PgpassEntry[]): Connection[] { + const seen = new Set(); + const out: Connection[] = []; + for (const e of entries) { + if (e.host === "*") continue; + const database = e.database === "*" ? undefined : e.database; + const user = e.user === "*" ? undefined : e.user; + const port = e.port === "*" ? undefined : portOf(e.port); + const id = `pgpass:${user ?? ""}@${e.host}:${port ?? ""}/${database ?? ""}`; + if (seen.has(id)) continue; + seen.add(id); + out.push({ + id, + name: `${database ?? "any database"} on ${e.host}`, + type: "postgres", + source: "pgpass", + host: e.host, + port, + database, + user, + }); + } + return out; +} + +// --- INI files (pg_service.conf, my.cnf) ----------------------------------------- + +export type Ini = { section: string; values: Record }[]; + +export function parseIni(text: string): Ini { + const out: Ini = []; + let cur: Ini[number] | undefined; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";") || line.startsWith("!")) continue; + const head = /^\[([^\]]+)\]$/.exec(line); + if (head) { + cur = { section: head[1].trim(), values: {} }; + out.push(cur); + continue; + } + if (!cur) continue; + const eq = line.indexOf("="); + const key = (eq < 0 ? line : line.slice(0, eq)).trim().toLowerCase().replace(/-/g, "_"); + let value = eq < 0 ? "" : line.slice(eq + 1).trim(); + if (/^(["']).*\1$/.test(value) && value.length >= 2) value = value.slice(1, -1); + cur.values[key] = value; + } + return out; +} + +export interface PgService { + name: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; +} + +export function parsePgService(text: string): PgService[] { + return parseIni(text).map(({ section, values: v }) => ({ + name: section, + host: v.host || v.hostaddr || undefined, + port: portOf(v.port), + database: v.dbname || undefined, + user: v.user || undefined, + password: v.password || undefined, + })); +} + +export function serviceConnections(services: PgService[]): Connection[] { + return services.map((s) => ({ + id: `service:${s.name}`, + name: s.name, + type: "postgres" as const, + source: "service" as const, + service: s.name, + host: s.host, + port: s.port, + database: s.database, + user: s.user, + })); +} + +export interface MyCnfGroup { + /** "" for `[client]` / `[mysql]`, else the suffix (`_prod`). */ + suffix: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; + socket?: string; +} + +/** + * Client option groups of a my.cnf: `[client]` and `[mysql]` make the default + * connection, `[client_x]` / `[mysql_x]` are read by `--defaults-group-suffix=_x`. + */ +export function parseMyCnf(text: string): MyCnfGroup[] { + const groups = new Map>(); + for (const { section, values } of parseIni(text)) { + const m = /^(client|mysql|mariadb-client|mariadb)(.*)$/i.exec(section); + if (!m) continue; + let suffix = m[2]; + if (m[1].toLowerCase().startsWith("mariadb") && suffix.startsWith("-client")) suffix = suffix.slice(7); + const merged = { ...(groups.get(suffix) ?? {}), ...values }; + groups.set(suffix, merged); + } + const out: MyCnfGroup[] = []; + for (const [suffix, v] of groups) { + if (!v.host && !v.user && !v.database && !v.socket) continue; + out.push({ + suffix, + host: v.host || undefined, + port: portOf(v.port), + database: v.database || undefined, + user: v.user || undefined, + password: v.password ?? v.pass ?? undefined, + socket: v.socket || undefined, + }); + } + return out; +} + +export function myCnfConnections(groups: MyCnfGroup[]): Connection[] { + return groups.map((g) => ({ + id: `mycnf:${g.suffix}`, + name: g.suffix ? g.suffix.replace(/^[_-]/, "") : `${g.user ?? "default"}@${g.host ?? "localhost"}`, + type: "mysql" as const, + source: "mycnf" as const, + group: g.suffix, + host: g.host, + port: g.port, + database: g.database, + user: g.user, + socket: g.socket, + })); +} + +// --- Saved connections and their secret preference ------------------------------ + +export function savedConnections(saved: unknown): Connection[] { + if (!Array.isArray(saved)) return []; + return saved.filter((s): s is SavedConnection => + !!s && typeof s === "object" && typeof s.name === "string" && TYPES.includes(s.type) + ).map((s) => ({ + id: `saved:${s.name}`, + name: s.name, + type: s.type, + source: "saved" as const, + host: s.type === "sqlite" ? undefined : s.host, + port: s.type === "sqlite" ? undefined : s.port, + database: s.type === "sqlite" ? undefined : s.database, + user: s.type === "sqlite" ? undefined : s.user, + path: s.type === "sqlite" ? s.path : undefined, + })); +} + +/** + * The "Passwords" secret preference: `name=password` pairs separated by `;` + * or new lines. `\;` and `\\` escape inside a password. + */ +export function parsePasswords(text: unknown): Map { + const out = new Map(); + const s = String(text ?? ""); + let cur = ""; + const flush = () => { + const eq = cur.indexOf("="); + if (eq > 0) out.set(cur.slice(0, eq).trim(), cur.slice(eq + 1)); + cur = ""; + }; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "\\" && i + 1 < s.length) cur += s[++i]; + else if (c === ";" || c === "\n") flush(); + else cur += c; + } + flush(); + return out; +} + +export interface FormInput { + name?: string; + type?: string; + host?: string; + port?: string; + database?: string; + user?: string; +} + +/** Validates the form; for SQLite, `database` is the file path. */ +export function toSaved(v: FormInput, home: string): SavedConnection { + const name = (v.name ?? "").trim(); + if (!name) throw new Error("Give the connection a name"); + const type = (v.type ?? "postgres") as DbType; + if (!TYPES.includes(type)) throw new Error(`Unknown type ${v.type}`); + if (type === "sqlite") { + let path = (v.database ?? "").trim(); + if (!path) throw new Error("Enter the path of the SQLite file in Database"); + if (path.startsWith("~/")) path = home + path.slice(1); + if (!path.startsWith("/")) throw new Error("Use an absolute path (or ~/…) for the SQLite file"); + return { name, type, path }; + } + const portText = (v.port ?? "").trim(); + const port = portText ? portOf(portText) : DEFAULT_PORTS[type]; + if (port === undefined) throw new Error(`Port must be a number from 1 to 65535, not “${portText}”`); + const out: SavedConnection = { name, type, host: (v.host ?? "").trim() || "localhost", port }; + const database = (v.database ?? "").trim(); + const user = (v.user ?? "").trim(); + if (database) out.database = database; + if (user) out.user = user; + return out; +} + +/** Replaces (by name, or `previous` when renaming) or adds a saved connection. */ +export function upsertSaved(all: SavedConnection[], next: SavedConnection, previous?: string): SavedConnection[] { + const rest = all.filter((s) => s.name !== (previous ?? next.name)); + if (rest.some((s) => s.name === next.name)) throw new Error(`A connection named “${next.name}” already exists`); + return [...rest, next].sort((a, b) => a.name.localeCompare(b.name)); +} + +// --- URLs and command lines ------------------------------------------------------- + +const hostPart = (host: string) => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +/** A connection URL; the password is masked unless `reveal`. */ +export function connectionUrl(c: Connection, password?: string, reveal = false): string { + if (c.type === "sqlite") return `sqlite://${c.path ?? ""}`; + if (c.type === "postgres" && c.service) return `postgresql://?service=${encodeURIComponent(c.service)}`; + const scheme = { postgres: "postgresql", mysql: "mysql", redis: "redis" }[c.type]; + const pw = password ? `:${reveal ? encodeURIComponent(password) : "****"}` : ""; + const user = c.user ? encodeURIComponent(c.user) : ""; + const auth = user || pw ? `${user}${pw}@` : ""; + const port = c.port ? `:${c.port}` : ""; + const db = c.database ? `/${encodeURIComponent(c.database)}` : ""; + return `${scheme}://${auth}${hostPart(c.host ?? "localhost")}${port}${db}`; +} + +export type Client = "psql" | "pgcli" | "mysql" | "mariadb" | "mycli" | "sqlite3" | "litecli" | "redis-cli"; + +export const CLIENTS: Record = { + postgres: ["psql", "pgcli"], + mysql: ["mysql", "mariadb", "mycli"], + sqlite: ["sqlite3", "litecli"], + redis: ["redis-cli"], +}; + +/** Environment variable each client reads a password from (kept out of argv). */ +const PASSWORD_ENV: Partial> = { + psql: "PGPASSWORD", + pgcli: "PGPASSWORD", + mysql: "MYSQL_PWD", + mariadb: "MYSQL_PWD", + mycli: "MYSQL_PWD", + "redis-cli": "REDISCLI_AUTH", +}; + +export interface Invocation { + argv: string[]; + env: Record; +} + +/** + * How to open `c` with `client`. Files the clients read themselves + * (pgpass, pg_service.conf, my.cnf) need no password here. + */ +export function clientCommand(c: Connection, client: Client, password?: string): Invocation { + const env: Record = {}; + const envName = PASSWORD_ENV[client]; + if (password && envName) env[envName] = password; + const argv: string[] = [client]; + switch (c.type) { + case "postgres": + if (c.service) { + argv.push(`service=${c.service}`); + break; + } + if (c.host) argv.push("-h", c.host); + if (c.port) argv.push("-p", String(c.port)); + if (c.user) argv.push("-U", c.user); + if (c.database) argv.push("-d", c.database); + break; + case "mysql": + if (c.source === "mycnf") { + if (c.group) argv.push(`--defaults-group-suffix=${c.group}`); + break; + } + if (c.host) argv.push("-h", c.host); + if (c.port) argv.push("-P", String(c.port)); + if (c.user) argv.push("-u", c.user); + if (c.database) argv.push(c.database); + break; + case "sqlite": + argv.push(c.path ?? ""); + break; + case "redis": + if (c.host) argv.push("-h", c.host); + if (c.port) argv.push("-p", String(c.port)); + if (c.user) argv.push("--user", c.user); + if (c.database && /^\d+$/.test(c.database)) argv.push("-n", c.database); + break; + } + return { argv, env }; +} + +// --- Status ----------------------------------------------------------------------- + +export type Health = "up" | "auth" | "rejecting" | "down" | "missing" | "unknown"; + +export interface Status { + health: Health; + text: string; +} + +export const HEALTH_COLORS = { + up: "green", + auth: "green", + rejecting: "orange", + down: "red", + missing: "gray", + unknown: "gray", +} as const; + +/** The program and arguments that check whether the server answers. */ +export function statusCommand(c: Connection): string[] | undefined { + switch (c.type) { + case "postgres": + if (c.service) return ["pg_isready", "-t", "3", "-d", `service=${c.service}`]; + return [ + "pg_isready", + "-t", + "3", + "-h", + c.host ?? "localhost", + "-p", + String(c.port ?? 5432), + ...(c.user ? ["-U", c.user] : []), + ...(c.database ? ["-d", c.database] : []), + ]; + case "mysql": + return [ + "mysqladmin", + ...(c.source === "mycnf" && c.group ? [`--defaults-group-suffix=${c.group}`] : []), + "--connect-timeout=3", + ...(c.source !== "mycnf" || c.host ? ["-h", c.host ?? "localhost"] : []), + ...(c.port ? ["-P", String(c.port)] : []), + ...(c.source !== "mycnf" && c.user ? ["-u", c.user] : []), + "ping", + ]; + case "redis": + return ["redis-cli", "-h", c.host ?? "localhost", "-p", String(c.port ?? 6379), "ping"]; + case "sqlite": + return c.path + ? ["sqlite3", "-readonly", c.path, "select count(*) from sqlite_master where type = 'table'"] + : undefined; + } +} + +/** `exec` errors read "prog exited with N: message". */ +export function exitCode(err: unknown): number | undefined { + const m = /exited with (\d+)/.exec(err instanceof Error ? err.message : String(err)); + return m ? Number(m[1]) : undefined; +} + +const errorText = (err: unknown) => + (err instanceof Error ? err.message : String(err)).replace(/^[\w-]+ exited with \d+: /, ""); + +/** Reads the result of {@link statusCommand}: its output, or the error it threw. */ +export function interpretStatus(type: DbType, out: string | undefined, err?: unknown): Status { + if (err !== undefined) { + const text = errorText(err); + if (type === "postgres") { + const code = exitCode(err); + if (code === 1) return { health: "rejecting", text: "Rejecting connections" }; + if (code === 2) return { health: "down", text: "No response" }; + if (code === 3) return { health: "unknown", text: text || "Invalid parameters" }; + } + if (type === "mysql" && /Access denied/i.test(text)) return { health: "auth", text: "Up (access denied)" }; + if (type === "sqlite") { + if (/unable to open|no such file/i.test(text)) return { health: "down", text: "File not found" }; + if (/not a database|encrypted/i.test(text)) return { health: "rejecting", text: "Not a SQLite database" }; + } + if (/refused|can't connect|could not connect|timed out|timeout|unknown host|name or service/i.test(text)) { + return { health: "down", text: "Unreachable" }; + } + return { health: "unknown", text: text.split("\n")[0].slice(0, 80) || "Unknown" }; + } + const o = (out ?? "").trim(); + switch (type) { + case "postgres": + return { health: "up", text: "Accepting connections" }; + case "mysql": + return /alive/i.test(o) ? { health: "up", text: "Alive" } : { health: "unknown", text: o || "Unknown" }; + case "redis": + if (/^PONG$/i.test(o)) return { health: "up", text: "PONG" }; + if (/NOAUTH|WRONGPASS|NOPERM/i.test(o)) return { health: "auth", text: "Up (needs auth)" }; + if (/LOADING/i.test(o)) return { health: "rejecting", text: "Loading dataset" }; + if (/Could not connect|refused/i.test(o)) return { health: "down", text: "Unreachable" }; + return { health: "unknown", text: o.slice(0, 80) || "No answer" }; + case "sqlite": { + const n = Number(o); + return Number.isFinite(n) + ? { health: "up", text: `${n} table${n === 1 ? "" : "s"}` } + : { health: "unknown", text: o || "Unknown" }; + } + } +} + +// --- Display ------------------------------------------------------------------- + +/** `user@host:port/db`, `service=prod` or the file path. */ +export function describe(c: Connection, home = ""): string { + if (c.type === "sqlite") { + return home && c.path?.startsWith(home + "/") ? `~${c.path.slice(home.length)}` : c.path ?? ""; + } + if (c.service) return `service=${c.service}`; + if (c.socket && !c.host) return `${c.user ? `${c.user}@` : ""}${c.socket}`; + const port = c.port && c.port !== DEFAULT_PORTS[c.type] ? `:${c.port}` : ""; + return `${c.user ? `${c.user}@` : ""}${c.host ?? "localhost"}${port}${c.database ? `/${c.database}` : ""}`; +} + +export function sortConnections(all: Connection[]): Connection[] { + const order: Source[] = ["saved", "service", "pgpass", "mycnf"]; + return [...all].sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source) || a.name.localeCompare(b.name)); +} + +// --- Installing clients ------------------------------------------------------------ + +export type Family = "debian" | "fedora" | "arch" | "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 PACKAGES: Record> = { + postgres: { debian: "postgresql-client", fedora: "postgresql", arch: "postgresql", suse: "postgresql" }, + mysql: { debian: "mariadb-client", fedora: "mariadb", arch: "mariadb-clients", suse: "mariadb-client" }, + sqlite: { debian: "sqlite3", fedora: "sqlite", arch: "sqlite", suse: "sqlite3" }, + redis: { debian: "redis-tools", fedora: "redis", arch: "redis", suse: "redis" }, +}; + +const MANAGERS: Record = { + debian: "sudo apt install", + fedora: "sudo dnf install", + arch: "sudo pacman -S", + suse: "sudo zypper install", +}; + +/** The install command for the client of `type`, for this distribution (Debian's when unknown). */ +export function installCommand(type: DbType, family: Family | undefined): string { + const f = family ?? "debian"; + return `${MANAGERS[f]} ${PACKAGES[type][f]}`; +} diff --git a/extensions/databases/lib_test.ts b/extensions/databases/lib_test.ts new file mode 100644 index 0000000..7bce003 --- /dev/null +++ b/extensions/databases/lib_test.ts @@ -0,0 +1,313 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + clientCommand, + type Connection, + connectionUrl, + describe, + installCommand, + interpretStatus, + matchPgpass, + myCnfConnections, + osFamily, + parseMyCnf, + parsePasswords, + parsePgpass, + parsePgService, + pgpassConnections, + savedConnections, + serviceConnections, + sortConnections, + statusCommand, + toSaved, + upsertSaved, +} from "./lib.ts"; +import { holdOpen, shellLine, terminalArgs } from "./terminal.ts"; + +const PGPASS = [ + "# host:port:database:username:password", + "db.prod.acme.io:5432:shop:app:s3cr\\:et", + "localhost:5433:*:postgres:local", + "*:*:*:readonly:ro-pass", + "db.prod.acme.io:5432:shop:app:duplicate", + "broken line", + "", +].join("\n"); + +Deno.test("pgpass: escapes, wildcards and comments", () => { + const entries = parsePgpass(PGPASS); + assertEquals(entries.length, 4); + assertEquals(entries[0], { + host: "db.prod.acme.io", + port: "5432", + database: "shop", + user: "app", + password: "s3cr:et", + }); + assertEquals(entries[2].host, "*"); +}); + +Deno.test("pgpass: connections skip wildcard hosts and duplicates", () => { + const conns = pgpassConnections(parsePgpass(PGPASS)); + assertEquals(conns.map((c) => c.name), ["shop on db.prod.acme.io", "any database on localhost"]); + assertEquals(conns[1].port, 5433); + assertEquals(conns[1].database, undefined); + assertEquals(conns[1].user, "postgres"); +}); + +Deno.test("pgpass: matching follows libpq (first match, wildcards, db defaults to user)", () => { + const entries = parsePgpass(PGPASS); + assertEquals( + matchPgpass(entries, { host: "db.prod.acme.io", port: 5432, database: "shop", user: "app" })?.password, + "s3cr:et", + ); + assertEquals(matchPgpass(entries, { host: "localhost", port: 5433, user: "postgres" })?.password, "local"); + assertEquals(matchPgpass(entries, { host: "other", database: "x", user: "readonly" })?.password, "ro-pass"); + assertEquals(matchPgpass(entries, { host: "other", database: "x", user: "nobody" }), undefined); +}); + +Deno.test("pg_service.conf", () => { + const services = parsePgService(` +# comment +[prod] +host=db.prod.acme.io +port=6432 +dbname=shop +user=app +password=hunter2 + +[local] +dbname = scratch +`); + assertEquals(services[0], { + name: "prod", + host: "db.prod.acme.io", + port: 6432, + database: "shop", + user: "app", + password: "hunter2", + }); + const conns = serviceConnections(services); + assertEquals(conns[1].id, "service:local"); + assertEquals(clientCommand(conns[0], "psql").argv, ["psql", "service=prod"]); + assertEquals(connectionUrl(conns[0]), "postgresql://?service=prod"); + assertEquals(statusCommand(conns[0]), ["pg_isready", "-t", "3", "-d", "service=prod"]); +}); + +Deno.test("my.cnf: [client] and suffixed groups", () => { + const groups = parseMyCnf(` +[mysqld] +datadir=/var/lib/mysql + +[client] +user=steven +password="p@ss word" + +[mysql] +database=blog + +[client_prod] +host=mysql.acme.io +port=3307 +user=deploy + +!includedir /etc/mysql/conf.d/ +`); + assertEquals(groups, [ + { + suffix: "", + host: undefined, + port: undefined, + database: "blog", + user: "steven", + password: "p@ss word", + socket: undefined, + }, + { + suffix: "_prod", + host: "mysql.acme.io", + port: 3307, + database: undefined, + user: "deploy", + password: undefined, + socket: undefined, + }, + ]); + const conns = myCnfConnections(groups); + assertEquals(conns.map((c) => c.name), ["steven@localhost", "prod"]); + // The client reads the group itself. + assertEquals(clientCommand(conns[1], "mysql").argv, ["mysql", "--defaults-group-suffix=_prod"]); + assertEquals(clientCommand(conns[0], "mariadb").argv, ["mariadb"]); + assertEquals(statusCommand(conns[1]), [ + "mysqladmin", + "--defaults-group-suffix=_prod", + "--connect-timeout=3", + "-h", + "mysql.acme.io", + "-P", + "3307", + "ping", + ]); +}); + +Deno.test("passwords preference", () => { + const p = parsePasswords("prod-db=s3cret;cache=a\\;b\nlocal = x=y"); + assertEquals(p.get("prod-db"), "s3cret"); + assertEquals(p.get("cache"), "a;b"); + assertEquals(p.get("local"), " x=y"); + assertEquals(parsePasswords(undefined).size, 0); +}); + +Deno.test("form values become saved connections", () => { + assertEquals( + toSaved({ name: " prod ", type: "postgres", host: "", port: "", database: "shop", user: "app" }, "/home/u"), + { + name: "prod", + type: "postgres", + host: "localhost", + port: 5432, + database: "shop", + user: "app", + }, + ); + assertEquals(toSaved({ name: "notes", type: "sqlite", database: "~/data/notes.db" }, "/home/u"), { + name: "notes", + type: "sqlite", + path: "/home/u/data/notes.db", + }); + assertEquals(toSaved({ name: "cache", type: "redis", port: "6380" }, "/h").port, 6380); + assertThrows(() => toSaved({ name: "", type: "redis" }, "/h"), Error, "name"); + assertThrows(() => toSaved({ name: "x", type: "mysql", port: "99999" }, "/h"), Error, "Port"); + assertThrows(() => toSaved({ name: "x", type: "sqlite", database: "rel.db" }, "/h"), Error, "absolute"); +}); + +Deno.test("saved connections: upsert, rename, duplicates", () => { + const a = { name: "a", type: "redis" as const }; + const b = { name: "b", type: "redis" as const }; + assertEquals(upsertSaved([b], a).map((s) => s.name), ["a", "b"]); + assertEquals(upsertSaved([a, b], { ...a, name: "c" }, "a").map((s) => s.name), ["b", "c"]); + assertThrows(() => upsertSaved([a, b], { ...a, name: "b" }, "a"), Error, "already exists"); + // Garbage in storage is ignored. + assertEquals(savedConnections([a, { name: 1 }, null, { name: "z", type: "oracle" }]).length, 1); + assertEquals(savedConnections("nope"), []); +}); + +const pg: Connection = { + id: "saved:prod", + name: "prod", + type: "postgres", + source: "saved", + host: "db.acme.io", + port: 5432, + database: "shop", + user: "app", +}; + +Deno.test("URLs mask the password unless revealed", () => { + assertEquals(connectionUrl(pg), "postgresql://app@db.acme.io:5432/shop"); + assertEquals(connectionUrl(pg, "p@ss"), "postgresql://app:****@db.acme.io:5432/shop"); + assertEquals(connectionUrl(pg, "p@ss", true), "postgresql://app:p%40ss@db.acme.io:5432/shop"); + const redis: Connection = { + id: "r", + name: "r", + type: "redis", + source: "saved", + host: "::1", + port: 6379, + database: "2", + }; + assertEquals(connectionUrl(redis, "pw", true), "redis://:pw@[::1]:6379/2"); + const lite: Connection = { id: "s", name: "s", type: "sqlite", source: "saved", path: "/home/u/app.db" }; + assertEquals(connectionUrl(lite), "sqlite:///home/u/app.db"); + assertEquals(describe(lite, "/home/u"), "~/app.db"); + assertEquals(describe(pg), "app@db.acme.io/shop"); +}); + +Deno.test("client commands keep passwords in the environment", () => { + assertEquals(clientCommand(pg, "psql", "pw"), { + argv: ["psql", "-h", "db.acme.io", "-p", "5432", "-U", "app", "-d", "shop"], + env: { PGPASSWORD: "pw" }, + }); + const my: Connection = { ...pg, type: "mysql", port: 3306 }; + assertEquals(clientCommand(my, "mycli", "pw"), { + argv: ["mycli", "-h", "db.acme.io", "-P", "3306", "-u", "app", "shop"], + env: { MYSQL_PWD: "pw" }, + }); + const redis: Connection = { + id: "r", + name: "r", + type: "redis", + source: "saved", + host: "cache", + port: 6380, + database: "1", + }; + assertEquals(clientCommand(redis, "redis-cli", "pw"), { + argv: ["redis-cli", "-h", "cache", "-p", "6380", "-n", "1"], + env: { REDISCLI_AUTH: "pw" }, + }); + assertEquals(clientCommand({ ...pg, type: "sqlite", path: "/x.db" }, "sqlite3").argv, ["sqlite3", "/x.db"]); +}); + +Deno.test("status: pg_isready exit codes", () => { + const err = (code: number, msg = "") => new Error(`pg_isready exited with ${code}: ${msg}`); + assertEquals(interpretStatus("postgres", "db:5432 - accepting connections").health, "up"); + assertEquals(interpretStatus("postgres", undefined, err(1, "rejecting")).health, "rejecting"); + assertEquals(interpretStatus("postgres", undefined, err(2, "db:5432 - no response")), { + health: "down", + text: "No response", + }); + assertEquals(interpretStatus("postgres", undefined, err(3)).health, "unknown"); +}); + +Deno.test("status: redis, mysql and sqlite", () => { + assertEquals(interpretStatus("redis", "PONG").health, "up"); + assertEquals(interpretStatus("redis", "NOAUTH Authentication required.").health, "auth"); + assertEquals( + interpretStatus( + "redis", + undefined, + new Error("redis-cli exited with 1: Could not connect to Redis at x:6379: Connection refused"), + ).health, + "down", + ); + assertEquals(interpretStatus("mysql", "mysqld is alive").health, "up"); + assertEquals( + interpretStatus("mysql", undefined, new Error("mysqladmin exited with 1: error: 'Access denied for user 'x'@'y''")) + .health, + "auth", + ); + assertEquals( + interpretStatus("mysql", undefined, new Error("mysqladmin exited with 1: Can't connect to server on 'x' (115)")) + .health, + "down", + ); + assertEquals(interpretStatus("sqlite", "12"), { health: "up", text: "12 tables" }); + assertEquals( + interpretStatus("sqlite", undefined, new Error('sqlite3 exited with 1: Error: unable to open database "/x.db"')) + .text, + "File not found", + ); +}); + +Deno.test("sorting puts saved connections first", () => { + const conns = sortConnections([ + { ...pg, id: "p", source: "pgpass", name: "a" }, + { ...pg, id: "s", source: "saved", name: "z" }, + { ...pg, id: "v", source: "service", name: "m" }, + ]); + assertEquals(conns.map((c) => c.id), ["s", "v", "p"]); +}); + +Deno.test("install commands per distribution", () => { + assertEquals(osFamily("ID=ubuntu\nID_LIKE=debian\n"), "debian"); + assertEquals(osFamily('ID="fedora"\n'), "fedora"); + assertEquals(installCommand("postgres", "debian"), "sudo apt install postgresql-client"); + assertEquals(installCommand("mysql", "arch"), "sudo pacman -S mariadb-clients"); + assertEquals(installCommand("redis", undefined), "sudo apt install redis-tools"); +}); + +Deno.test("terminal helpers", () => { + assertEquals(terminalArgs("gnome-terminal", ["psql"]), ["--", "psql"]); + assertEquals(shellLine(["psql", "-d", "my db"]), "psql -d 'my db'"); + assertEquals(holdOpen(["echo", "hi"])[2].startsWith("echo hi; printf"), true); +}); diff --git a/extensions/databases/terminal.ts b/extensions/databases/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/databases/terminal.ts @@ -0,0 +1,89 @@ +// 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]; + } +} + +/** + * Wraps a command that ends by itself (a log dump, a login) so the terminal + * stays open on its output until the user presses Enter. + */ +export function holdOpen(argv: string[]): string[] { + return ["sh", "-c", `${shellLine(argv)}; printf '\\n[Press Enter to close] '; read -r _`]; +} + +/** + * Starts `argv` in a new terminal window that outlives the command. `env` is + * added to the terminal's environment (secrets go there, never in `argv`). + */ +export function runInTerminal(argv: string[], preferred?: string, env?: Record): 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", + env, + }).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/google-cloud/backslash.toml b/extensions/google-cloud/backslash.toml new file mode 100644 index 0000000..3e5f55b --- /dev/null +++ b/extensions/google-cloud/backslash.toml @@ -0,0 +1,53 @@ +[extension] +id = "google-cloud" +title = "Google Cloud" +description = "Switch gcloud projects, SSH into Compute Engine VMs and follow Cloud Run services, their URLs and logs" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +# gcloud does the authentication; the distribution is read for install hints. +[permissions] +read = ["/etc/os-release"] +run = [ + "gcloud", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "projects" +title = "Google Cloud Projects" +description = "Your projects, the current one first: switch with gcloud config set project" +icon = "bs-project" +keywords = ["gcloud", "gcp", "google cloud", "project", "switch", "projet", "changer"] + +[[commands]] +name = "instances" +title = "Compute Engine Instances" +description = "VMs of the current project: SSH, logs, serial console, start and stop" +icon = "bs-server" +keywords = ["gcloud", "gcp", "compute", "gce", "vm", "instance", "ssh", "serveur", "machine virtuelle"] + +[[commands]] +name = "run" +title = "Cloud Run Services" +description = "Services with their status, URL and revision: open, copy, logs" +icon = "bs-rocket" +keywords = ["gcloud", "gcp", "cloud run", "serverless", "service", "container", "deploy", "conteneur", "déploiement"] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For SSH, logs and sign-in. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/google-cloud/commands/instances.ts b/extensions/google-cloud/commands/instances.ts new file mode 100644 index 0000000..db2fe78 --- /dev/null +++ b/extensions/google-cloud/commands/instances.ts @@ -0,0 +1,139 @@ +import { type Action, action, copy, exec, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { + age, + formatDate, + HEALTH_COLORS, + type Instance, + instanceConsoleUrl, + instanceHealth, + instanceLogsCommand, + parseInstances, + serialCommand, + sortInstances, + sshCommand, + statusLabel, + zoneRegion, +} from "../lib.ts"; +import { gcloud, guarded, inTerminal, type Prefs, projectActions, showProject } from "../gcp.ts"; +import { shellLine } from "../terminal.ts"; + +const SECTIONS = { ok: "Running", busy: "Changing", off: "Stopped", failed: "Problems" }; + +function power(i: Instance, project: string, verb: "start" | "stop"): Action { + return action(verb === "start" ? "Start Instance" : "Stop Instance", async () => { + await toast(`${verb === "start" ? "Starting" : "Stopping"} ${i.name}…`); + await exec("gcloud", ["compute", "instances", verb, i.name, `--zone=${i.zone}`, `--project=${project}`, "--async"]); + await toast(`${verb === "start" ? "Starting" : "Stopping"} ${i.name}`, { style: "success" }); + return refresh(); + }, { + icon: verb === "start" ? Icons.play : Icons.stop, + destructive: verb === "stop", + confirm: verb === "stop" ? `Stop ${i.name} in ${i.zone}?` : undefined, + shortcut: verb === "start" ? "s" : "x", + section: "Instance", + }); +} + +function item(i: Instance, project: string, prefs: Prefs): Item { + const health = instanceHealth(i.status); + const color = HEALTH_COLORS[health]; + const running = i.status === "RUNNING"; + const labels = Object.entries(i.labels).map(([k, v]) => `${k}=${v}`); + const actions: Action[] = []; + if (running) actions.push(inTerminal("SSH in Terminal", sshCommand(i, project), prefs, { section: "Terminal" })); + actions.push( + open(instanceConsoleUrl(i, project), "Open in Console", { icon: Icons.globe, shortcut: "o" }), + inTerminal("Logs in Terminal", instanceLogsCommand(i, project), prefs, { + icon: Icons.text, + shortcut: "l", + hold: true, + }), + ...(running + ? [inTerminal("Follow Serial Console", serialCommand(i, project), prefs, { icon: Icons.monitor })] + : []), + copy(i.name, "Copy Name", { shortcut: "period", section: "Copy" }), + ...(i.externalIp ? [copy(i.externalIp, "Copy External IP", { icon: Icons.globe, section: "Copy" })] : []), + ...(i.internalIp ? [copy(i.internalIp, "Copy Internal IP", { icon: Icons.server, section: "Copy" })] : []), + copy(shellLine(sshCommand(i, project)), "Copy SSH Command", { icon: Icons.terminal, section: "Copy" }), + copy(instanceConsoleUrl(i, project), "Copy Console URL", { icon: Icons.link, section: "Copy" }), + ); + if (i.status === "TERMINATED" || i.status === "STOPPED") actions.push(power(i, project, "start")); + if (running) actions.push(power(i, project, "stop")); + actions.push(action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), ...projectActions()); + + return { + id: `${i.zone}/${i.name}`, + title: i.name, + subtitle: i.zone, + icon: { source: Icons.server, tint: color }, + section: SECTIONS[health], + keywords: [ + i.status, + i.machineType, + i.internalIp ?? "", + i.externalIp ?? "", + zoneRegion(i.zone), + ...labels, + ...i.tags, + ], + accessories: [ + ...(i.spot ? [{ tag: "Spot", color: "purple" as const }] : []), + ...(i.externalIp ? [{ icon: Icons.globe, text: i.externalIp, tooltip: "External IP" }] : []), + { text: i.machineType, tooltip: "Machine type" }, + { tag: statusLabel(i.status), color }, + ], + detail: { + stats: [ + { label: "status", value: statusLabel(i.status), color }, + { label: "type", value: i.machineType }, + { label: "age", value: age(i.created) || "—" }, + ], + metadata: [ + meta.text("Zone", i.zone, { icon: Icons.pin }), + ...(i.externalIp ? [meta.text("External IP", i.externalIp, { icon: Icons.globe })] : []), + ...(i.internalIp ? [meta.text("Internal IP", i.internalIp, { icon: Icons.server })] : []), + ...(i.network ? [meta.text("Network", i.network, { icon: Icons.layers })] : []), + ...(i.os ? [meta.text("Image", i.os, { icon: Icons.box })] : []), + ...(i.cpuPlatform ? [meta.text("CPU", i.cpuPlatform, { icon: Icons.cpu })] : []), + ...(i.spot ? [meta.text("Provisioning", "Spot", { icon: Icons.bolt, color: "purple" })] : []), + ...(labels.length ? [meta.tags("Labels", labels)] : []), + ...(i.tags.length ? [meta.tags("Network tags", i.tags)] : []), + meta.separator(), + ...(i.created ? [meta.text("Created", formatDate(i.created), { icon: Icons.calendar })] : []), + meta.text("Project", project, { icon: Icons.project }), + ], + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter instances by name, zone, IP, label…", + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "stopped", title: "Stopped" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "No instances", description: "No Compute Engine VM matches in this project", icon: Icons.server }, + status: () => summary, + actions: projectActions(), + items: ({ preferences, signal, filter }) => { + const prefs = preferences as Prefs; + summary = ""; + return guarded(prefs, signal, async (c) => { + const all = parseInstances(await gcloud(["compute", "instances", "list", `--project=${c.project}`], signal)); + const running = all.filter((i) => i.status === "RUNNING").length; + summary = `${c.project} · ${all.length} instance${all.length === 1 ? "" : "s"} · ${running} running`; + await showProject(c, `${running} running`); + const shown = all.filter((i) => + filter === "running" ? i.status === "RUNNING" : filter === "stopped" ? instanceHealth(i.status) === "off" : true + ); + return sortInstances(shown).map((i) => item(i, c.project, prefs)); + }); + }, +}); diff --git a/extensions/google-cloud/commands/projects.ts b/extensions/google-cloud/commands/projects.ts new file mode 100644 index 0000000..094d917 --- /dev/null +++ b/extensions/google-cloud/commands/projects.ts @@ -0,0 +1,73 @@ +import { action, copy, Icons, type Item, list, meta, open, refresh } from "@backslash/api"; +import { formatDate, parseProjects, type Project, projectConsoleUrl } from "../lib.ts"; +import { errorItem, gcloud, guarded, type Prefs, showProject, useProject } from "../gcp.ts"; + +function item(p: Project, current: boolean, account?: string): Item { + const active = p.state === "ACTIVE"; + const labels = Object.entries(p.labels).map(([k, v]) => `${k}=${v}`); + return { + id: p.id, + title: p.name, + subtitle: p.id === p.name ? undefined : p.id, + icon: { + source: current ? Icons.check : Icons.project, + tint: current ? "green" : active ? "blue" : "gray", + }, + section: current ? "Current Project" : active ? "Projects" : "Pending Deletion", + keywords: [p.id, p.number, ...labels], + accessories: [ + ...(active ? [] : [{ tag: "Deleting", color: "red" as const }]), + ...(current ? [{ tag: "Current", color: "green" as const }] : []), + ], + detail: { + metadata: [ + meta.text("Project ID", p.id, { icon: Icons.hash }), + meta.text("Number", p.number, { icon: Icons.hash }), + meta.text("State", active ? "Active" : p.state.replace(/_/g, " ").toLowerCase(), { + icon: Icons.dot, + color: active ? "green" : "red", + }), + ...(p.parent ? [meta.text("Parent", p.parent, { icon: Icons.layers })] : []), + ...(labels.length ? [meta.tags("Labels", labels)] : []), + meta.separator(), + ...(p.created ? [meta.text("Created", formatDate(p.created), { icon: Icons.calendar })] : []), + ...(current && account ? [meta.person("Account", account)] : []), + ], + }, + actions: [ + ...(current || !active ? [] : [action("Switch to Project", async () => { + await useProject(p.id); + return refresh(); + }, { icon: Icons.arrowRight })]), + open(projectConsoleUrl(p.id), "Open in Console", { icon: Icons.globe, shortcut: "o" }), + copy(p.id, "Copy Project ID", { shortcut: "period", section: "Copy" }), + copy(p.number, "Copy Project Number", { section: "Copy" }), + copy(`gcloud config set project ${p.id}`, "Copy Switch Command", { icon: Icons.terminal, section: "Copy" }), + ], + }; +} + +export default list({ + placeholder: "Switch Google Cloud project…", + empty: { title: "No projects", description: "This account cannot see any project", icon: Icons.project }, + status: (items) => { + const n = items.filter((i) => !["missing", "error", "no-project"].includes(i.id ?? "")).length; + return n ? `${n} project${n === 1 ? "" : "s"}` : ""; + }, + items: ({ preferences, signal }) => + guarded(preferences as Prefs, signal, async (c) => { + await showProject(c, c.account); + const projects = parseProjects(await gcloud(["projects", "list"], signal)); + return projects + .map((p) => item(p, p.id === c.project, c.account)) + .sort((a, b) => Number(b.section === "Current Project") - Number(a.section === "Current Project")); + }).then(async (items) => { + // Without a current project, the list of projects is exactly what is needed. + if (items[0]?.id !== "no-project") return items; + try { + return parseProjects(await gcloud(["projects", "list"], signal)).map((p) => item(p, false)); + } catch (err) { + return [errorItem(err, undefined, preferences as Prefs), ...items]; + } + }), +}); diff --git a/extensions/google-cloud/commands/run.ts b/extensions/google-cloud/commands/run.ts new file mode 100644 index 0000000..62ed3c2 --- /dev/null +++ b/extensions/google-cloud/commands/run.ts @@ -0,0 +1,130 @@ +import { + action, + copy, + detail, + type DetailSpec, + exec, + Icons, + type Item, + list, + meta, + open, + refresh, +} from "@backslash/api"; +import { + age, + formatDate, + HEALTH_COLORS, + parseServices, + readServiceLogsCommand, + type Service, + serviceConsoleUrl, + serviceHealth, + serviceLogsUrl, + shortImage, + tailServiceCommand, +} from "../lib.ts"; +import { gcloud, guarded, inTerminal, type Prefs, projectActions, showProject } from "../gcp.ts"; +import { shellLine } from "../terminal.ts"; + +const SECTIONS = { ok: "Ready", busy: "Deploying", failed: "Failing", off: "Off" }; +const LABELS = { ok: "Ready", busy: "Deploying", failed: "Failed", off: "Off" }; + +const fence = (text: string) => `\`\`\`\n${text.replace(/```/g, "ʼʼʼ") || "(no recent logs)"}\n\`\`\``; + +async function recentLogs(s: Service, project: string, prefs: Prefs): Promise { + const argv = readServiceLogsCommand(s, project); + const text = await exec(argv[0], argv.slice(1)).catch((e) => String(e.message)); + return detail({ + title: `${s.name} · logs`, + markdown: fence(text), + status: `Last ${Math.min(text.split("\n").length, 100)} entries · ${s.region}`, + actions: [ + inTerminal("Tail in Terminal", tailServiceCommand(s, project), prefs, { shortcut: "t" }), + copy(text, "Copy Logs", { shortcut: "c" }), + action("Reload", () => recentLogs(s, project, prefs), { icon: Icons.refresh, shortcut: "r" }), + open(serviceLogsUrl(s, project), "Open Logs in Console", { icon: Icons.globe }), + ], + }); +} + +function item(s: Service, project: string, prefs: Prefs): Item { + const health = serviceHealth(s); + const color = HEALTH_COLORS[health]; + const split = s.traffic.filter((t) => t.percent > 0); + return { + id: `${s.region}/${s.name}`, + title: s.name, + subtitle: s.url?.replace(/^https:\/\//, ""), + icon: { source: Icons.rocket, tint: color }, + section: SECTIONS[health], + keywords: [s.region, s.revision ?? "", s.image ?? "", s.modifier ?? ""], + accessories: [ + ...(split.length > 1 + ? [{ icon: Icons.shuffle, text: `${split.length} revisions`, tooltip: "Traffic split" }] + : []), + { icon: Icons.globe, text: s.region, tooltip: "Region" }, + ...(s.updated ? [{ text: age(s.updated), tooltip: `Updated ${formatDate(s.updated)}` }] : []), + { tag: LABELS[health], color }, + ], + detail: { + markdown: health === "failed" && s.message ? `**${s.reason ?? "Not ready"}**: ${s.message}` : undefined, + metadata: [ + meta.text("Status", LABELS[health], { icon: Icons.dot, color }), + ...(s.url ? [meta.link("URL", s.url.replace(/^https:\/\//, ""), s.url)] : []), + ...(s.revision ? [meta.text("Revision", s.revision, { icon: Icons.commit })] : []), + ...(s.image ? [meta.text("Image", shortImage(s.image), { icon: Icons.box })] : []), + ...(split.length + ? [meta.tags("Traffic", split.map((t) => `${t.tag ? `${t.tag}: ` : ""}${t.revision} ${t.percent}%`))] + : []), + ...(s.ingress ? [meta.text("Ingress", s.ingress, { icon: Icons.shield })] : []), + meta.separator(), + meta.text("Region", s.region, { icon: Icons.globe }), + ...(s.modifier ? [meta.person("Last deployed by", s.modifier)] : []), + ...(s.updated ? [meta.text("Updated", formatDate(s.updated), { icon: Icons.calendar })] : []), + meta.text("Project", project, { icon: Icons.project }), + ], + }, + actions: [ + ...(s.url ? [open(s.url, "Open Service URL", { icon: Icons.globe })] : []), + open(serviceConsoleUrl(s, project), "Open in Console", { icon: Icons.external, shortcut: "o" }), + action("Show Recent Logs", () => recentLogs(s, project, prefs), { + icon: Icons.text, + shortcut: "l", + section: "Logs", + }), + inTerminal("Tail Logs in Terminal", tailServiceCommand(s, project), prefs, { section: "Logs" }), + ...(s.url ? [copy(s.url, "Copy URL", { shortcut: "c", section: "Copy" })] : []), + copy(s.name, "Copy Service Name", { shortcut: "period", section: "Copy" }), + ...(s.image ? [copy(s.image, "Copy Image", { icon: Icons.box, section: "Copy" })] : []), + copy(shellLine(tailServiceCommand(s, project)), "Copy Logs Command", { icon: Icons.terminal, section: "Copy" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...projectActions(), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter Cloud Run services by name, region, image…", + empty: { title: "No Cloud Run services", description: "Nothing deployed in this project", icon: Icons.rocket }, + status: () => summary, + actions: projectActions(), + items: ({ preferences, signal }) => { + const prefs = preferences as Prefs; + summary = ""; + return guarded(prefs, signal, async (c) => { + const services = parseServices(await gcloud(["run", "services", "list", `--project=${c.project}`], signal)); + const failing = services.filter((s) => serviceHealth(s) === "failed").length; + summary = `${c.project} · ${services.length} service${services.length === 1 ? "" : "s"}${ + failing ? ` · ${failing} failing` : "" + }`; + await showProject(c, failing ? `${failing} failing` : `${services.length} services`); + const order = ["failed", "busy", "ok", "off"]; + return services + .sort((a, b) => order.indexOf(serviceHealth(a)) - order.indexOf(serviceHealth(b))) + .map((s) => item(s, c.project, prefs)); + }); + }, +}); diff --git a/extensions/google-cloud/gcp.ts b/extensions/google-cloud/gcp.ts new file mode 100644 index 0000000..fed1141 --- /dev/null +++ b/extensions/google-cloud/gcp.ts @@ -0,0 +1,164 @@ +// Calling gcloud, and the items the commands share (missing CLI, sign-in, +// errors, project switching). + +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + open, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { classifyError, type GcloudConfig, installCommand, osFamily, parseConfig, parseProjects } from "./lib.ts"; +import { holdOpen, runInTerminal } from "./terminal.ts"; + +export interface Prefs { + terminal?: string; +} + +/** gcloud with JSON output, never prompting. */ +export function gcloud(args: string[], signal?: AbortSignal): Promise { + return exec("gcloud", [...args, "--format=json", "--quiet"], { signal }); +} + +export const isMissing = (err: unknown) => err instanceof Deno.errors.NotFound; + +export async function config(signal?: AbortSignal): Promise { + return parseConfig(await gcloud(["config", "list"], signal)); +} + +export async function useProject(id: string) { + await exec("gcloud", ["config", "set", "project", id, "--quiet"]); + await toast(`Switched to ${id}`, { style: "success", message: "gcloud config set project" }); +} + +export async function showProject(c: GcloudConfig, extra?: string) { + await updateCommandMetadata({ subtitle: c.project ? [c.project, extra].filter(Boolean).join(" · ") : null }) + .catch(() => {}); +} + +export function inTerminal( + title: string, + argv: string[], + prefs: Prefs, + o: { icon?: string; shortcut?: string; section?: string; hold?: boolean } = {}, +): Action { + return action(title, () => { + const term = runInTerminal(o.hold ? holdOpen(argv) : argv, prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: o.icon ?? Icons.terminal, shortcut: o.shortcut, section: o.section ?? "Terminal" }); +} + +/** "Switch Project ›", loaded when opened. */ +export function projectActions(): Action[] { + return [ + submenu("Switch Project", async () => { + const [c, projects] = await Promise.all([config(), gcloud(["projects", "list"]).then(parseProjects)]); + return projects.map((p) => + action(`${p.name} (${p.id})`, async () => { + await useProject(p.id); + return refresh(); + }, { icon: p.id === c.project ? Icons.check : Icons.project }) + ); + }, { icon: Icons.project, shortcut: "p", section: "Project" }), + ]; +} + +let hint: Promise | undefined; +const installHint = () => + hint ??= Deno.readTextFile("/etc/os-release").catch(() => "").then((t) => installCommand(osFamily(t))); + +export async function gcloudMissing(): Promise { + const cmd = await installHint(); + return { + id: "missing", + title: "The Google Cloud CLI is not installed", + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This extension runs **gcloud** with your active configuration.", + "", + "```sh", + cmd, + "gcloud init", + "```", + ].join("\n"), + }, + actions: [ + copy(cmd.replace(/\s+#.*$/, ""), "Copy Install Command", { icon: Icons.terminal }), + open("https://cloud.google.com/sdk/docs/install", "Open Install Guide"), + ], + }; +} + +export function noProject(prefs: Prefs): Item { + return { + id: "no-project", + title: "No project selected", + subtitle: "Pick one with Switch Project (Ctrl+Shift+P)", + icon: { source: Icons.project, tint: "yellow" }, + detail: { markdown: "gcloud has no current project. Choose one, or run `gcloud init`." }, + actions: [ + ...projectActions(), + inTerminal("Run gcloud init", ["gcloud", "init"], prefs, { section: "Setup" }), + ], + }; +} + +/** An error as an item, with the way out. */ +export function errorItem(err: unknown, c: GcloudConfig | undefined, prefs: Prefs): Item { + const e = classifyError(err); + const fixes: Action[] = []; + if (e.problem === "auth") { + fixes.push(inTerminal("Sign In with gcloud auth login", ["gcloud", "auth", "login"], prefs, { + icon: Icons.key, + section: "Fix", + hold: true, + })); + } + if (e.problem === "api" && c?.project) { + fixes.push(open( + `https://console.cloud.google.com/apis/library/${e.api ?? ""}?project=${c.project}`, + "Enable the API", + { icon: Icons.plug, section: "Fix" }, + )); + } + return { + id: "error", + title: e.title, + subtitle: [c?.project, c?.account].filter(Boolean).join(" · "), + icon: { source: Icons.alert, tint: e.problem === "denied" || e.problem === "api" ? "orange" : "red" }, + detail: { markdown: `\`\`\`\n${e.message.replace(/```/g, "ʼʼʼ")}\n\`\`\`` }, + actions: [ + ...fixes, + action("Retry", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...projectActions(), + copy(e.message, "Copy Error", { section: "Copy" }), + ], + }; +} + +/** Loads the config, then the project's items; problems become a single helpful item. */ +export async function guarded( + prefs: Prefs, + signal: AbortSignal, + load: (c: GcloudConfig & { project: string }) => Promise, +): Promise { + let c: GcloudConfig | undefined; + try { + c = await config(signal); + if (!c.project) return [noProject(prefs)]; + return await load(c as GcloudConfig & { project: string }); + } catch (err) { + if (isMissing(err)) return [await gcloudMissing()]; + if (signal.aborted) throw err; + return [errorItem(err, c, prefs)]; + } +} diff --git a/extensions/google-cloud/lib.ts b/extensions/google-cloud/lib.ts new file mode 100644 index 0000000..dbf9a4e --- /dev/null +++ b/extensions/google-cloud/lib.ts @@ -0,0 +1,336 @@ +// Pure logic of the Google Cloud extension: gcloud JSON mapping, console +// URLs, command lines, error classification. No I/O here. + +/** `https://…/zones/europe-west1-b` → `europe-west1-b`. */ +export const lastSegment = (url: string | undefined) => (url ?? "").split("/").pop() ?? ""; + +// --- Configuration and projects -------------------------------------------------------- + +export interface GcloudConfig { + project?: string; + account?: string; + region?: string; + zone?: string; +} + +export function parseConfig(json: string): GcloudConfig { + const c = JSON.parse(json || "{}") as { + core?: { project?: string; account?: string }; + compute?: { region?: string; zone?: string }; + }; + return { + project: c.core?.project || undefined, + account: c.core?.account || undefined, + region: c.compute?.region || undefined, + zone: c.compute?.zone || undefined, + }; +} + +export interface Project { + id: string; + name: string; + number: string; + state: string; + created?: Date; + parent?: string; + labels: Record; +} + +export function parseProjects(json: string): Project[] { + const data = JSON.parse(json || "[]") as any[]; + return data.map((p) => ({ + id: p.projectId, + name: p.name ?? p.projectId, + number: String(p.projectNumber ?? ""), + state: p.lifecycleState ?? p.state ?? "ACTIVE", + created: p.createTime ? new Date(p.createTime) : undefined, + parent: p.parent ? `${p.parent.type} ${p.parent.id}` : undefined, + labels: p.labels ?? {}, + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +// --- Compute Engine ------------------------------------------------------------------------ + +export interface Instance { + name: string; + id: string; + zone: string; + status: string; + machineType: string; + internalIp?: string; + externalIp?: string; + network?: string; + created?: Date; + labels: Record; + tags: string[]; + spot: boolean; + os?: string; + cpuPlatform?: string; +} + +export function parseInstances(json: string): Instance[] { + const data = JSON.parse(json || "[]") as any[]; + return data.map((i) => { + const nic = i.networkInterfaces?.[0]; + const boot = (i.disks ?? []).find((d: any) => d.boot); + return { + name: i.name, + id: String(i.id ?? ""), + zone: lastSegment(i.zone), + status: i.status ?? "UNKNOWN", + machineType: lastSegment(i.machineType), + internalIp: nic?.networkIP || undefined, + externalIp: nic?.accessConfigs?.find((a: any) => a.natIP)?.natIP || undefined, + network: nic?.network ? lastSegment(nic.network) : undefined, + created: i.creationTimestamp ? new Date(i.creationTimestamp) : undefined, + labels: i.labels ?? {}, + tags: i.tags?.items ?? [], + spot: i.scheduling?.provisioningModel === "SPOT" || i.scheduling?.preemptible === true, + os: boot?.licenses?.length ? lastSegment(boot.licenses[0]) : undefined, + cpuPlatform: i.cpuPlatform || undefined, + }; + }); +} + +/** `europe-west1-b` → `europe-west1`. */ +export const zoneRegion = (zone: string) => zone.replace(/-[a-z]$/, ""); + +export type Health = "ok" | "busy" | "off" | "failed"; + +export function instanceHealth(status: string): Health { + switch (status) { + case "RUNNING": + return "ok"; + case "PROVISIONING": + case "STAGING": + case "STOPPING": + case "SUSPENDING": + case "REPAIRING": + return "busy"; + case "TERMINATED": + case "STOPPED": + case "SUSPENDED": + return "off"; + default: + return "failed"; + } +} + +export const HEALTH_COLORS = { ok: "green", busy: "yellow", off: "gray", failed: "red" } as const; + +/** `TERMINATED` → `Stopped` (what the console shows), others title-cased. */ +export function statusLabel(status: string): string { + if (status === "TERMINATED") return "Stopped"; + return status.charAt(0) + status.slice(1).toLowerCase(); +} + +export function sortInstances(all: Instance[]): Instance[] { + const order: Health[] = ["failed", "busy", "ok", "off"]; + return [...all].sort((a, b) => + order.indexOf(instanceHealth(a.status)) - order.indexOf(instanceHealth(b.status)) || a.name.localeCompare(b.name) + ); +} + +// --- Cloud Run ---------------------------------------------------------------------------- + +export interface Service { + name: string; + region: string; + url?: string; + ready: "True" | "False" | "Unknown"; + reason?: string; + message?: string; + revision?: string; + image?: string; + modifier?: string; + updated?: Date; + ingress?: string; + traffic: { revision: string; percent: number; tag?: string; latest?: boolean }[]; +} + +export function parseServices(json: string): Service[] { + const data = JSON.parse(json || "[]") as any[]; + return data.map((s) => { + const ready = (s.status?.conditions ?? []).find((c: any) => c.type === "Ready"); + const container = s.spec?.template?.spec?.containers?.[0]; + const meta = s.metadata ?? {}; + return { + name: meta.name, + region: meta.labels?.["cloud.googleapis.com/location"] ?? "", + url: s.status?.url || s.status?.address?.url || undefined, + ready: (ready?.status ?? "Unknown") as Service["ready"], + reason: ready?.reason || undefined, + message: ready?.message || undefined, + revision: s.status?.latestReadyRevisionName || s.status?.latestCreatedRevisionName || undefined, + image: container?.image, + modifier: meta.annotations?.["serving.knative.dev/lastModifier"] ?? + meta.annotations?.["serving.knative.dev/creator"], + updated: ready?.lastTransitionTime + ? new Date(ready.lastTransitionTime) + : meta.creationTimestamp + ? new Date(meta.creationTimestamp) + : undefined, + ingress: meta.annotations?.["run.googleapis.com/ingress"], + traffic: (s.status?.traffic ?? []).map((t: any) => ({ + revision: t.revisionName ?? (t.latestRevision ? "latest" : ""), + percent: t.percent ?? 0, + tag: t.tag, + latest: t.latestRevision, + })), + }; + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +export function serviceHealth(s: Service): Health { + return s.ready === "True" ? "ok" : s.ready === "False" ? "failed" : "busy"; +} + +/** `europe-docker.pkg.dev/acme/apps/api@sha256:…` → `api@sha256:1a2b3c4`. */ +export function shortImage(image: string | undefined): string { + if (!image) return ""; + const last = image.split("/").pop()!; + const at = last.indexOf("@sha256:"); + return at > 0 ? `${last.slice(0, at)}@${last.slice(at + 8, at + 15)}` : last; +} + +// --- URLs and commands ------------------------------------------------------------------- + +const C = "https://console.cloud.google.com"; +export const projectConsoleUrl = (p: string) => `${C}/home/dashboard?project=${encodeURIComponent(p)}`; +export const instanceConsoleUrl = (i: Instance, p: string) => + `${C}/compute/instancesDetail/zones/${i.zone}/instances/${encodeURIComponent(i.name)}?project=${ + encodeURIComponent(p) + }`; +export const serviceConsoleUrl = (s: Service, p: string) => + `${C}/run/detail/${s.region}/${encodeURIComponent(s.name)}/metrics?project=${encodeURIComponent(p)}`; +export const serviceLogsUrl = (s: Service, p: string) => + `${C}/run/detail/${s.region}/${encodeURIComponent(s.name)}/logs?project=${encodeURIComponent(p)}`; + +export const sshCommand = ( + i: Instance, + p: string, +) => ["gcloud", "compute", "ssh", i.name, `--zone=${i.zone}`, `--project=${p}`]; + +export const serialCommand = (i: Instance, p: string) => [ + "gcloud", + "compute", + "instances", + "tail-serial-port-output", + i.name, + `--zone=${i.zone}`, + `--project=${p}`, +]; + +export const instanceLogsCommand = (i: Instance, p: string) => [ + "gcloud", + "logging", + "read", + `resource.type="gce_instance" AND resource.labels.instance_id="${i.id}"`, + "--freshness=1d", + "--limit=200", + "--order=asc", + "--format=value(timestamp,severity,textPayload,jsonPayload.message)", + `--project=${p}`, +]; + +export const tailServiceCommand = (s: Service, p: string) => [ + "gcloud", + "beta", + "run", + "services", + "logs", + "tail", + s.name, + `--region=${s.region}`, + `--project=${p}`, +]; + +export const readServiceLogsCommand = (s: Service, p: string, limit = 100) => [ + "gcloud", + "run", + "services", + "logs", + "read", + s.name, + `--region=${s.region}`, + `--project=${p}`, + `--limit=${limit}`, +]; + +// --- Errors ------------------------------------------------------------------------------ + +export type Problem = "auth" | "api" | "denied" | "project" | "network" | "other"; + +export function classifyError(err: unknown): { problem: Problem; title: string; message: string; api?: string } { + const raw = (err instanceof Error ? err.message : String(err)).replace(/^gcloud exited with \d+: /, "").trim(); + const message = raw.split("\n").filter((l) => !/^\s*(- '@type'|metadata:|method:|service:|reason:)/.test(l)).join( + "\n", + ).trim(); + const is = (re: RegExp) => re.test(raw); + if ( + is( + /You do not currently have an active account|gcloud auth login|UNAUTHENTICATED|Reauthentication|refresh token|invalid_grant/i, + ) + ) { + return { problem: "auth", title: "Sign in to Google Cloud", message }; + } + const api = + /(\w+\.googleapis\.com) (?:has not been used|is not enabled)|SERVICE_DISABLED|API \[([\w.]+)\] not enabled/.exec( + raw, + ); + if (api) return { problem: "api", title: "API not enabled for this project", message, api: api[1] ?? api[2] }; + if ( + is( + /required '?project'? property|project property is not set|The required property \[project\] is not currently set/i, + ) + ) { + return { problem: "project", title: "No project selected", message }; + } + if (is(/PERMISSION_DENIED|does not have .* permission|Required '[\w.]+' permission|403/)) { + return { problem: "denied", title: "Permission denied", message }; + } + if (is(/Unable to find|Failed to connect|Connection aborted|timed out|Name or service not known|TransportError/i)) { + return { problem: "network", title: "Cannot reach Google Cloud", message }; + } + return { problem: "other", title: "gcloud failed", message }; +} + +// --- Formatting ------------------------------------------------------------------------- + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) 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`; +} + +export const formatDate = (d?: Date) => + d && !isNaN(d.getTime()) ? d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : ""; + +// --- Installing the CLI -------------------------------------------------------------------- + +export type Family = "debian" | "fedora" | "arch" | "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 function installCommand(family: Family | undefined): string { + switch (family) { + case "arch": + return "yay -S google-cloud-cli # AUR"; + case "fedora": + return "sudo dnf install google-cloud-cli # after adding Google's repository"; + default: + return "sudo snap install google-cloud-cli --classic"; + } +} diff --git a/extensions/google-cloud/lib_test.ts b/extensions/google-cloud/lib_test.ts new file mode 100644 index 0000000..de6bbf1 --- /dev/null +++ b/extensions/google-cloud/lib_test.ts @@ -0,0 +1,180 @@ +import { assertEquals } from "@std/assert"; +import { + age, + classifyError, + installCommand, + instanceConsoleUrl, + instanceHealth, + instanceLogsCommand, + lastSegment, + osFamily, + parseConfig, + parseInstances, + parseProjects, + parseServices, + serviceConsoleUrl, + serviceHealth, + shortImage, + sortInstances, + sshCommand, + statusLabel, + tailServiceCommand, + zoneRegion, +} from "./lib.ts"; +import { holdOpen, terminalArgs } from "./terminal.ts"; + +Deno.test("config", () => { + assertEquals( + parseConfig( + JSON.stringify({ core: { project: "acme-prod", account: "a@acme.io" }, compute: { zone: "europe-west1-b" } }), + ), + { project: "acme-prod", account: "a@acme.io", region: undefined, zone: "europe-west1-b" }, + ); + assertEquals(parseConfig("{}").project, undefined); +}); + +Deno.test("projects", () => { + const projects = parseProjects(JSON.stringify([ + { lifecycleState: "ACTIVE", name: "Zeta", projectId: "zeta-1", projectNumber: 2 }, + { + createTime: "2023-02-01T10:00:00.000Z", + lifecycleState: "ACTIVE", + name: "Acme Production", + parent: { id: "4455", type: "organization" }, + projectId: "acme-prod", + projectNumber: "123456789012", + labels: { env: "prod" }, + }, + ])); + assertEquals(projects.map((p) => p.id), ["acme-prod", "zeta-1"]); + assertEquals(projects[0].parent, "organization 4455"); + assertEquals(projects[1].number, "2"); +}); + +const Z = "https://www.googleapis.com/compute/v1/projects/acme-prod/zones/"; + +Deno.test("Compute Engine instances", () => { + const all = parseInstances(JSON.stringify([ + { + id: "8812345678901234567", + name: "web-1", + zone: `${Z}europe-west1-b`, + status: "RUNNING", + machineType: `${Z}europe-west1-b/machineTypes/e2-medium`, + networkInterfaces: [{ + networkIP: "10.132.0.2", + network: "https://www.googleapis.com/compute/v1/projects/acme-prod/global/networks/default", + accessConfigs: [{ name: "External NAT", natIP: "34.77.12.9" }], + }], + tags: { items: ["http-server"] }, + disks: [{ boot: true, licenses: ["https://x/licenses/debian-12-bookworm"] }], + }, + { id: "2", name: "batch", zone: `${Z}europe-west4-a`, status: "TERMINATED", scheduling: { preemptible: true } }, + { id: "3", name: "db", zone: `${Z}us-central1-a`, status: "STAGING" }, + ])); + assertEquals(all[0].zone, "europe-west1-b"); + assertEquals(all[0].machineType, "e2-medium"); + assertEquals(all[0].externalIp, "34.77.12.9"); + assertEquals(all[0].network, "default"); + assertEquals(all[0].os, "debian-12-bookworm"); + assertEquals(all[1].spot, true); + assertEquals(all[1].internalIp, undefined); + assertEquals(sortInstances(all).map((i) => i.name), ["db", "web-1", "batch"]); + assertEquals(instanceHealth("TERMINATED"), "off"); + assertEquals(instanceHealth("REPAIRING"), "busy"); + assertEquals(statusLabel("TERMINATED"), "Stopped"); + assertEquals(statusLabel("RUNNING"), "Running"); + assertEquals(zoneRegion("europe-west1-b"), "europe-west1"); + assertEquals(lastSegment(undefined), ""); + assertEquals(sshCommand(all[0], "acme-prod"), [ + "gcloud", + "compute", + "ssh", + "web-1", + "--zone=europe-west1-b", + "--project=acme-prod", + ]); + assertEquals( + instanceConsoleUrl(all[0], "acme-prod"), + "https://console.cloud.google.com/compute/instancesDetail/zones/europe-west1-b/instances/web-1?project=acme-prod", + ); + assertEquals( + instanceLogsCommand(all[0], "p")[3], + 'resource.type="gce_instance" AND resource.labels.instance_id="8812345678901234567"', + ); +}); + +Deno.test("Cloud Run services", () => { + const services = parseServices(JSON.stringify([ + { + metadata: { + name: "worker", + labels: { "cloud.googleapis.com/location": "europe-west1" }, + annotations: { "serving.knative.dev/lastModifier": "ci@acme.iam.gserviceaccount.com" }, + }, + spec: { + template: { spec: { containers: [{ image: "europe-docker.pkg.dev/acme/apps/worker@sha256:1a2b3c4d5e6f" }] } }, + }, + status: { + url: "https://worker-abc-ew.a.run.app", + latestReadyRevisionName: "worker-00042-xiz", + conditions: [{ + type: "Ready", + status: "False", + reason: "RevisionFailed", + message: "Container failed to start", + lastTransitionTime: "2026-10-27T15:00:00Z", + }], + traffic: [{ revisionName: "worker-00042-xiz", percent: 100, latestRevision: true }], + }, + }, + { metadata: { name: "api", labels: { "cloud.googleapis.com/location": "us-central1" } }, status: {} }, + ])); + assertEquals(services.map((s) => s.name), ["api", "worker"]); + const w = services[1]; + assertEquals(serviceHealth(w), "failed"); + assertEquals(serviceHealth(services[0]), "busy"); + assertEquals(w.reason, "RevisionFailed"); + assertEquals(w.modifier, "ci@acme.iam.gserviceaccount.com"); + assertEquals(w.traffic, [{ revision: "worker-00042-xiz", percent: 100, tag: undefined, latest: true }]); + assertEquals(shortImage(w.image), "worker@1a2b3c4"); + assertEquals(shortImage("gcr.io/acme/api:1.4"), "api:1.4"); + assertEquals( + serviceConsoleUrl(w, "acme"), + "https://console.cloud.google.com/run/detail/europe-west1/worker/metrics?project=acme", + ); + assertEquals(tailServiceCommand(w, "acme").slice(0, 6), ["gcloud", "beta", "run", "services", "logs", "tail"]); +}); + +Deno.test("errors", () => { + const c = (m: string) => classifyError(new Error(`gcloud exited with 1: ${m}`)); + assertEquals( + c("ERROR: (gcloud.projects.list) You do not currently have an active account selected.\nPlease run:\n\n $ gcloud auth login") + .problem, + "auth", + ); + assertEquals( + c("ERROR: (gcloud.projects.list) UNAUTHENTICATED: Request had invalid authentication credentials.\n- '@type': type.googleapis.com/google.rpc.ErrorInfo\n reason: CREDENTIALS_MISSING") + .message, + "ERROR: (gcloud.projects.list) UNAUTHENTICATED: Request had invalid authentication credentials.", + ); + const api = c( + "ERROR: (gcloud.compute.instances.list) Compute Engine API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/compute.googleapis.com/overview?project=123 then retry.\n compute.googleapis.com has not been used", + ); + assertEquals(api.problem, "api"); + assertEquals(api.api, "compute.googleapis.com"); + assertEquals( + c("ERROR: (gcloud.run.services.list) PERMISSION_DENIED: Permission 'run.services.list' denied").problem, + "denied", + ); + assertEquals(c("ERROR: The required property [project] is not currently set.").problem, "project"); + assertEquals(c("boom").problem, "other"); +}); + +Deno.test("formatting and install hints", () => { + assertEquals(age(new Date("2026-10-27T15:00:00Z"), new Date("2026-10-28T10:00:00Z")), "19h"); + assertEquals(installCommand(osFamily("ID=arch")), "yay -S google-cloud-cli # AUR"); + assertEquals(installCommand(osFamily("ID=ubuntu\nID_LIKE=debian")), "sudo snap install google-cloud-cli --classic"); + assertEquals(terminalArgs("foot", ["gcloud", "compute", "ssh", "x"]), ["gcloud", "compute", "ssh", "x"]); + assertEquals(holdOpen(["gcloud", "auth", "login"])[2].startsWith("gcloud auth login;"), true); +}); diff --git a/extensions/google-cloud/terminal.ts b/extensions/google-cloud/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/google-cloud/terminal.ts @@ -0,0 +1,89 @@ +// 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]; + } +} + +/** + * Wraps a command that ends by itself (a log dump, a login) so the terminal + * stays open on its output until the user presses Enter. + */ +export function holdOpen(argv: string[]): string[] { + return ["sh", "-c", `${shellLine(argv)}; printf '\\n[Press Enter to close] '; read -r _`]; +} + +/** + * Starts `argv` in a new terminal window that outlives the command. `env` is + * added to the terminal's environment (secrets go there, never in `argv`). + */ +export function runInTerminal(argv: string[], preferred?: string, env?: Record): 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", + env, + }).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/nerd-fonts/backslash.toml b/extensions/nerd-fonts/backslash.toml new file mode 100644 index 0000000..8916a20 --- /dev/null +++ b/extensions/nerd-fonts/backslash.toml @@ -0,0 +1,19 @@ +[extension] +id = "nerd-fonts" +title = "Nerd Font Icons" +description = "Search every Nerd Font glyph by name or code point and copy the glyph, its class, code point or HTML entity" +version = "1.0.0" +author = "Backslash" +icon = "bs-font" +license = "MIT" + +# The official glyph list (glyphnames.json), downloaded once and cached. +[permissions] +net = ["raw.githubusercontent.com"] + +[[commands]] +name = "search" +title = "Search Nerd Font Icons" +description = "11,000 glyphs of Material, Font Awesome, Devicons, Codicons, Octicons, Powerline…" +icon = "bs-font" +keywords = ["nerd font", "nerdfont", "glyph", "icon", "symbol", "powerline", "devicons", "font awesome", "icône", "glyphe", "police"] diff --git a/extensions/nerd-fonts/commands/search.ts b/extensions/nerd-fonts/commands/search.ts new file mode 100644 index 0000000..2b09d7f --- /dev/null +++ b/extensions/nerd-fonts/commands/search.ts @@ -0,0 +1,154 @@ +import { + type Action, + action, + clipboard, + close, + copy, + Icons, + type Item, + list, + meta, + open, + refresh, + storage, + toast, +} from "@backslash/api"; +import { + charOf, + className, + codePoint, + countBySet, + cssContent, + type Glyph, + htmlEntity, + jsEscape, + prefixOf, + remember, + searchGlyphs, + SET_NAMES, + SETS, + shortTitle, +} from "../lib.ts"; +import { download, glyphs, POPULAR, recent, reset } from "../glyphs.ts"; + +const CHEAT_SHEET = "https://www.nerdfonts.com/cheat-sheet"; +const DOWNLOADS = "https://www.nerdfonts.com/font-downloads"; + +async function used(name: string) { + await storage.set("recent", remember(await recent(), name)).catch(() => {}); +} + +function glyphItem([name, code]: Glyph, section: string): Item { + const char = charOf(code); + const cls = className(name); + const actions: Action[] = [ + action("Copy Glyph", async () => { + await used(name); + await clipboard.write(char); + return close(`Copied ${cls}`); + }, { icon: Icons.copy }), + action("Paste Glyph", async () => { + await used(name); + await clipboard.paste(char); + }, { icon: Icons.paste }), + copy(cls, `Copy Class (${cls})`, { icon: Icons.code, shortcut: "period", section: "Copy" }), + copy(codePoint(code), `Copy Code Point (${codePoint(code)})`, { + icon: Icons.hash, + shortcut: "c", + section: "Copy", + }), + copy(jsEscape(code), `Copy Escape (${jsEscape(code)})`, { icon: Icons.braces, section: "Copy" }), + copy(htmlEntity(code), `Copy HTML Entity (${htmlEntity(code)})`, { icon: Icons.globe, section: "Copy" }), + copy(cssContent(code), `Copy CSS Content (${cssContent(code)})`, { icon: Icons.palette, section: "Copy" }), + copy(``, "Copy HTML Tag", { icon: Icons.code, section: "Copy" }), + ]; + return { + id: `${section}:${name}`, + title: shortTitle(name), + subtitle: cls, + icon: char, + section, + keywords: [cls, code], + detail: { + markdown: `# ${char}`, + metadata: [ + meta.text("Class", cls, { icon: Icons.code }), + meta.text("Code point", codePoint(code), { icon: Icons.hash }), + meta.text("HTML", htmlEntity(code)), + meta.tags("Set", [SET_NAMES[prefixOf(name)] ?? prefixOf(name)]), + ], + }, + actions, + }; +} + +const updateAction = action("Update Glyph List", async () => { + await toast("Downloading glyphnames.json…"); + const data = await download(); + reset(); + await toast(`${data.glyphs.length.toLocaleString("en-US")} glyphs`, { + style: "success", + message: data.version ? `Nerd Fonts ${data.version}` : undefined, + }); + return refresh(); +}, { icon: Icons.refresh, shortcut: "r", section: "Glyphs" }); + +const globalActions: Action[] = [ + updateAction, + open(CHEAT_SHEET, "Open Nerd Fonts Cheat Sheet", { icon: Icons.globe, section: "Glyphs" }), + open(DOWNLOADS, "Download a Nerd Font", { icon: Icons.download, section: "Glyphs" }), +]; + +let summary = ""; + +export default list({ + layout: "grid", + columns: 8, + placeholder: "Search Nerd Font icons: folder, git branch, nf-md-…, f07c", + filter: { options: SETS.map(({ value, title }) => ({ value, title })), default: "all" }, + empty: { + title: "No glyph found", + description: + "Try another word or set. Glyphs show only if your font has them: install a Nerd Font (Ctrl+K → Download a Nerd Font).", + icon: Icons.font, + }, + status: () => summary, + actions: globalActions, + async search(query, { filter }) { + const data = await glyphs(); + const set = filter ?? "all"; + const counts = countBySet(data.glyphs); + const setTitle = SETS.find((s) => s.value === set)?.title ?? "All"; + const about = data.source === "fallback" + ? `Offline: ${data.glyphs.length} popular glyphs` + : `${counts[set].toLocaleString("en-US")} glyphs${set === "all" ? "" : ` in ${setTitle}`}${ + data.version ? ` · Nerd Fonts ${data.version}` : "" + }`; + const items: Item[] = []; + if (data.source === "fallback") { + items.push({ + id: "offline", + title: "Offline", + subtitle: `Could not download the glyph list: ${data.error}`, + icon: { source: Icons.wifiOff, tint: "orange" }, + section: "Glyph list", + detail: { markdown: `Showing ${data.glyphs.length} popular glyphs. ${data.error ?? ""}` }, + actions: [updateAction], + }); + } + if (!query.trim()) { + const names = new Map(data.glyphs.map((g) => [g[0], g])); + const mine = (await recent()).map((n) => names.get(n)).filter((g): g is Glyph => !!g) + .filter((g) => searchGlyphs([g], "", set).length); + items.push(...mine.map((g) => glyphItem(g, "Recently Copied"))); + const browse = set === "all" ? POPULAR.filter((g) => names.has(g[0])) : searchGlyphs(data.glyphs, "", set, 240); + items.push(...browse.map((g) => glyphItem(g, set === "all" ? "Popular" : setTitle))); + summary = about; + return items; + } + const found = searchGlyphs(data.glyphs, query, set); + summary = `${found.length === 240 ? "240+" : found.length} match${found.length === 1 ? "" : "es"} · ${about}`; + items.push(...found.map((g) => glyphItem(g, "Results"))); + return items; + }, +}); diff --git a/extensions/nerd-fonts/fallback.json b/extensions/nerd-fonts/fallback.json new file mode 100644 index 0000000..6f9010b --- /dev/null +++ b/extensions/nerd-fonts/fallback.json @@ -0,0 +1,321 @@ +[ + ["dev-git", "e702"], + ["dev-github", "e709"], + ["dev-github_badge", "e709"], + ["dev-python", "e73c"], + ["dev-rust", "e7a8"], + ["dev-docker", "e7b0"], + ["dev-linux", "e712"], + ["dev-apple", "e711"], + ["dev-windows", "e70f"], + ["dev-javascript", "e781"], + ["dev-typescript", "e8ca"], + ["dev-html5", "e736"], + ["dev-css3", "e749"], + ["dev-react", "e7ba"], + ["dev-nodejs_small", "e718"], + ["dev-go", "e724"], + ["dev-java", "e738"], + ["dev-vim", "e7c5"], + ["dev-terminal", "e795"], + ["dev-npm", "e71e"], + ["dev-kubernetes", "e81d"], + ["dev-aws", "e7ad"], + ["dev-database", "e706"], + ["dev-ruby", "e739"], + ["dev-php", "e73d"], + ["dev-swift", "e755"], + ["dev-kotlin", "e81b"], + ["dev-c", "e771"], + ["dev-cplusplus", "e7a3"], + ["dev-lua", "e826"], + ["dev-dart", "e798"], + ["dev-android", "e70e"], + ["dev-firefox", "e745"], + ["dev-chrome", "e743"], + ["dev-visualstudio", "e70c"], + ["dev-vscode", "e8da"], + ["dev-gitlab", "e7eb"], + ["dev-bitbucket", "e703"], + ["dev-postgresql", "e76e"], + ["dev-mysql", "e704"], + ["dev-redis", "e76d"], + ["dev-mongodb", "e7a4"], + ["dev-sqlite", "e7c4"], + ["dev-markdown", "e73e"], + ["dev-json", "e80b"], + ["dev-yaml", "e8eb"], + ["dev-nixos", "e843"], + ["dev-debian", "e77d"], + ["dev-ubuntu", "e73a"], + ["dev-fedora", "e7d9"], + ["dev-archlinux", "e732"], + ["cod-github", "ea84"], + ["cod-git_branch", "ec6f"], + ["cod-git_commit", "eafc"], + ["cod-git_merge", "eafe"], + ["cod-git_pull_request", "ea64"], + ["cod-terminal", "ea85"], + ["cod-folder", "ea83"], + ["cod-folder_opened", "eaf7"], + ["cod-file", "ea7b"], + ["cod-file_code", "eae9"], + ["cod-search", "ea6d"], + ["cod-settings_gear", "eb51"], + ["cod-check", "eab2"], + ["cod-close", "ea76"], + ["cod-warning", "ea6c"], + ["cod-error", "ea87"], + ["cod-info", "ea74"], + ["cod-bug", "eaaf"], + ["cod-rocket", "eb44"], + ["cod-lightbulb", "ea61"], + ["cod-heart", "eb05"], + ["cod-star_full", "eb59"], + ["cod-calendar", "eab0"], + ["cod-cloud", "ebaa"], + ["cod-cloud_download", "eac2"], + ["cod-cloud_upload", "eac3"], + ["cod-lock", "ea75"], + ["cod-unlock", "eb74"], + ["cod-account", "eb99"], + ["cod-home", "eb06"], + ["cod-bell", "eaa2"], + ["cod-trash", "ea81"], + ["cod-edit", "ea73"], + ["cod-save", "eb4b"], + ["cod-copy", "ebcc"], + ["cod-debug", "ead8"], + ["cod-play", "eb2c"], + ["cod-debug_pause", "ead1"], + ["cod-refresh", "eb37"], + ["cod-sync", "ea77"], + ["cod-extensions", "eae6"], + ["cod-package", "eb29"], + ["cod-database", "eace"], + ["cod-server", "eb50"], + ["cod-key", "eb11"], + ["cod-mail", "eb1c"], + ["cod-link", "eb15"], + ["cod-pin", "eb2b"], + ["cod-tag", "ea66"], + ["cod-eye", "ea70"], + ["cod-code", "eac4"], + ["cod-json", "eb0f"], + ["cod-markdown", "eb1d"], + ["fa-github", "f09b"], + ["fa-gitlab", "f296"], + ["fa-git", "f1d3"], + ["fa-folder", "f07b"], + ["fa-folder_open", "f07c"], + ["fa-file", "f15b"], + ["fa-home", "f015"], + ["fa-search", "f002"], + ["fa-gear", "f013"], + ["fa-check", "f00c"], + ["fa-close", "f00d"], + ["fa-warning", "f071"], + ["fa-info_circle", "f05a"], + ["fa-heart", "f004"], + ["fa-star", "f005"], + ["fa-clock_o", "f017"], + ["fa-calendar", "f073"], + ["fa-battery_full", "f240"], + ["fa-battery_half", "f242"], + ["fa-battery_empty", "f244"], + ["fa-wifi", "f1eb"], + ["fa-bluetooth", "f293"], + ["fa-volume_up", "f028"], + ["fa-volume_off", "f026"], + ["fa-music", "f001"], + ["fa-play", "f04b"], + ["fa-pause", "f04c"], + ["fa-stop", "f04d"], + ["fa-lock", "f023"], + ["fa-user", "f007"], + ["fa-cloud", "f0c2"], + ["fa-download", "f019"], + ["fa-upload", "f093"], + ["fa-code", "f121"], + ["fa-code_fork", "f126"], + ["fa-bug", "f188"], + ["fa-rocket", "f135"], + ["fa-fire", "f06d"], + ["fa-lightbulb_o", "f0eb"], + ["fa-terminal", "f120"], + ["fa-power_off", "f011"], + ["fa-bell", "f0f3"], + ["fa-envelope", "f0e0"], + ["fa-trash", "f1f8"], + ["fa-pencil", "f040"], + ["fa-camera", "f030"], + ["fa-desktop", "f108"], + ["fa-laptop", "f109"], + ["fa-mobile", "ed08"], + ["fa-keyboard_o", "f11c"], + ["fa-microchip", "f2db"], + ["fa-hdd_o", "f0a0"], + ["fa-server", "f233"], + ["fa-database", "f1c0"], + ["fa-plug", "f1e6"], + ["fa-coffee", "f0f4"], + ["fa-linux", "f17c"], + ["fa-apple", "f179"], + ["fa-windows", "f17a"], + ["fa-android", "f17b"], + ["fa-docker", "f21f"], + ["fa-python", "ed1b"], + ["fa-npm", "ed0e"], + ["fa-spotify", "f1bc"], + ["fa-slack", "f198"], + ["fa-discord", "f1ff"], + ["fa-telegram", "f2c6"], + ["fa-reddit", "f1a1"], + ["fa-twitter", "f099"], + ["fa-youtube", "f16a"], + ["fa-firefox", "f269"], + ["fa-chrome", "f268"], + ["linux-archlinux", "f303"], + ["linux-debian", "f306"], + ["linux-ubuntu", "f31b"], + ["linux-fedora", "f30a"], + ["linux-nixos", "f313"], + ["linux-tux", "f31a"], + ["linux-manjaro", "f312"], + ["linux-opensuse", "f314"], + ["linux-gentoo", "f30d"], + ["linux-pop_os", "f32a"], + ["linux-endeavour", "f322"], + ["linux-alpine", "f300"], + ["linux-raspberry_pi", "f315"], + ["linux-redhat", "f316"], + ["linux-centos", "f304"], + ["linux-void", "f32e"], + ["linux-kali_linux", "f327"], + ["linux-elementary", "f309"], + ["linux-gnome", "f361"], + ["linux-kde", "f373"], + ["linux-hyprland", "f359"], + ["linux-sway", "f35d"], + ["linux-i3", "f35a"], + ["linux-neovim", "f36f"], + ["linux-wayland", "f367"], + ["linux-xorg", "f369"], + ["md-folder", "f024b"], + ["md-folder_open", "f0770"], + ["md-file", "f0214"], + ["md-home", "f02dc"], + ["md-magnify", "f0349"], + ["md-cog", "f0493"], + ["md-check", "f012c"], + ["md-close", "f0156"], + ["md-alert", "f0026"], + ["md-heart", "f02d1"], + ["md-star", "f04ce"], + ["md-clock", "f0954"], + ["md-calendar", "f00ed"], + ["md-battery", "f0079"], + ["md-wifi", "f05a9"], + ["md-bluetooth", "f00af"], + ["md-volume_high", "f057e"], + ["md-music", "f075a"], + ["md-play", "f040a"], + ["md-pause", "f03e4"], + ["md-lock", "f033e"], + ["md-account", "f0004"], + ["md-cloud", "f015f"], + ["md-download", "f01da"], + ["md-upload", "f0552"], + ["md-code_braces", "f0169"], + ["md-source_branch", "f062c"], + ["md-bug", "f00e4"], + ["md-rocket", "f0463"], + ["md-fire", "f0238"], + ["md-lightbulb", "f0335"], + ["md-console", "f018d"], + ["md-power", "f0425"], + ["md-bell", "f009a"], + ["md-email", "f01ee"], + ["md-delete", "f01b4"], + ["md-pencil", "f03eb"], + ["md-language_python", "f0320"], + ["md-language_rust", "f1617"], + ["md-language_typescript", "f06e6"], + ["md-language_javascript", "f031e"], + ["md-language_go", "f07d3"], + ["md-docker", "f0868"], + ["md-kubernetes", "f10fe"], + ["md-github", "f02a4"], + ["md-git", "f02a2"], + ["md-npm", "f06f7"], + ["md-nodejs", "f0399"], + ["md-react", "f0708"], + ["md-vuejs", "f0844"], + ["md-linux", "f033d"], + ["md-apple", "f0035"], + ["md-microsoft_windows", "f05b3"], + ["md-ubuntu", "f0548"], + ["md-arch", "f08c7"], + ["md-memory", "f035b"], + ["md-harddisk", "f02ca"], + ["md-thermometer", "f050f"], + ["md-weather_sunny", "f0599"], + ["md-weather_cloudy", "f0590"], + ["md-weather_rainy", "f0597"], + ["md-weather_snowy", "f0598"], + ["md-weather_night", "f0594"], + ["oct-mark_github", "f408"], + ["oct-repo", "f401"], + ["oct-git_branch", "f418"], + ["oct-git_commit", "f417"], + ["oct-git_pull_request", "f407"], + ["oct-issue_opened", "f41b"], + ["oct-check", "f42e"], + ["oct-x", "f467"], + ["oct-alert", "f421"], + ["oct-gear", "f423"], + ["oct-terminal", "f489"], + ["oct-file", "f4a5"], + ["oct-file_directory", "f413"], + ["oct-search", "f422"], + ["oct-star", "f41e"], + ["oct-heart", "2665"], + ["oct-rocket", "f427"], + ["oct-bug", "f46f"], + ["oct-zap", "26a1"], + ["oct-package", "f487"], + ["weather-day_sunny", "e30d"], + ["weather-cloudy", "e312"], + ["weather-rain", "e318"], + ["weather-snow", "e31a"], + ["weather-thunderstorm", "e31d"], + ["weather-fog", "e313"], + ["weather-night_clear", "e32b"], + ["weather-windy", "e31e"], + ["weather-thermometer", "e350"], + ["weather-umbrella", "e37c"], + ["pl-left_hard_divider", "e0b0"], + ["pl-right_hard_divider", "e0b2"], + ["pl-left_soft_divider", "e0b1"], + ["pl-right_soft_divider", "e0b3"], + ["pl-branch", "e0a0"], + ["pl-line_number", "e0a1"], + ["ple-left_half_circle_thick", "e0b6"], + ["ple-right_half_circle_thick", "e0b4"], + ["ple-flame_thick", "e0c0"], + ["ple-pixelated_squares_big", "e0c6"], + ["seti-python", "e606"], + ["seti-rust", "e68b"], + ["seti-go", "e627"], + ["seti-typescript", "e628"], + ["seti-javascript", "e60c"], + ["seti-html", "e60e"], + ["seti-css", "e614"], + ["seti-json", "e60b"], + ["seti-markdown", "e609"], + ["seti-docker", "e650"], + ["seti-git", "e65d"], + ["seti-lua", "e620"], + ["seti-shell", "e691"], + ["seti-config", "e615"] +] diff --git a/extensions/nerd-fonts/glyphs.ts b/extensions/nerd-fonts/glyphs.ts new file mode 100644 index 0000000..54f585b --- /dev/null +++ b/extensions/nerd-fonts/glyphs.ts @@ -0,0 +1,56 @@ +// Loading the glyph list: from storage, else downloaded once from the +// nerd-fonts repository, else the small list shipped with the extension. + +import { storage } from "@backslash/api"; +import { type Glyph, type GlyphData, GLYPHNAMES_URL, isGlyphData, parseGlyphnames, STALE_AFTER } from "./lib.ts"; +import popular from "./fallback.json" with { type: "json" }; + +export const POPULAR = popular as Glyph[]; + +export type Loaded = GlyphData & { + source: "cache" | "download" | "fallback"; + error?: string; +}; + +export async function download(signal?: AbortSignal): Promise { + const res = await fetch(GLYPHNAMES_URL, { signal }); + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); + const data = parseGlyphnames(await res.text()); + await storage.set("glyphs", data); + return data; +} + +let loading: Promise | undefined; + +/** Loaded once per process; a stale cache is used while a fresh copy downloads. */ +export function glyphs(): Promise { + loading ??= (async () => { + const cached = await storage.get("glyphs").catch(() => undefined); + if (isGlyphData(cached)) { + if (Date.now() - cached.fetched > STALE_AFTER) download().catch(() => {}); + return { ...cached, source: "cache" as const }; + } + try { + return { ...(await download()), source: "download" as const }; + } catch (err) { + loading = undefined; // Try the network again next time. + return { + version: "", + fetched: 0, + glyphs: POPULAR, + source: "fallback" as const, + error: err instanceof Error ? err.message : String(err), + }; + } + })(); + return loading; +} + +export function reset() { + loading = undefined; +} + +export async function recent(): Promise { + const r = await storage.get("recent").catch(() => undefined); + return Array.isArray(r) ? r : []; +} diff --git a/extensions/nerd-fonts/lib.ts b/extensions/nerd-fonts/lib.ts new file mode 100644 index 0000000..75a8e69 --- /dev/null +++ b/extensions/nerd-fonts/lib.ts @@ -0,0 +1,166 @@ +// Pure logic of the Nerd Font Icons extension: the glyph list, search, +// sets and the text forms of a glyph. No I/O here. + +export const GLYPHNAMES_URL = "https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/glyphnames.json"; + +/** `[name without nf-, hex code]`, e.g. `["md-folder_open", "f0770"]`. */ +export type Glyph = [string, string]; + +// A type alias (not an interface) so that it is plain JSON for storage. +export type GlyphData = { + version: string; + fetched: number; + glyphs: Glyph[]; +}; + +/** Reads the official glyphnames.json into a compact list. */ +export function parseGlyphnames(json: string, now = Date.now()): GlyphData { + const data = JSON.parse(json) as Record; + const version = String((data.METADATA as { version?: string } | undefined)?.version ?? ""); + const glyphs: Glyph[] = []; + for (const [name, g] of Object.entries(data)) { + if (name === "METADATA" || !g || typeof g.code !== "string" || !/^[0-9a-f]{4,6}$/i.test(g.code)) continue; + glyphs.push([name, g.code.toLowerCase()]); + } + if (glyphs.length < 100) throw new Error("glyphnames.json looks incomplete"); + return { version, fetched: now, glyphs }; +} + +export function isGlyphData(v: unknown): v is GlyphData { + const d = v as GlyphData; + return !!d && typeof d === "object" && Array.isArray(d.glyphs) && d.glyphs.length > 0 && + typeof d.fetched === "number"; +} + +/** Refreshed monthly: Nerd Fonts releases a few times a year. */ +export const STALE_AFTER = 30 * 86400 * 1000; + +// --- Sets --------------------------------------------------------------------------- + +export interface GlyphSet { + value: string; + title: string; + prefixes: string[]; +} + +export const SETS: GlyphSet[] = [ + { value: "all", title: "All", prefixes: [] }, + { value: "md", title: "Material", prefixes: ["md"] }, + { value: "fa", title: "Font Awesome", prefixes: ["fa", "fae"] }, + { value: "dev", title: "Devicons", prefixes: ["dev"] }, + { value: "cod", title: "Codicons", prefixes: ["cod"] }, + { value: "oct", title: "Octicons", prefixes: ["oct"] }, + { value: "linux", title: "Logos", prefixes: ["linux"] }, + { value: "seti", title: "Seti", prefixes: ["seti", "custom"] }, + { value: "weather", title: "Weather", prefixes: ["weather"] }, + { value: "pl", title: "Powerline", prefixes: ["pl", "ple", "iec", "indent", "indentation", "extra", "pom"] }, +]; + +export const SET_NAMES: Record = { + md: "Material Design", + fa: "Font Awesome", + fae: "Font Awesome Extension", + dev: "Devicons", + cod: "Codicons", + oct: "Octicons", + linux: "Font Logos", + seti: "Seti UI", + custom: "Seti UI + Custom", + weather: "Weather Icons", + pl: "Powerline", + ple: "Powerline Extra", + iec: "IEC Power Symbols", + indent: "Indent", + indentation: "Indent", + extra: "Extra", + pom: "Pomicons", +}; + +export const prefixOf = (name: string) => name.split("-")[0]; + +export function inSet(name: string, set: string | undefined): boolean { + const s = SETS.find((x) => x.value === set); + return !s || !s.prefixes.length || s.prefixes.includes(prefixOf(name)); +} + +// --- Text forms ------------------------------------------------------------------------ + +export const charOf = (code: string) => String.fromCodePoint(parseInt(code, 16)); +export const className = (name: string) => `nf-${name}`; +export const codePoint = (code: string) => `U+${code.toUpperCase()}`; +export const htmlEntity = (code: string) => `&#x${code};`; +export const cssContent = (code: string) => `\\${code}`; +/** ``, or `\u{f0770}` beyond the BMP (JavaScript, Rust, Python 3 use their own forms). */ +export const jsEscape = (code: string) => code.length > 4 ? `\\u{${code}}` : `\\u${code}`; + +/** `md-folder_open` → `folder open`. */ +export const shortTitle = (name: string) => name.slice(name.indexOf("-") + 1).replace(/_/g, " "); + +// --- Search ------------------------------------------------------------------------------ + +/** + * Finds glyphs by name words (`folder open`, `nf-md-folder`), by code point + * (`f07c`, `U+F07C`, ``) or by the glyph itself. Best matches first. + */ +export function searchGlyphs(glyphs: Glyph[], query: string, set?: string, limit = 240): Glyph[] { + const q = query.trim().toLowerCase().replace(/^nf-/, ""); + if (!q) return glyphs.filter((g) => inSet(g[0], set)).slice(0, limit); + + // The glyph itself, pasted. + const chars = [...q]; + if (chars.length === 1 && q.codePointAt(0)! >= 0xe000) { + const code = q.codePointAt(0)!.toString(16); + return glyphs.filter((g) => g[1] === code); + } + const hex = /^(?:u\+|\\u\{?|0x|&#x)?([0-9a-f]{4,6})\}?;?$/.exec(q)?.[1]; + + const tokens = q.split(/[\s_\-]+/).filter(Boolean); + const scored: { g: Glyph; score: number }[] = []; + for (const g of glyphs) { + if (!inSet(g[0], set)) continue; + const name = g[0]; + if (hex && g[1] === hex) { + scored.push({ g, score: -1 }); + continue; + } + const bare = name.slice(name.indexOf("-") + 1); + const words = name.split(/[-_]/); + let score: number; + if (name === q || bare === q.replace(/ /g, "_")) score = 0; + else if (bare.startsWith(q.replace(/ /g, "_"))) score = 1; + else { + // Every token must start a word (best) or appear in the name. + let total = 2; + let ok = true; + for (const t of tokens) { + if (words.some((w) => w === t)) continue; + if (words.some((w) => w.startsWith(t))) total += 1; + else if (name.includes(t)) total += 3; + else { + ok = false; + break; + } + } + if (!ok) continue; + score = total; + } + scored.push({ g, score }); + } + scored.sort((a, b) => a.score - b.score || a.g[0].length - b.g[0].length || a.g[0].localeCompare(b.g[0])); + return scored.slice(0, limit).map((s) => s.g); +} + +/** Counts per set, for the status line. */ +export function countBySet(glyphs: Glyph[]): Record { + const out: Record = {}; + for (const s of SETS) out[s.value] = 0; + for (const g of glyphs) { + for (const s of SETS) if (s.value === "all" || s.prefixes.includes(prefixOf(g[0]))) out[s.value]++; + } + return out; +} + +/** Most recent first, no duplicates, at most `max`. */ +export function remember(recent: string[], name: string, max = 24): string[] { + return [name, ...recent.filter((n) => n !== name)].slice(0, max); +} diff --git a/extensions/nerd-fonts/lib_test.ts b/extensions/nerd-fonts/lib_test.ts new file mode 100644 index 0000000..e91fc64 --- /dev/null +++ b/extensions/nerd-fonts/lib_test.ts @@ -0,0 +1,116 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + charOf, + className, + codePoint, + countBySet, + cssContent, + type Glyph, + htmlEntity, + inSet, + isGlyphData, + jsEscape, + parseGlyphnames, + remember, + searchGlyphs, + shortTitle, +} from "./lib.ts"; +import fallback from "./fallback.json" with { type: "json" }; + +// A sample of the official glyphnames.json (the real file is ~550 KB). +const SAMPLE: Record = { + METADATA: { website: "https://www.nerdfonts.com", version: "3.5.1", date: "2026-08-21 13:56:47+00:00" }, + "cod-folder_opened": { char: "", code: "eaf7" }, + "custom-folder_open": { char: "", code: "e5fe" }, + "dev-git": { char: "", code: "e702" }, + "dev-archlinux": { char: "", code: "e732" }, + "fa-folder_open": { char: "", code: "f07c" }, + "fa-folder_open_o": { char: "", code: "f115" }, + "fa-git": { char: "", code: "f1d3" }, + "linux-archlinux": { char: "", code: "f303" }, + "md-arch": { char: "\u{f08c7}", code: "f08c7" }, + "md-folder_open": { char: "\u{f0770}", code: "f0770" }, + "md-folder_lock_open": { char: "\u{f0250}", code: "f0250" }, + "md-source_branch": { char: "\u{f062c}", code: "f062c" }, + "oct-git_branch": { char: "", code: "f418" }, + "pl-branch": { char: "", code: "e0a0" }, + "weather-day_sunny": { char: "", code: "e30d" }, +}; +for (let i = 0; i < 100; i++) SAMPLE[`md-filler_${i}`] = { char: "x", code: (0xf1000 + i).toString(16) }; +SAMPLE["broken"] = { char: "?" }; + +const DATA = parseGlyphnames(JSON.stringify(SAMPLE), 1000); +const G = DATA.glyphs; +const names = (gs: Glyph[]) => gs.map((g) => g[0]); + +Deno.test("glyphnames.json becomes a compact list", () => { + assertEquals(DATA.version, "3.5.1"); + assertEquals(DATA.fetched, 1000); + assertEquals(G.length, 115); + assertEquals(G.find((g) => g[0] === "md-folder_open"), ["md-folder_open", "f0770"]); + assertEquals(isGlyphData(DATA), true); + assertEquals(isGlyphData({ glyphs: [] }), false); + assertEquals(isGlyphData(undefined), false); + assertThrows(() => parseGlyphnames('{"METADATA":{}}'), Error, "incomplete"); +}); + +Deno.test("search by words, best matches first", () => { + assertEquals(names(searchGlyphs(G, "folder open")), [ + "fa-folder_open", + "md-folder_open", + "custom-folder_open", + "fa-folder_open_o", + "cod-folder_opened", + "md-folder_lock_open", + ]); + assertEquals(names(searchGlyphs(G, "nf-md-folder_open"))[0], "md-folder_open"); + assertEquals(names(searchGlyphs(G, "branch")).slice(0, 3), ["pl-branch", "oct-git_branch", "md-source_branch"]); + assertEquals(names(searchGlyphs(G, "arch")).slice(0, 3), ["md-arch", "dev-archlinux", "linux-archlinux"]); + assertEquals(searchGlyphs(G, "nothing-like-this"), []); +}); + +Deno.test("search by code point or glyph", () => { + for (const q of ["f07c", "U+F07C", "\\uf07c", "0xf07c", ""]) { + assertEquals(names(searchGlyphs(G, q)), ["fa-folder_open"], q); + } + assertEquals(names(searchGlyphs(G, "\\u{f0770}")), ["md-folder_open"]); + assertEquals(names(searchGlyphs(G, "")), ["fa-git"]); +}); + +Deno.test("sets", () => { + assertEquals(names(searchGlyphs(G, "folder open", "md")), ["md-folder_open", "md-folder_lock_open"]); + assertEquals(names(searchGlyphs(G, "", "linux")), ["linux-archlinux"]); + assertEquals(searchGlyphs(G, "", "all", 3).length, 3); + assertEquals(inSet("fae-tooth", "fa"), true); + assertEquals(inSet("ple-flame_thick", "pl"), true); + assertEquals(inSet("md-x", undefined), true); + const c = countBySet(G); + assertEquals([c.all, c.md, c.fa, c.dev], [115, 104, 3, 2]); +}); + +Deno.test("text forms of a glyph", () => { + assertEquals(charOf("f07c"), ""); + assertEquals(charOf("f0770"), "\u{f0770}"); + assertEquals(className("md-folder_open"), "nf-md-folder_open"); + assertEquals(codePoint("f0770"), "U+F0770"); + assertEquals(htmlEntity("f07c"), ""); + assertEquals(cssContent("f07c"), "\\f07c"); + assertEquals(jsEscape("f07c"), "\\uf07c"); + assertEquals(jsEscape("f0770"), "\\u{f0770}"); + assertEquals(shortTitle("md-folder_open"), "folder open"); +}); + +Deno.test("recently copied", () => { + assertEquals(remember(["a", "b", "c"], "b"), ["b", "a", "c"]); + assertEquals(remember(["a", "b"], "c", 2), ["c", "a"]); +}); + +Deno.test("the shipped fallback is valid", () => { + const list = fallback as Glyph[]; + assertEquals(list.length > 200, true); + for (const [name, code] of list) { + assertEquals(/^[a-z]+-[\w]+$/.test(name), true, name); + assertEquals(/^[0-9a-f]{4,5}$/.test(code), true, code); + } + assertEquals(new Set(list.map((g) => g[0])).size, list.length); +}); diff --git a/extensions/ollama/backslash.toml b/extensions/ollama/backslash.toml new file mode 100644 index 0000000..c03f307 --- /dev/null +++ b/extensions/ollama/backslash.toml @@ -0,0 +1,34 @@ +[extension] +id = "ollama" +title = "Ollama Models" +description = "See the local Ollama models and the ones loaded in memory, pull new ones with live progress and delete them" +version = "1.0.0" +author = "Backslash" +icon = "bs-sparkle" +license = "MIT" + +[permissions] +# The local Ollama server (its default address). +net = ["127.0.0.1:11434"] +# ollama --version tells "not installed" from "not running"; systemd-run starts +# `ollama serve` in the background when asked. +run = ["ollama", "systemd-run"] +read = ["/etc/os-release"] + +[[commands]] +name = "models" +title = "Ollama Models" +description = "Installed and running models: size, family, parameters, quantization, memory" +icon = "bs-sparkle" +keywords = ["ollama", "llm", "ai", "model", "local", "llama", "qwen", "gemma", "mistral", "modèle", "ia"] + +[[commands]] +name = "pull" +title = "Pull Ollama Model" +description = "Download a model from the Ollama library with live progress" +icon = "bs-download" +keywords = ["ollama", "pull", "download", "model", "llm", "install", "télécharger", "modèle"] + +[[commands.arguments]] +name = "model" +placeholder = "llama3.2, qwen3:8b…" diff --git a/extensions/ollama/commands/models.ts b/extensions/ollama/commands/models.ts new file mode 100644 index 0000000..8a3ce8a --- /dev/null +++ b/extensions/ollama/commands/models.ts @@ -0,0 +1,232 @@ +import { + type Action, + action, + copy, + detail, + Icons, + type Item, + list, + meta, + type Metadata, + open, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + age, + bytes, + count, + displayName, + expiresIn, + libraryUrl, + type Model, + type ModelInfo, + parsePs, + parseShow, + parseTags, + processor, + type Running, +} from "../lib.ts"; +import { api, guarded, nextChange, notifyChanged, pullForm, pullView } from "../ollama.ts"; + +const CAPABILITY_COLORS: Record = { + completion: "gray", + vision: "purple", + tools: "blue", + thinking: "orange", + embedding: "teal", + insert: "green", +}; + +function infoView(m: Model, info: ModelInfo | undefined, raw: { modelfile?: string } | undefined) { + const lines = [ + `# ${displayName(m.name)}`, + "", + info?.system ? `**System prompt:** ${info.system}\n` : "", + info?.parameters.length ? "## Parameters\n\n```\n" + info.parameters.join("\n") + "\n```\n" : "", + raw?.modelfile ? "## Modelfile\n\n```\n" + raw.modelfile.replace(/```/g, "ʼʼʼ").slice(0, 6000) + "\n```" : "", + ]; + return detail({ + title: displayName(m.name), + markdown: lines.filter(Boolean).join("\n"), + metadata: metadata(m, info), + actions: [ + copy(`ollama run ${m.name}`, "Copy ollama run Command", { icon: Icons.terminal }), + ...(raw?.modelfile ? [copy(raw.modelfile, "Copy Modelfile", { icon: Icons.file })] : []), + open(libraryUrl(m.name), "Open in Ollama Library"), + ], + }); +} + +function metadata(m: Model, info?: ModelInfo, r?: Running): Metadata[] { + return [ + ...(r + ? [ + meta.text("Loaded", processor(r), { icon: Icons.cpu, color: "green" }), + meta.text("Memory", bytes(r.size), { icon: Icons.layers }), + meta.text("Unloads", expiresIn(r.expires), { icon: Icons.timer }), + ...(r.context ? [meta.text("Context in use", r.context.toLocaleString("en-US"))] : []), + meta.separator(), + ] + : []), + ...(m.family ? [meta.text("Family", m.family, { icon: Icons.layers })] : []), + ...(m.parameters ? [meta.text("Parameters", m.parameters, { icon: Icons.cpu })] : []), + ...(m.quantization ? [meta.text("Quantization", m.quantization)] : []), + ...(info?.contextLength ? [meta.text("Context window", info.contextLength.toLocaleString("en-US"))] : []), + ...(info?.capabilities.length + ? [meta.tags("Capabilities", info.capabilities.map((c) => ({ text: c, color: CAPABILITY_COLORS[c] ?? "gray" })))] + : []), + meta.separator(), + meta.text("Size on disk", m.remote ? "Cloud model" : bytes(m.size), { icon: Icons.drive }), + ...(m.format ? [meta.text("Format", m.format.toUpperCase())] : []), + ...(info?.license ? [meta.text("License", info.license, { icon: Icons.book })] : []), + meta.text("Digest", m.digest.slice(0, 12), { icon: Icons.hash }), + ...(m.modified ? [meta.text("Updated", age(m.modified) + " ago", { icon: Icons.calendar })] : []), + ]; +} + +function item(m: Model, info: ModelInfo | undefined, r: Running | undefined): Item { + const actions: Action[] = [ + copy(`ollama run ${m.name}`, "Copy ollama run Command", { icon: Icons.terminal }), + action("Show Details", async () => { + const raw = await api("/api/show", { body: { model: m.name } }).catch(() => undefined); + return infoView(m, raw ? parseShow(raw) : info, raw); + }, { icon: Icons.info, shortcut: "i" }), + ]; + if (r) { + actions.push(action("Unload from Memory", async () => { + await api("/api/generate", { body: { model: m.name, keep_alive: 0 } }); + notifyChanged(); + await toast(`Unloaded ${displayName(m.name)}`, { style: "success" }); + }, { icon: Icons.stop, shortcut: "u", section: "Model" })); + } else if (!info?.capabilities.includes("embedding")) { + actions.push(action("Load into Memory", async () => { + await toast(`Loading ${displayName(m.name)}…`); + await api("/api/generate", { body: { model: m.name, keep_alive: "10m" } }); + notifyChanged(); + await toast(`${displayName(m.name)} is loaded for 10 minutes`, { style: "success" }); + }, { icon: Icons.play, shortcut: "l", section: "Model" })); + } + actions.push( + ...(m.remote ? [] : [action("Update (Pull Again)", () => pullView(m.name), { + icon: Icons.download, + section: "Model", + })]), + action("Pull Another Model…", () => pullForm(), { icon: Icons.plus, shortcut: "n", section: "Model" }), + open(libraryUrl(m.name), "Open in Ollama Library", { icon: Icons.globe, shortcut: "o", section: "Model" }), + copy(m.name, "Copy Model Name", { shortcut: "period", section: "Copy" }), + copy(`ollama pull ${m.name}`, "Copy ollama pull Command", { icon: Icons.terminal, section: "Copy" }), + action("Delete Model", async () => { + await api("/api/delete", { method: "DELETE", body: { model: m.name } }); + notifyChanged(); + await toast(`Deleted ${displayName(m.name)}`, { + style: "success", + message: m.remote ? undefined : `${bytes(m.size)} freed`, + }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${displayName(m.name)}${ + m.remote ? "" : ` (${bytes(m.size)})` + }? Pulling it again downloads it again.`, + shortcut: "Delete", + section: "Danger", + }), + ); + const caps = (info?.capabilities ?? []).filter((c) => c !== "completion"); + return { + id: m.name, + title: displayName(m.name), + subtitle: [m.family, m.parameters, m.quantization].filter(Boolean).join(" · "), + icon: { source: m.remote ? Icons.cloud : Icons.sparkle, tint: r ? "green" : m.remote ? "blue" : "purple" }, + section: r ? "Running" : "Installed", + keywords: [m.family ?? "", ...m.families, m.parameters ?? "", m.quantization ?? "", ...caps], + accessories: [ + ...caps.slice(0, 2).map((c) => ({ tag: c, color: CAPABILITY_COLORS[c] ?? "gray" as const })), + ...(r + ? [{ icon: { source: Icons.timer, tint: "green" as const }, text: expiresIn(r.expires), tooltip: "Unloads" }] + : []), + { text: m.remote ? "cloud" : bytes(m.size), tooltip: m.remote ? "Runs on ollama.com" : "Size on disk" }, + ], + detail: { + stats: r + ? [ + { label: "memory", value: bytes(r.size), color: "green" }, + { label: "on GPU", value: r.size ? `${Math.round((r.vram / r.size) * 100)}%` : "—" }, + { label: "unloads", value: expiresIn(r.expires).replace(/^in /, "") || "—" }, + ] + : [ + { label: "parameters", value: m.parameters || count(info?.parameterCount) || "—" }, + { label: "size", value: m.remote ? "cloud" : bytes(m.size) }, + { label: "quant", value: m.quantization ?? "—" }, + ], + metadata: metadata(m, info, r), + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter models by name, family, size…", + empty: { + title: "No models yet", + description: "Press Ctrl+N to pull one, e.g. llama3.2 or qwen3", + icon: Icons.sparkle, + }, + status: () => summary, + actions: [action("Pull a Model…", () => pullForm(), { icon: Icons.download, shortcut: "n" })], + async *items({ signal }) { + const infos = new Map(); + while (!signal.aborted) { + let models: Model[] = []; + let running: Running[] = []; + let version = ""; + const items = await guarded(async () => { + const [tags, ps, v] = await Promise.all([ + api("/api/tags", { signal }), + api("/api/ps", { signal }), + api<{ version: string }>("/api/version", { signal }).catch(() => ({ version: "" })), + ]); + models = parseTags(tags); + running = parsePs(ps); + version = v.version; + return build(models, running, infos); + }); + const total = models.reduce((n, m) => n + (m.remote ? 0 : m.size), 0); + summary = models.length + ? `${models.length} model${models.length === 1 ? "" : "s"} · ${bytes(total)}${ + running.length ? ` · ${running.length} running` : "" + }${version ? ` · Ollama ${version}` : ""}` + : ""; + await updateCommandMetadata({ + subtitle: models.length + ? `${models.length} models${running.length ? ` · ${running.length} running` : ""}` + : null, + }).catch(() => {}); + yield items; + + // Capabilities and context windows, once per model. + const missing = models.filter((m) => !infos.has(m.name)); + if (missing.length) { + await Promise.all(missing.map(async (m) => { + const raw = await api("/api/show", { body: { model: m.name }, signal }).catch(() => undefined); + if (raw) infos.set(m.name, parseShow(raw)); + })); + if (signal.aborted) return; + yield build(models, running, infos); + } + // Running models unload by themselves: keep their countdown fresh. + await nextChange(signal, running.length ? 15_000 : 60_000); + } + }, +}); + +function build(models: Model[], running: Running[], infos: Map): Item[] { + const byName = new Map(running.map((r) => [r.name, r])); + return [ + ...models.filter((m) => byName.has(m.name)), + ...models.filter((m) => !byName.has(m.name)), + ].map((m) => item(m, infos.get(m.name), byName.get(m.name))); +} diff --git a/extensions/ollama/commands/pull.ts b/extensions/ollama/commands/pull.ts new file mode 100644 index 0000000..d67bfbe --- /dev/null +++ b/extensions/ollama/commands/pull.ts @@ -0,0 +1,9 @@ +import type { Context } from "@backslash/api"; +import { normalizeModelName } from "../lib.ts"; +import { pullForm, pullView } from "../ollama.ts"; + +// "Pull Ollama Model qwen3:8b" streams the download; without a name, asks for one. +export default (ctx: Context<{ model?: string }>) => { + const name = String(ctx.args.model ?? ctx.query ?? "").trim(); + return name ? pullView(normalizeModelName(name)) : pullForm(); +}; diff --git a/extensions/ollama/lib.ts b/extensions/ollama/lib.ts new file mode 100644 index 0000000..3695d94 --- /dev/null +++ b/extensions/ollama/lib.ts @@ -0,0 +1,248 @@ +// Pure logic of the Ollama extension: API response mapping, pull progress, +// formatting and install hints. No I/O here. + +export const BASE = "http://127.0.0.1:11434"; + +export interface Model { + name: string; + size: number; + digest: string; + modified?: Date; + family?: string; + families: string[]; + parameters?: string; + quantization?: string; + format?: string; + /** Cloud models run on ollama.com and take no disk space. */ + remote: boolean; +} + +export function mapModel(m: any): Model { + const d = m.details ?? {}; + return { + name: m.name ?? m.model, + size: Number(m.size ?? 0), + digest: String(m.digest ?? ""), + modified: m.modified_at ? new Date(m.modified_at) : undefined, + family: d.family || undefined, + families: d.families ?? (d.family ? [d.family] : []), + parameters: d.parameter_size || undefined, + quantization: d.quantization_level || undefined, + format: d.format || undefined, + remote: !!m.remote_host || /-cloud$|:cloud$/.test(m.name ?? ""), + }; +} + +export function parseTags(body: { models?: unknown[] } | undefined): Model[] { + return (body?.models ?? []).map(mapModel).sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface Running { + name: string; + size: number; + vram: number; + expires?: Date; + context?: number; +} + +export function parsePs(body: { models?: any[] } | undefined): Running[] { + return (body?.models ?? []).map((m) => ({ + name: m.name ?? m.model, + size: Number(m.size ?? 0), + vram: Number(m.size_vram ?? 0), + expires: m.expires_at ? new Date(m.expires_at) : undefined, + context: m.context_length || undefined, + })); +} + +/** `100% GPU`, `48%/52% CPU/GPU` like `ollama ps`, or `100% CPU`. */ +export function processor(r: Running): string { + if (!r.size) return ""; + const gpu = Math.round((r.vram / r.size) * 100); + if (gpu >= 100) return "100% GPU"; + if (gpu <= 0) return "100% CPU"; + return `${100 - gpu}%/${gpu}% CPU/GPU`; +} + +/** `in 4 min`, `in 2 h`, `forever` (keep_alive -1 sets a date centuries away). */ +export function expiresIn(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) return ""; + const s = (date.getTime() - now.getTime()) / 1000; + if (s > 86400 * 365) return "forever"; + if (s <= 0) return "now"; + if (s < 60) return `in ${Math.round(s)} s`; + if (s < 3600) return `in ${Math.round(s / 60)} min`; + if (s < 86400) return `in ${Math.round(s / 3600)} h`; + return `in ${Math.round(s / 86400)} d`; +} + +export interface ModelInfo { + architecture?: string; + parameterCount?: number; + contextLength?: number; + embeddingLength?: number; + capabilities: string[]; + license?: string; + parameters: string[]; + template?: string; + system?: string; +} + +export function parseShow(body: any): ModelInfo { + const info = body?.model_info ?? {}; + const arch = info["general.architecture"]; + const license = typeof body?.license === "string" ? body.license.trim().split("\n")[0].trim() : undefined; + const system = /^SYSTEM\s+"""([\s\S]*?)"""/m.exec(body?.modelfile ?? "")?.[1] ?? + /^SYSTEM\s+(.+)$/m.exec(body?.modelfile ?? "")?.[1]; + return { + architecture: arch, + parameterCount: info["general.parameter_count"], + contextLength: arch ? info[`${arch}.context_length`] : undefined, + embeddingLength: arch ? info[`${arch}.embedding_length`] : undefined, + capabilities: body?.capabilities ?? [], + license: license || undefined, + parameters: String(body?.parameters ?? "").split("\n").map((l) => l.trim().replace(/\s+/g, " ")).filter(Boolean), + template: body?.template || undefined, + system: system?.trim() || undefined, + }; +} + +/** `llama3.2:latest` → `llama3.2`; `qwen3:8b` stays. */ +export const displayName = (name: string) => name.replace(/:latest$/, ""); + +/** The library page of a model (namespaced models live under their user). */ +export function libraryUrl(name: string): string { + const base = name.split(":")[0]; + return base.includes("/") ? `https://ollama.com/${base}` : `https://ollama.com/library/${base}`; +} + +/** Validates a model name typed by the user. */ +export function normalizeModelName(input: string): string { + let name = input.trim().replace(/^ollama\s+(run|pull)\s+/, ""); + name = name.replace(/^https?:\/\/ollama\.com\/(library\/)?/, ""); + if (!/^[\w.-]+(\/[\w.-]+)?(:[\w.-]+)?$/.test(name)) { + throw new Error(`“${input.trim()}” is not a model name (e.g. llama3.2, qwen3:8b, gemma3:4b)`); + } + return name; +} + +// --- Pull progress ----------------------------------------------------------------- + +export interface Layer { + digest: string; + total: number; + completed: number; +} + +export interface PullState { + status: string; + layers: Layer[]; + done: boolean; + error?: string; +} + +export const initialPull = (): PullState => ({ status: "Starting", layers: [], done: false }); + +/** Folds one NDJSON line of `/api/pull` into the state. */ +export function reducePull(state: PullState, line: { + status?: string; + digest?: string; + total?: number; + completed?: number; + error?: string; +}): PullState { + if (line.error) return { ...state, error: line.error, done: true }; + const layers = [...state.layers]; + if (line.digest && line.total) { + const i = layers.findIndex((l) => l.digest === line.digest); + const layer = { digest: line.digest, total: line.total, completed: line.completed ?? 0 }; + if (i < 0) layers.push(layer); + else layers[i] = { ...layer, completed: Math.max(layers[i].completed, layer.completed) }; + } + const status = line.status ?? state.status; + return { status, layers, done: status === "success" }; +} + +export function pullTotals(state: PullState): { completed: number; total: number; ratio: number } { + const total = state.layers.reduce((n, l) => n + l.total, 0); + const completed = state.layers.reduce((n, l) => n + Math.min(l.completed, l.total), 0); + return { completed, total, ratio: total ? completed / total : state.done ? 1 : 0 }; +} + +/** `pulling 6a0746a1ec1a` → `Downloading layer 6a0746a1`. */ +export function statusLabel(status: string): string { + if (status === "success") return "Done"; + if (status === "pulling manifest") return "Fetching manifest"; + const m = /^pulling ([0-9a-f]{8,})/.exec(status); + if (m) return `Downloading layer ${m[1].slice(0, 8)}`; + if (status.startsWith("verifying")) return "Verifying digest"; + if (status.startsWith("writing manifest")) return "Writing manifest"; + if (status.startsWith("removing")) return "Removing unused layers"; + return status.charAt(0).toUpperCase() + status.slice(1); +} + +/** Splits a stream of chunks into JSON lines. */ +export function splitLines(buffer: string): { lines: string[]; rest: string } { + const parts = buffer.split("\n"); + const rest = parts.pop() ?? ""; + return { lines: parts.map((l) => l.trim()).filter(Boolean), rest }; +} + +// --- Formatting ------------------------------------------------------------------------- + +export function bytes(n: number): string { + if (n < 1000) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n; + let i = -1; + do { + v /= 1000; + i++; + } while (v >= 1000 && i < units.length - 1); + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** 3212749888 → `3.2B`. */ +export function count(n: number | undefined): string { + if (!n) return ""; + if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(0)}M`; + return `${Math.round(n / 1e3)}K`; +} + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) 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`; +} + +// --- Installing ---------------------------------------------------------------------------- + +export type Family = "debian" | "fedora" | "arch" | "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 INSTALL_SCRIPT = "curl -fsSL https://ollama.com/install.sh | sh"; + +export function installCommand(family: Family | undefined): string { + switch (family) { + case "arch": + return "sudo pacman -S ollama # or ollama-cuda, ollama-rocm"; + case "fedora": + return "sudo dnf install ollama"; + default: + return INSTALL_SCRIPT; + } +} diff --git a/extensions/ollama/lib_test.ts b/extensions/ollama/lib_test.ts new file mode 100644 index 0000000..054dabc --- /dev/null +++ b/extensions/ollama/lib_test.ts @@ -0,0 +1,144 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + bytes, + count, + displayName, + expiresIn, + initialPull, + installCommand, + libraryUrl, + normalizeModelName, + osFamily, + parsePs, + parseShow, + parseTags, + processor, + pullTotals, + reducePull, + splitLines, + statusLabel, +} from "./lib.ts"; + +const TAGS = { + models: [ + { + name: "qwen3:8b", + model: "qwen3:8b", + modified_at: "2026-09-01T10:00:00Z", + size: 5225388164, + digest: "500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41", + details: { + format: "gguf", + family: "qwen3", + families: ["qwen3"], + parameter_size: "8.2B", + quantization_level: "Q4_K_M", + }, + }, + { + name: "llama3.2:latest", + model: "llama3.2:latest", + modified_at: "2026-10-20T10:00:00.123+02:00", + size: 2019393189, + digest: "a80c4f17acd5", + details: { format: "gguf", family: "llama", parameter_size: "3.2B", quantization_level: "Q4_K_M" }, + }, + { name: "gpt-oss:120b-cloud", size: 384, digest: "x", remote_host: "https://ollama.com:443", details: {} }, + ], +}; + +Deno.test("installed models", () => { + const models = parseTags(TAGS); + assertEquals(models.map((m) => m.name), ["gpt-oss:120b-cloud", "llama3.2:latest", "qwen3:8b"]); + assertEquals(models[1].families, ["llama"]); + assertEquals(models[2].parameters, "8.2B"); + assertEquals(models[0].remote, true); + assertEquals(models[1].remote, false); + assertEquals(parseTags(undefined), []); + assertEquals(displayName("llama3.2:latest"), "llama3.2"); + assertEquals(displayName("qwen3:8b"), "qwen3:8b"); +}); + +Deno.test("running models", () => { + const [r] = parsePs({ + models: [{ + name: "llama3.2:latest", + size: 3500000000, + size_vram: 1680000000, + expires_at: "2026-10-28T12:04:00Z", + context_length: 4096, + }], + }); + assertEquals(processor(r), "52%/48% CPU/GPU"); + assertEquals(processor({ ...r, vram: r.size }), "100% GPU"); + assertEquals(processor({ ...r, vram: 0 }), "100% CPU"); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals(expiresIn(r.expires, now), "in 4 min"); + assertEquals(expiresIn(new Date("2318-01-01T00:00:00Z"), now), "forever"); + assertEquals(expiresIn(new Date("2026-10-28T11:00:00Z"), now), "now"); +}); + +Deno.test("model details from /api/show", () => { + const info = parseShow({ + license: "\nMIT License\n\nCopyright", + modelfile: 'FROM x\nSYSTEM """You are a pirate."""\nPARAMETER temperature 0.7', + parameters: 'temperature 0.7\nstop "<|im_end|>"', + capabilities: ["completion", "vision"], + model_info: { + "general.architecture": "gemma3", + "general.parameter_count": 4300079472, + "gemma3.context_length": 131072, + }, + }); + assertEquals(info.contextLength, 131072); + assertEquals(info.license, "MIT License"); + assertEquals(info.system, "You are a pirate."); + assertEquals(info.parameters, ["temperature 0.7", 'stop "<|im_end|>"']); + assertEquals(count(info.parameterCount), "4.3B"); + assertEquals(count(137000000), "137M"); + assertEquals(parseShow(undefined).capabilities, []); +}); + +Deno.test("model names", () => { + assertEquals(normalizeModelName(" ollama run qwen3:8b "), "qwen3:8b"); + assertEquals(normalizeModelName("https://ollama.com/library/gemma3"), "gemma3"); + assertEquals(normalizeModelName("hf.co/user"), "hf.co/user"); + assertThrows(() => normalizeModelName("rm -rf /"), Error, "not a model name"); + assertEquals(libraryUrl("qwen3:8b"), "https://ollama.com/library/qwen3"); + assertEquals(libraryUrl("mannix/llama3:8b"), "https://ollama.com/mannix/llama3"); +}); + +Deno.test("pull progress", () => { + const lines = [ + { status: "pulling manifest" }, + { status: "pulling dde5aa3fc5ff", digest: "sha256:dde5", total: 2000, completed: 500 }, + { status: "pulling 966de95ca8a6", digest: "sha256:966d", total: 1000 }, + { status: "pulling dde5aa3fc5ff", digest: "sha256:dde5", total: 2000, completed: 400 }, // out of order: kept at 500 + { status: "pulling 966de95ca8a6", digest: "sha256:966d", total: 1000, completed: 1000 }, + ]; + let s = initialPull(); + for (const l of lines) s = reducePull(s, l); + assertEquals(s.layers.map((l) => l.completed), [500, 1000]); + assertEquals(pullTotals(s), { completed: 1500, total: 3000, ratio: 0.5 }); + assertEquals(statusLabel(s.status), "Downloading layer 966de95c"); + s = reducePull(reducePull(s, { status: "verifying sha256 digest" }), { status: "success" }); + assertEquals(s.done, true); + assertEquals(statusLabel(s.status), "Done"); + const failed = reducePull(initialPull(), { error: "pull model manifest: file does not exist" }); + assertEquals([failed.done, failed.error], [true, "pull model manifest: file does not exist"]); + assertEquals(pullTotals(initialPull()).ratio, 0); +}); + +Deno.test("NDJSON splitting", () => { + assertEquals(splitLines('{"a":1}\n{"b":2}\n{"c"'), { lines: ['{"a":1}', '{"b":2}'], rest: '{"c"' }); + assertEquals(splitLines(""), { lines: [], rest: "" }); +}); + +Deno.test("formatting and install hints", () => { + assertEquals(bytes(2019393189), "2.0 GB"); + assertEquals(bytes(274302450), "274 MB"); + assertEquals(bytes(512), "512 B"); + assertEquals(installCommand(osFamily("ID=arch")), "sudo pacman -S ollama # or ollama-cuda, ollama-rocm"); + assertEquals(installCommand(osFamily('ID="fedora"')), "sudo dnf install ollama"); + assertEquals(installCommand(osFamily("ID=ubuntu\nID_LIKE=debian")), "curl -fsSL https://ollama.com/install.sh | sh"); +}); diff --git a/extensions/ollama/ollama.ts b/extensions/ollama/ollama.ts new file mode 100644 index 0000000..20b2010 --- /dev/null +++ b/extensions/ollama/ollama.ts @@ -0,0 +1,346 @@ +// Talking to the local Ollama server, and the views both commands share. + +import { + action, + close, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, +} from "@backslash/api"; +import { + BASE, + bytes, + displayName, + initialPull, + INSTALL_SCRIPT, + installCommand, + libraryUrl, + normalizeModelName, + osFamily, + pullTotals, + reducePull, + splitLines, + statusLabel, +} from "./lib.ts"; + +export class NotRunning extends Error {} + +export async function api( + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + method: options.method ?? (options.body === undefined ? "GET" : "POST"), + signal: options.signal, + headers: options.body === undefined ? {} : { "Content-Type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (err) { + if (options.signal?.aborted) throw err; + throw new NotRunning(`Ollama isn't running on ${BASE.replace(/^http:\/\//, "")}`); + } + const text = await res.text(); + const body = text ? JSON.parse(text) : undefined; + if (!res.ok) throw new Error(body?.error ?? `Ollama answered ${res.status}`); + return body as T; +} + +// --- Live reload ------------------------------------------------------------------------ + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Resolves on the next change, after `ms`, or when `signal` aborts. */ +export function nextChange(signal: AbortSignal, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + } + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); +} + +// --- Not running -------------------------------------------------------------------------- + +async function installed(): Promise { + try { + await new Deno.Command("ollama", { args: ["--version"], stdin: "null", stdout: "null", stderr: "null" }).output(); + return true; + } catch { + return false; + } +} + +export async function notRunningItems(err: NotRunning): Promise { + const [hasBinary, osRelease] = await Promise.all([ + installed(), + Deno.readTextFile("/etc/os-release").catch(() => ""), + ]); + const install = installCommand(osFamily(osRelease)); + if (hasBinary) { + return [{ + id: "not-running", + title: err.message, + subtitle: "Start the server, then reload", + icon: { source: Icons.power, tint: "orange" }, + detail: { + markdown: [ + "Ollama is installed but its server doesn't answer.", + "", + "```sh", + "sudo systemctl start ollama # the service installed by the official script", + "ollama serve # or in a terminal", + "```", + "", + "**Start ollama serve** runs it in the background for your session (`systemd-run --user`).", + ].join("\n"), + }, + actions: [ + action("Start ollama serve", async () => { + const out = await new Deno.Command("systemd-run", { + args: ["--user", "--collect", "--unit=backslash-ollama-serve", "ollama", "serve"], + stdin: "null", + stdout: "null", + stderr: "piped", + }).output().catch((err) => { + if (err instanceof Deno.errors.NotFound) { + throw new Error("systemd-run is missing: run ollama serve in a terminal"); + } + throw err; + }); + if (!out.success) throw new Error(new TextDecoder().decode(out.stderr).trim() || "systemd-run failed"); + await new Promise((r) => setTimeout(r, 1500)); + return refresh(); + }, { icon: Icons.play }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + copy("sudo systemctl start ollama", "Copy systemctl Command", { icon: Icons.terminal, section: "Copy" }), + ], + }]; + } + return [{ + id: "not-installed", + title: "Ollama is not installed", + subtitle: install, + icon: { source: Icons.download, tint: "orange" }, + detail: { + markdown: [ + "Ollama runs language models on this computer. Install it, then pull a model:", + "", + "```sh", + install, + "ollama pull llama3.2", + "```", + ].join("\n"), + }, + actions: [ + copy(install.replace(/\s+#.*$/, ""), "Copy Install Command", { icon: Icons.terminal }), + ...(install === INSTALL_SCRIPT + ? [] + : [copy(INSTALL_SCRIPT, "Copy Install Script Command", { icon: Icons.terminal })]), + open("https://ollama.com/download/linux", "Open Download Page"), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (err) { + if (err instanceof NotRunning) return notRunningItems(err); + throw err; + } +} + +// --- Pull -------------------------------------------------------------------------------- + +export function pullForm(): FormSpec { + return form({ + title: "Pull a Model", + submitTitle: "Pull", + fields: [ + field.text("model", "Model", { required: true, placeholder: "llama3.2, qwen3:8b, gemma3:4b, deepseek-r1:7b…" }), + ], + submit: (values) => pullView(normalizeModelName(String(values.model ?? ""))), + }); +} + +/** Streams `/api/pull` into items: the overall progress, then one row per layer. */ +export function pullView(name: string): ListSpec { + let line = ""; + return list({ + title: `Pull ${name}`, + placeholder: `Pulling ${name}…`, + status: () => line, + async *items({ signal }) { + let state = initialPull(); + const started = Date.now(); + let res: Response; + try { + res = await fetch(`${BASE}/api/pull`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: name, stream: true }), + }); + } catch (err) { + if (signal.aborted) return; + console.error(err); + yield await notRunningItems(new NotRunning(`Ollama isn't running on ${BASE.replace(/^http:\/\//, "")}`)); + return; + } + if (!res.ok || !res.body) { + const body = await res.json().catch(() => undefined); + line = "Failed"; + yield [failedItem(name, body?.error ?? `Ollama answered ${res.status}`)]; + return; + } + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); + let rest = ""; + let lastYield = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const split = splitLines(rest + value); + rest = split.rest; + for (const l of split.lines) { + try { + state = reducePull(state, JSON.parse(l)); + } catch { + // A partial or odd line: keep going. + } + } + // A few updates a second are enough for a progress bar. + if (Date.now() - lastYield > 250 || state.done) { + lastYield = Date.now(); + const t = pullTotals(state); + const secs = (Date.now() - started) / 1000; + const speed = secs > 1 && t.completed ? `${bytes(t.completed / secs)}/s` : ""; + line = state.error + ? "Failed" + : `${statusLabel(state.status)}${t.total ? ` · ${bytes(t.completed)} of ${bytes(t.total)}` : ""}${ + speed && !state.done ? ` · ${speed}` : "" + }`; + yield progressItems(name, state, speed); + } + if (state.done) break; + } + if (state.error) { + line = "Failed"; + yield [failedItem(name, state.error)]; + } else if (!state.done && !signal.aborted) { + yield [failedItem(name, "The download stopped before it finished")]; + } else if (state.done) { + notifyChanged(); + const t = pullTotals(state); + line = `Done in ${Math.round((Date.now() - started) / 1000)} s${t.total ? ` · ${bytes(t.total)}` : ""}`; + yield [readyItem(name)]; + } + }, + }); +} + +function progressItems(name: string, state: ReturnType, speed: string): Item[] { + const t = pullTotals(state); + const pct = `${Math.floor(t.ratio * 100)}%`; + const overall: Item = { + id: "overall", + title: state.done ? `${displayName(name)} is ready` : `Pulling ${displayName(name)}`, + subtitle: statusLabel(state.status), + icon: { source: state.done ? Icons.done : Icons.download, tint: state.done ? "green" : "blue" }, + section: "Progress", + accessories: [...(speed && !state.done ? [{ text: speed }] : []), { tag: pct, color: "blue" }], + detail: { + stats: [ + { label: "done", value: pct, color: "blue" }, + { label: "downloaded", value: bytes(t.completed) }, + { label: "size", value: t.total ? bytes(t.total) : "…" }, + ], + metadata: [ + meta.progress("Progress", t.ratio, t.total ? `${bytes(t.completed)} of ${bytes(t.total)}` : pct), + meta.text("Step", statusLabel(state.status), { icon: Icons.inProgress }), + ...(speed ? [meta.text("Speed", speed, { icon: Icons.bolt })] : []), + meta.separator(), + meta.link("Library", libraryUrl(name).replace(/^https:\/\//, ""), libraryUrl(name)), + ], + markdown: "Leaving this view cancels the download; pulling again resumes it.", + }, + actions: [ + copy(`ollama pull ${name}`, "Copy Pull Command", { icon: Icons.terminal }), + open(libraryUrl(name), "Open in Ollama Library"), + ], + }; + const layers: Item[] = state.layers.map((l) => { + const ratio = l.total ? Math.min(1, l.completed / l.total) : 0; + const done = ratio >= 1; + return { + id: l.digest, + title: `Layer ${l.digest.replace(/^sha256:/, "").slice(0, 12)}`, + subtitle: `${bytes(Math.min(l.completed, l.total))} of ${bytes(l.total)}`, + icon: { source: done ? Icons.done : Icons.inProgress, tint: done ? "green" : "blue" }, + section: "Layers", + accessories: [{ text: `${Math.floor(ratio * 100)}%`, color: done ? "green" as const : undefined }], + detail: { metadata: [meta.progress("Layer", ratio), meta.text("Digest", l.digest, { icon: Icons.hash })] }, + actions: [copy(l.digest, "Copy Digest")], + }; + }); + return [overall, ...layers]; +} + +function readyItem(name: string): Item { + return { + id: "overall", + title: `${displayName(name)} is ready`, + subtitle: `ollama run ${name}`, + icon: { source: Icons.done, tint: "green" }, + section: "Progress", + accessories: [{ tag: "100%", color: "green" }], + detail: { + stats: [{ label: "done", value: "100%", color: "green" }], + metadata: [meta.progress("Progress", 1, "Downloaded and verified")], + }, + actions: [ + copy(`ollama run ${name}`, "Copy ollama run Command", { icon: Icons.terminal }), + action("Done", () => close(`${displayName(name)} is ready`), { icon: Icons.check }), + open(libraryUrl(name), "Open in Ollama Library"), + ], + }; +} + +function failedItem(name: string, error: string): Item { + const missing = /file does not exist|not found|manifest unknown/i.test(error); + return { + id: "failed", + title: missing ? `No model named ${name}` : `Pulling ${name} failed`, + subtitle: error, + icon: { source: Icons.alert, tint: "red" }, + detail: { + markdown: missing + ? `Ollama's library has no **${name}**. Check the name and tag on ollama.com/library.` + : `\`\`\`\n${error}\n\`\`\``, + }, + actions: [ + action("Try Again", () => refresh(), { icon: Icons.refresh }), + open( + missing ? `https://ollama.com/search?q=${encodeURIComponent(name.split(":")[0])}` : libraryUrl(name), + "Search the Library", + ), + copy(error, "Copy Error", { section: "Copy" }), + ], + }; +} From 7aaae58bbae14229e469ad5aa28898af52b89d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:38:50 +0000 Subject: [PATCH 05/10] Slack, Trello, Asana, ClickUp, TickTick and Raindrop.io Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/asana/api.ts | 122 +++++ extensions/asana/backslash.toml | 43 ++ extensions/asana/commands/create-task.ts | 15 + extensions/asana/commands/my-tasks.ts | 53 ++ extensions/asana/commands/projects.ts | 38 ++ extensions/asana/lib.ts | 377 ++++++++++++++ extensions/asana/lib_test.ts | 163 ++++++ extensions/asana/live.ts | 24 + extensions/asana/views.ts | 262 ++++++++++ extensions/clickup/api.ts | 128 +++++ extensions/clickup/backslash.toml | 32 ++ extensions/clickup/commands/create-task.ts | 15 + extensions/clickup/commands/my-tasks.ts | 58 +++ extensions/clickup/lib.ts | 435 ++++++++++++++++ extensions/clickup/lib_test.ts | 193 +++++++ extensions/clickup/live.ts | 24 + extensions/clickup/views.ts | 204 ++++++++ extensions/raindrop/api.ts | 67 +++ extensions/raindrop/backslash.toml | 40 ++ extensions/raindrop/commands/save.ts | 17 + extensions/raindrop/commands/search.ts | 61 +++ extensions/raindrop/lib.ts | 256 +++++++++ extensions/raindrop/lib_test.ts | 127 +++++ extensions/raindrop/views.ts | 195 +++++++ extensions/slack/api.ts | 147 ++++++ extensions/slack/backslash.toml | 61 +++ extensions/slack/commands/channels.ts | 136 +++++ extensions/slack/commands/clear-status.ts | 18 + .../slack/commands/pause-notifications.ts | 15 + extensions/slack/commands/search.ts | 92 ++++ extensions/slack/commands/status.ts | 255 +++++++++ extensions/slack/lib.ts | 490 ++++++++++++++++++ extensions/slack/lib_test.ts | 166 ++++++ extensions/slack/live.ts | 24 + extensions/ticktick/api.ts | 129 +++++ extensions/ticktick/backslash.toml | 49 ++ extensions/ticktick/commands/add.ts | 18 + extensions/ticktick/commands/tasks.ts | 59 +++ extensions/ticktick/lib.ts | 453 ++++++++++++++++ extensions/ticktick/lib_test.ts | 190 +++++++ extensions/ticktick/live.ts | 24 + extensions/ticktick/views.ts | 161 ++++++ extensions/trello/api.ts | 46 ++ extensions/trello/backslash.toml | 44 ++ extensions/trello/commands/boards.ts | 45 ++ extensions/trello/commands/create-card.ts | 14 + extensions/trello/commands/my-cards.ts | 59 +++ extensions/trello/lib.ts | 456 ++++++++++++++++ extensions/trello/lib_test.ts | 204 ++++++++ extensions/trello/live.ts | 24 + extensions/trello/views.ts | 296 +++++++++++ 51 files changed, 6624 insertions(+) create mode 100644 extensions/asana/api.ts create mode 100644 extensions/asana/backslash.toml create mode 100644 extensions/asana/commands/create-task.ts create mode 100644 extensions/asana/commands/my-tasks.ts create mode 100644 extensions/asana/commands/projects.ts create mode 100644 extensions/asana/lib.ts create mode 100644 extensions/asana/lib_test.ts create mode 100644 extensions/asana/live.ts create mode 100644 extensions/asana/views.ts create mode 100644 extensions/clickup/api.ts create mode 100644 extensions/clickup/backslash.toml create mode 100644 extensions/clickup/commands/create-task.ts create mode 100644 extensions/clickup/commands/my-tasks.ts create mode 100644 extensions/clickup/lib.ts create mode 100644 extensions/clickup/lib_test.ts create mode 100644 extensions/clickup/live.ts create mode 100644 extensions/clickup/views.ts create mode 100644 extensions/raindrop/api.ts create mode 100644 extensions/raindrop/backslash.toml create mode 100644 extensions/raindrop/commands/save.ts create mode 100644 extensions/raindrop/commands/search.ts create mode 100644 extensions/raindrop/lib.ts create mode 100644 extensions/raindrop/lib_test.ts create mode 100644 extensions/raindrop/views.ts create mode 100644 extensions/slack/api.ts create mode 100644 extensions/slack/backslash.toml create mode 100644 extensions/slack/commands/channels.ts create mode 100644 extensions/slack/commands/clear-status.ts create mode 100644 extensions/slack/commands/pause-notifications.ts create mode 100644 extensions/slack/commands/search.ts create mode 100644 extensions/slack/commands/status.ts create mode 100644 extensions/slack/lib.ts create mode 100644 extensions/slack/lib_test.ts create mode 100644 extensions/slack/live.ts create mode 100644 extensions/ticktick/api.ts create mode 100644 extensions/ticktick/backslash.toml create mode 100644 extensions/ticktick/commands/add.ts create mode 100644 extensions/ticktick/commands/tasks.ts create mode 100644 extensions/ticktick/lib.ts create mode 100644 extensions/ticktick/lib_test.ts create mode 100644 extensions/ticktick/live.ts create mode 100644 extensions/ticktick/views.ts create mode 100644 extensions/trello/api.ts create mode 100644 extensions/trello/backslash.toml create mode 100644 extensions/trello/commands/boards.ts create mode 100644 extensions/trello/commands/create-card.ts create mode 100644 extensions/trello/commands/my-cards.ts create mode 100644 extensions/trello/lib.ts create mode 100644 extensions/trello/lib_test.ts create mode 100644 extensions/trello/live.ts create mode 100644 extensions/trello/views.ts diff --git a/extensions/asana/api.ts b/extensions/asana/api.ts new file mode 100644 index 0000000..97c8b14 --- /dev/null +++ b/extensions/asana/api.ts @@ -0,0 +1,122 @@ +// Calls to the Asana REST API with the user's personal access token. + +import type { Item } from "@backslash/api"; +import { API, asanaError, AuthError, pickWorkspace, setupItem, tokenOf, type Workspace } from "./lib.ts"; + +export interface Session { + token: string; + workspace: Workspace; + me: { gid: string; name: string }; +} + +export async function asana( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify({ data: options.body }), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Asana is unreachable: ${(e as Error).message}`); + } + const json = await res.json().catch(() => undefined) as { data?: T } | undefined; + if (!res.ok) throw asanaError(res.status, json); + return json?.data as T; +} + +/** Follows `next_page.offset`, up to `max` results. */ +export async function paged(token: string, path: string, max: number, signal?: AbortSignal): Promise { + const out: T[] = []; + let offset = ""; + do { + const sep = path.includes("?") ? "&" : "?"; + const res = await fetch(`${API}${path}${sep}limit=100${offset ? `&offset=${encodeURIComponent(offset)}` : ""}`, { + signal, + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }).catch((e) => { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Asana is unreachable: ${(e as Error).message}`); + }); + const json = await res.json().catch(() => undefined) as + | { data?: T[]; next_page?: { offset?: string } | null } + | undefined; + if (!res.ok) throw asanaError(res.status, json); + out.push(...(json?.data ?? [])); + offset = json?.next_page?.offset ?? ""; + } while (offset && out.length < max); + return out; +} + +// One process serves the views of a command: ask who I am once. +let cached: { key: string; session: Session } | undefined; + +/** The token, the workspace from the preferences and who I am. */ +export async function session(prefs: Record, signal?: AbortSignal): Promise { + const token = tokenOf(prefs); + const key = `${token}\n${String(prefs.workspace ?? "")}`; + if (cached?.key === key) return cached.session; + const me = await asana<{ gid: string; name: string; workspaces: Workspace[] }>( + token, + "/users/me?opt_fields=name,workspaces.name,workspaces.is_organization", + { signal }, + ); + const s = { token, me: { gid: me.gid, name: me.name }, workspace: pickWorkspace(me.workspaces, prefs.workspace) }; + cached = { key, session: s }; + return s; +} + +export async function guarded( + prefs: Record, + load: (s: Session) => Promise, + signal?: AbortSignal, +): Promise { + try { + return await load(await session(prefs, signal)); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } +} + +export const TASK_FIELDS = [ + "name", + "notes", + "completed", + "due_on", + "due_at", + "start_on", + "permalink_url", + "modified_at", + "num_subtasks", + "num_likes", + "projects.name", + "projects.color", + "tags.name", + "tags.color", + "memberships.project.name", + "memberships.section.name", +].join(","); + +export const PROJECT_FIELDS = [ + "name", + "color", + "notes", + "permalink_url", + "due_on", + "modified_at", + "owner.name", + "team.name", + "current_status_update.status_type", + "current_status_update.title", +].join(","); diff --git a/extensions/asana/backslash.toml b/extensions/asana/backslash.toml new file mode 100644 index 0000000..715ba7a --- /dev/null +++ b/extensions/asana/backslash.toml @@ -0,0 +1,43 @@ +[extension] +id = "asana" +title = "Asana" +description = "Your Asana tasks by due date and your projects; complete, reschedule and create tasks" +version = "1.0.0" +author = "Backslash" +icon = "bs-circle-check" +license = "MIT" + +[permissions] +net = ["app.asana.com"] + +[[commands]] +name = "my-tasks" +title = "My Asana Tasks" +description = "Overdue, today, upcoming and later; complete or reschedule them" +icon = "bs-circle-check" +keywords = ["asana", "tasks", "my tasks", "todo", "due", "today", "mes tâches", "tâches", "aujourd'hui", "échéance"] + +[[commands]] +name = "projects" +title = "Asana Projects" +description = "Favorites first, with their status; Enter shows the tasks" +icon = "bs-project" +keywords = ["asana", "projects", "portfolio", "status", "projets"] + +[[commands]] +name = "create-task" +title = "Create Asana Task" +description = "Title, notes, project and due date" +icon = "bs-plus" +keywords = ["asana", "new", "task", "add", "create", "nouvelle tâche", "ajouter", "créer"] + +[[preferences]] +name = "token" +title = "Personal access token" +description = "Asana → Settings → Apps → Developer apps → Create new token (app.asana.com/0/my-apps)" +type = "secret" + +[[preferences]] +name = "workspace" +title = "Workspace" +description = "The name of the workspace or organization to use; empty for your first one" diff --git a/extensions/asana/commands/create-task.ts b/extensions/asana/commands/create-task.ts new file mode 100644 index 0000000..9ee95b8 --- /dev/null +++ b/extensions/asana/commands/create-task.ts @@ -0,0 +1,15 @@ +import { type Context, detail } from "@backslash/api"; +import { session } from "../api.ts"; +import { AuthError, setupItem } from "../lib.ts"; +import { taskForm } from "../views.ts"; + +// Text typed before opening the command becomes the task name. +export default async (ctx: Context) => { + try { + return await taskForm(await session(ctx.preferences), { name: ctx.query.trim(), standalone: true }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const setup = setupItem(e); + return detail({ title: "Connect Asana", markdown: setup.detail!.markdown!, actions: setup.actions }); + } +}; diff --git a/extensions/asana/commands/my-tasks.ts b/extensions/asana/commands/my-tasks.ts new file mode 100644 index 0000000..a8b274f --- /dev/null +++ b/extensions/asana/commands/my-tasks.ts @@ -0,0 +1,53 @@ +import { action, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { guarded, paged, type Session, session, TASK_FIELDS } from "../api.ts"; +import { sortTasks, type Task, taskSection } from "../lib.ts"; +import { live } from "../live.ts"; +import { taskForm, taskItem } from "../views.ts"; + +let summary = ""; + +export default list({ + placeholder: "Filter your tasks by name, project, tag…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "soon", title: "Due This Week" }, + ], + }, + empty: { title: "No open task", description: "Nothing assigned to you here.", icon: Icons.done }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async ({ preferences }) => taskForm(await session(preferences)), { + icon: Icons.plus, + shortcut: "n", + }), + ], + items: ({ preferences, signal, filter }) => + live(() => + guarded(preferences, async (s: Session) => { + const tasks = await paged( + s.token, + `/tasks?assignee=me&workspace=${s.workspace.gid}&completed_since=now&opt_fields=${TASK_FIELDS}`, + 500, + signal, + ); + const now = new Date(); + const open = tasks.filter((t) => !t.completed); + const count = (name: string) => open.filter((t) => taskSection(t, now) === name).length; + const overdue = count("Overdue"), today = count("Today"); + summary = [ + `${open.length} task${open.length === 1 ? "" : "s"}`, + overdue ? `${overdue} overdue` : "", + today ? `${today} today` : "", + s.workspace.name, + ].filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [overdue ? `${overdue} overdue` : "", today ? `${today} due today` : ""].filter(Boolean) + .join(" · ") || null, + }).catch(() => {}); + const shown = filter === "soon" + ? open.filter((t) => ["Overdue", "Today", "Upcoming"].includes(taskSection(t, now))) + : open; + return sortTasks(shown, now).map((t) => taskItem(s, t, { section: taskSection(t, now) })); + }, signal), signal), +}); diff --git a/extensions/asana/commands/projects.ts b/extensions/asana/commands/projects.ts new file mode 100644 index 0000000..e0b1870 --- /dev/null +++ b/extensions/asana/commands/projects.ts @@ -0,0 +1,38 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { asana, guarded, paged, PROJECT_FIELDS } from "../api.ts"; +import type { Named, Project } from "../lib.ts"; +import { projectItem } from "../views.ts"; + +let total = 0; + +export default (ctx: Context) => + list({ + placeholder: "Filter projects by name, team, owner…", + empty: { title: "No project", description: "Projects of your workspace show up here.", icon: Icons.project }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : `${total} project${total === 1 ? "" : "s"}`, + items: ({ signal }) => + guarded(ctx.preferences, async (s) => { + const [all, favorites] = await Promise.all([ + paged( + s.token, + `/projects?workspace=${s.workspace.gid}&archived=false&opt_fields=${PROJECT_FIELDS}`, + 1000, + signal, + ), + asana( + s.token, + `/users/me/favorites?resource_type=project&workspace=${s.workspace.gid}&opt_fields=name`, + { signal }, + ).catch(() => [] as Named[]), + ]); + total = all.length; + const fav = new Map(favorites.map((f, i) => [f.gid, i])); + return [...all] + .sort((a, b) => + (fav.get(a.gid) ?? 1e9) - (fav.get(b.gid) ?? 1e9) || + (a.team?.name ?? "").localeCompare(b.team?.name ?? "") || + (b.modified_at ?? "").localeCompare(a.modified_at ?? "") + ) + .map((p) => projectItem(s, p, ctx.preferences, fav.has(p.gid))); + }, signal), + }); diff --git a/extensions/asana/lib.ts b/extensions/asana/lib.ts new file mode 100644 index 0000000..0399f8f --- /dev/null +++ b/extensions/asana/lib.ts @@ -0,0 +1,377 @@ +// Asana: account, response types and pure mapping (due sections, colors, metadata). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://app.asana.com/api/1.0"; +export const TOKEN_URL = "https://app.asana.com/0/my-apps"; + +/** The token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export function tokenOf(prefs: Record): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your Asana account"); + return token; +} + +/** `{ errors: [{ message }] }` → an Error. */ +export function asanaError(status: number, body: unknown): Error { + const message = (body as { errors?: { message?: string }[] } | undefined)?.errors?.[0]?.message ?? ""; + if (status === 401) return new AuthError("Asana rejected the personal access token"); + if (status === 402) return new Error(`Asana: ${message || "this needs a paid plan"}`); + if (status === 403) return new Error(`Asana refused it${message ? `: ${message}` : ""}`); + if (status === 404) return new Error("Not found on Asana: it may have been deleted"); + if (status === 429) return new Error("Asana is rate limiting: try again in a minute"); + return new Error(`Asana answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Named { + gid: string; + name: string; +} + +export interface Workspace extends Named { + is_organization?: boolean; +} + +export interface Task extends Named { + notes?: string; + completed?: boolean; + due_on?: string | null; + due_at?: string | null; + start_on?: string | null; + permalink_url: string; + modified_at?: string; + num_subtasks?: number; + num_likes?: number; + projects?: (Named & { color?: string | null })[]; + tags?: (Named & { color?: string | null })[]; + memberships?: { project?: Named; section?: Named }[]; + assignee?: Named | null; +} + +export interface Project extends Named { + color?: string | null; + notes?: string; + permalink_url?: string; + due_on?: string | null; + modified_at?: string; + owner?: Named | null; + team?: Named | null; + archived?: boolean; + current_status_update?: { status_type?: string; title?: string } | null; +} + +/** The workspace named in the preferences (case-insensitive), or the first one. */ +export function pickWorkspace(workspaces: Workspace[], wanted: unknown): Workspace { + if (!workspaces.length) throw new Error("This Asana account has no workspace"); + const name = String(wanted ?? "").trim().toLowerCase(); + if (!name) return workspaces[0]; + const found = workspaces.find((w) => w.name.toLowerCase() === name || w.gid === name) ?? + workspaces.find((w) => w.name.toLowerCase().includes(name)); + if (!found) { + throw new Error(`No Asana workspace “${String(wanted).trim()}”: ${workspaces.map((w) => w.name).join(", ")}`); + } + return found; +} + +// --------------------------------------------------------------------------- +// Colors + +/** Asana's colors (`dark-green`, `light-warm-gray`…) in the launcher's palette. */ +export function asanaColor(color: string | null | undefined): Color { + const c = (color ?? "").replace(/^(dark|light)-/, ""); + const map: Record = { + pink: "pink", + magenta: "pink", + green: "green", + blue: "blue", + indigo: "purple", + red: "red", + teal: "teal", + aqua: "teal", + brown: "orange", + orange: "orange", + coral: "orange", + yellow: "yellow", + purple: "purple", + "warm-gray": "gray", + }; + return map[c] ?? "gray"; +} + +/** A project's latest status update: on track, at risk… */ +export function projectStatus(p: Project): { text: string; color: Color } | undefined { + const type = p.current_status_update?.status_type; + const map: Record = { + on_track: { text: "On track", color: "green" }, + at_risk: { text: "At risk", color: "yellow" }, + off_track: { text: "Off track", color: "red" }, + on_hold: { text: "On hold", color: "blue" }, + complete: { text: "Complete", color: "purple" }, + achieved: { text: "Achieved", color: "green" }, + partial: { text: "Partially achieved", color: "yellow" }, + missed: { text: "Missed", color: "red" }, + dropped: { text: "Dropped", color: "gray" }, + }; + return type ? map[type] : undefined; +} + +// --------------------------------------------------------------------------- +// Tasks + +/** When the task is due: `due_at` (a time) or `due_on` (a day). */ +export function dueOf(task: Task): { date: Date; time: boolean } | undefined { + if (task.due_at) return { date: new Date(task.due_at), time: true }; + if (task.due_on) return { date: dayOf(task.due_on), time: false }; + return undefined; +} + +export const SECTIONS = ["Overdue", "Today", "Upcoming", "Later", "No Due Date"]; + +/** Overdue, Today, Upcoming (the next 7 days), Later, No Due Date. */ +export function taskSection(task: Task, now = new Date()): string { + const due = dueOf(task); + const bucket = dueBucket(due?.date, now, due?.time ?? false); + return bucket === "Tomorrow" || bucket === "This Week" ? "Upcoming" : bucket; +} + +export function sortTasks(tasks: Task[], now = new Date()): Task[] { + const rank = (t: Task) => SECTIONS.indexOf(taskSection(t, now)); + const when = (t: Task) => dueOf(t)?.date.getTime() ?? Infinity; + return [...tasks].sort((a, b) => + rank(a) - rank(b) || when(a) - when(b) || (b.modified_at ?? "").localeCompare(a.modified_at ?? "") + ); +} + +export function dueColor(task: Task, now = new Date()): Color | undefined { + const section = taskSection(task, now); + if (section === "Overdue") return "red"; + if (section === "Today") return "green"; + return undefined; +} + +/** The section of the task in a project ("Doing"), when it has one. */ +export function sectionIn(task: Task, projectGid?: string): string | undefined { + const m = task.memberships?.find((x) => !projectGid || x.project?.gid === projectGid); + const name = m?.section?.name; + return name && name !== "(no section)" && name !== "Untitled section" ? name : undefined; +} + +export function taskMetadata(task: Task, now = new Date()): Metadata[] { + const lines: Metadata[] = []; + const due = dueOf(task); + lines.push( + due + ? meta.text("Due", shortDue(due.date, now, due.time), { icon: Icons.calendar, color: dueColor(task, now) }) + : meta.text("Due", "No due date", { icon: Icons.calendar }), + ); + if (task.start_on) { + lines.push(meta.text("Start", shortDue(dayOf(task.start_on), now, false), { icon: Icons.calendar })); + } + const projects = task.projects ?? []; + if (projects.length) { + lines.push(meta.tags("Projects", projects.map((p) => ({ text: p.name, color: asanaColor(p.color) })))); + } + const section = sectionIn(task); + if (section) lines.push(meta.text("Section", section, { icon: Icons.layers })); + const tags = task.tags ?? []; + if (tags.length) lines.push(meta.tags("Tags", tags.map((t) => ({ text: t.name, color: asanaColor(t.color) })))); + if (task.num_subtasks) lines.push(meta.text("Subtasks", task.num_subtasks, { icon: Icons.squareCheck })); + if (task.num_likes) lines.push(meta.text("Likes", task.num_likes, { icon: Icons.heart })); + lines.push(meta.separator()); + if (task.modified_at) lines.push(meta.text("Modified", shortDue(new Date(task.modified_at), now, false))); + lines.push(meta.link("Link", "Open in Asana", task.permalink_url)); + return lines; +} + +export function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +/** The task as Asana wants it for a due date: a day, a precise time, or none. */ +export function dueBody(when: When | undefined): Record { + if (!when) return { due_on: null, due_at: null }; + return when.time ? { due_at: when.date.toISOString() } : { due_on: localDate(when.date) }; +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A personal access token from Asana", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Asana", + "", + "1. Open **Asana → Settings → Apps → Developer apps** (Enter opens it) and choose", + " **Create new token**. Give it a name like *Backslash*.", + "2. Paste the token in **Settings → Extensions → Asana → Configure → Personal access token**.", + "3. With several workspaces, type the one to use in **Workspace** (the first one otherwise).", + "", + "The token acts as you: Backslash only reads your tasks and projects and changes the tasks you act on.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Access Token", { icon: Icons.key })], + }; +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/asana/lib_test.ts b/extensions/asana/lib_test.ts new file mode 100644 index 0000000..cae956e --- /dev/null +++ b/extensions/asana/lib_test.ts @@ -0,0 +1,163 @@ +import { assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + asanaColor, + asanaError, + AuthError, + dueBody, + dueBucket, + dueChoices, + dueColor, + dueOf, + parseWhen, + pickWorkspace, + projectStatus, + sectionIn, + setupItem, + shortDue, + sortTasks, + type Task, + taskMetadata, + taskSection, + tokenOf, +} from "./lib.ts"; + +Deno.test("token and errors", () => { + assertThrows(() => tokenOf({ token: " " }), AuthError, "Connect"); + assertEquals(tokenOf({ token: " 2/1200/1208:abc " }), "2/1200/1208:abc"); + assertInstanceOf(asanaError(401, { errors: [{ message: "Not Authorized" }] }), AuthError); + assertEquals( + asanaError(400, { errors: [{ message: "due_on: Invalid date" }] }).message, + "Asana answered 400: due_on: Invalid date", + ); + assertStringIncludes(asanaError(404, undefined).message, "Not found"); + assertStringIncludes(setupItem(new AuthError("Connect your Asana account")).detail!.markdown!, "Create new token"); +}); + +Deno.test("workspace from the preferences", () => { + const ws = [{ gid: "1", name: "Acme" }, { gid: "2", name: "Personal Projects" }]; + assertEquals(pickWorkspace(ws, "").gid, "1"); + assertEquals(pickWorkspace(ws, "personal projects").gid, "2"); + assertEquals(pickWorkspace(ws, "personal").gid, "2"); + assertEquals(pickWorkspace(ws, "2").gid, "2"); + assertThrows(() => pickWorkspace(ws, "Globex"), Error, "Acme, Personal Projects"); + assertThrows(() => pickWorkspace([], ""), Error, "no workspace"); +}); + +Deno.test("colors and project status", () => { + assertEquals(asanaColor("dark-green"), "green"); + assertEquals(asanaColor("light-warm-gray"), "gray"); + assertEquals(asanaColor("dark-brown"), "orange"); + assertEquals(asanaColor(null), "gray"); + assertEquals(projectStatus({ gid: "p", name: "P", current_status_update: { status_type: "at_risk" } }), { + text: "At risk", + color: "yellow", + }); + assertEquals(projectStatus({ gid: "p", name: "P" }), undefined); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const task = (over: Partial): Task => ({ + gid: "t", + name: "Task", + permalink_url: "https://app.asana.com/0/0/1", + ...over, +}); + +Deno.test("tasks: due, sections, order", () => { + const late = task({ gid: "late", due_on: "2026-10-26" }); + const today = task({ gid: "today", due_on: "2026-10-27" }); + const todayAt = task({ gid: "at", due_at: new Date(2026, 9, 27, 17, 0).toISOString() }); + const pastAt = task({ gid: "pastAt", due_at: new Date(2026, 9, 27, 9, 0).toISOString() }); + const soon = task({ gid: "soon", due_on: "2026-10-30" }); + const later = task({ gid: "later", due_on: "2026-12-01" }); + const none = task({ gid: "none" }); + assertEquals(dueOf(today), { date: new Date(2026, 9, 27), time: false }); + assertEquals(taskSection(late, NOW), "Overdue"); + assertEquals(taskSection(today, NOW), "Today"); + assertEquals(taskSection(todayAt, NOW), "Today"); + assertEquals(taskSection(pastAt, NOW), "Overdue"); + assertEquals(taskSection(soon, NOW), "Upcoming"); + assertEquals(taskSection(later, NOW), "Later"); + assertEquals(taskSection(none, NOW), "No Due Date"); + assertEquals( + sortTasks([none, later, soon, todayAt, today, late, pastAt], NOW).map((t) => t.gid), + ["late", "pastAt", "today", "at", "soon", "later", "none"], + ); + assertEquals(dueColor(late, NOW), "red"); + assertEquals(dueColor(today, NOW), "green"); + assertEquals(dueColor(soon, NOW), undefined); +}); + +Deno.test("task metadata and sections", () => { + const t = task({ + due_at: new Date(2026, 9, 28, 9, 30).toISOString(), + projects: [{ gid: "p1", name: "Website", color: "dark-blue" }], + tags: [{ gid: "g1", name: "urgent", color: "dark-red" }], + memberships: [{ project: { gid: "p1", name: "Website" }, section: { gid: "s1", name: "In progress" } }], + num_subtasks: 3, + }); + const lines = taskMetadata(t, NOW); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Due")?.value, "tomorrow 09:30"); + assertEquals(by("Projects")?.tags, [{ text: "Website", color: "blue" }]); + assertEquals(by("Section")?.value, "In progress"); + assertEquals(by("Tags")?.tags, [{ text: "urgent", color: "red" }]); + assertEquals(by("Subtasks")?.value, "3"); + assertEquals(sectionIn(t, "p1"), "In progress"); + assertEquals( + sectionIn( + task({ memberships: [{ project: { gid: "p", name: "P" }, section: { gid: "s", name: "Untitled section" } }] }), + ), + undefined, + ); +}); + +Deno.test("due body for the API", () => { + assertEquals(dueBody(undefined), { due_on: null, due_at: null }); + assertEquals(dueBody(parseWhen("friday", NOW)), { due_on: "2026-10-30" }); + assertEquals(dueBody(parseWhen("friday 3pm", NOW)), { due_at: new Date(2026, 9, 30, 15).toISOString() }); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/asana/live.ts b/extensions/asana/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/asana/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +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); + }); + } +} diff --git a/extensions/asana/views.ts b/extensions/asana/views.ts new file mode 100644 index 0000000..5b7c2e2 --- /dev/null +++ b/extensions/asana/views.ts @@ -0,0 +1,262 @@ +// Items, actions and forms shared by the commands. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + pop, + storage, + submenu, + toast, +} from "@backslash/api"; +import { asana, guarded, paged, type Session, TASK_FIELDS } from "./api.ts"; +import { + asanaColor, + dayOf, + dueBody, + dueChoices, + dueColor, + dueOf, + excerpt, + parseWhen, + type Project, + projectStatus, + sectionIn, + shortDue, + type Task, + taskMetadata, +} from "./lib.ts"; +import { live, notifyChanged } from "./live.ts"; + +async function updateTask(s: Session, task: Task, body: Record, message: string) { + await asana(s.token, `/tasks/${task.gid}`, { method: "PUT", body }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +export function taskActions(s: Session, task: Task): Action[] { + const reschedule = submenu( + "Change Due Date", + dueChoices().map((c) => + action( + c.title, + () => + updateTask( + s, + task, + c.date ? { due_on: c.date } : { due_on: null, due_at: null }, + c.date ? `Due ${shortDue(dayOf(c.date), new Date(), false)}` : "Due date removed", + ), + { icon: c.date ? Icons.calendar : Icons.cross }, + ) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ); + return [ + open(task.permalink_url, "Open in Asana"), + task.completed + ? action("Mark Incomplete", () => updateTask(s, task, { completed: false }, "Marked incomplete"), { + icon: Icons.todo, + }) + : action("Complete Task", () => updateTask(s, task, { completed: true }, `Completed “${task.name}”`), { + icon: Icons.done, + }), + reschedule, + action("New Task…", () => taskForm(s), { icon: Icons.plus, shortcut: "n", section: "Task" }), + copy(task.permalink_url, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${task.name}](${task.permalink_url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(task.name, "Copy Title", { shortcut: "period", section: "Copy" }), + ]; +} + +export function taskItem(s: Session, task: Task, options: { section?: string; project?: string } = {}): Item { + const now = new Date(); + const due = dueOf(task); + const projects = (task.projects ?? []).filter((p) => p.gid !== options.project); + const tags = task.tags ?? []; + const where = options.project ? undefined : sectionIn(task); + return { + id: task.gid, + title: task.name || "Untitled task", + subtitle: where, + icon: task.completed + ? { source: Icons.done, tint: "green" } + : { source: Icons.todo, tint: dueColor(task, now) ?? "gray" }, + section: options.section, + keywords: [...(task.projects ?? []).map((p) => p.name), ...tags.map((t) => t.name)], + accessories: [ + ...tags.slice(0, 1).map((t) => ({ tag: t.name, color: asanaColor(t.color) })), + ...projects.slice(0, 1).map((p) => ({ + icon: { source: Icons.dot, tint: asanaColor(p.color) }, + text: p.name, + tooltip: "Project", + })), + ...(task.num_subtasks ? [{ icon: Icons.squareCheck, text: String(task.num_subtasks), tooltip: "Subtasks" }] : []), + ...(due + ? [{ + icon: Icons.calendar, + text: shortDue(due.date, now, due.time), + color: dueColor(task, now), + tooltip: "Due", + }] + : []), + ], + detail: { markdown: excerpt(task.notes), metadata: taskMetadata(task, now) }, + actions: taskActions(s, task), + }; +} + +/** The open tasks of a project, by section, live after a change. */ +export function projectTasks(s: Session, project: Project, prefs: Record): ListSpec { + return list({ + title: project.name, + placeholder: `Filter tasks of ${project.name}…`, + empty: { title: "No open task in this project", icon: Icons.done }, + status: (items) => `${items.length} open task${items.length === 1 ? "" : "s"}`, + actions: [ + action("New Task in Project…", () => taskForm(s, { project: project.gid }), { + icon: Icons.plus, + shortcut: "n", + }), + ...(project.permalink_url ? [open(project.permalink_url, "Open Project in Asana")] : []), + ], + items: ({ signal }) => + live(() => + guarded(prefs, async () => { + const tasks = await paged( + s.token, + `/projects/${project.gid}/tasks?completed_since=now&opt_fields=${TASK_FIELDS}`, + 500, + signal, + ); + // Asana returns them in the project's order, section by section. + return tasks.map((t) => + taskItem(s, t, { section: sectionIn(t, project.gid) ?? "Tasks", project: project.gid }) + ); + }, signal), signal), + }); +} + +export function projectItem(s: Session, p: Project, prefs: Record, favorite: boolean): Item { + const status = projectStatus(p); + return { + id: p.gid, + title: p.name, + // Sections are teams; favorites say which team. + subtitle: favorite ? p.team?.name : p.current_status_update?.title || undefined, + icon: { source: Icons.project, tint: asanaColor(p.color) }, + section: favorite ? "Favorites" : p.team?.name ?? "Projects", + keywords: [p.owner?.name ?? "", p.team?.name ?? ""].filter(Boolean), + accessories: [ + ...(favorite ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Favorite" }] : []), + ...(status ? [{ tag: status.text, color: status.color }] : []), + ...(p.owner ? [{ avatar: p.owner.name, tooltip: `Owner: ${p.owner.name}` }] : []), + ], + detail: { + markdown: excerpt(p.notes), + metadata: [ + ...(status + ? [ + meta.text("Status", status.text, { icon: Icons.activity, color: status.color }), + ...(p.current_status_update?.title ? [meta.text("Update", p.current_status_update.title)] : []), + ] + : []), + ...(p.owner ? [meta.person("Owner", p.owner.name)] : []), + ...(p.team ? [meta.text("Team", p.team.name, { icon: Icons.person })] : []), + ...(p.due_on ? [meta.text("Due", shortDue(dayOf(p.due_on), new Date(), false), { icon: Icons.calendar })] : []), + meta.separator(), + ...(p.modified_at ? [meta.text("Modified", shortDue(new Date(p.modified_at), new Date(), false))] : []), + ...(p.permalink_url ? [meta.link("Link", "Open in Asana", p.permalink_url)] : []), + ], + }, + actions: [ + action("Show Tasks", () => projectTasks(s, p, prefs), { icon: Icons.layers }), + ...(p.permalink_url ? [open(p.permalink_url, "Open in Asana")] : []), + action("New Task in Project…", () => taskForm(s, { project: p.gid }), { icon: Icons.plus, shortcut: "n" }), + ...(p.permalink_url ? [copy(p.permalink_url, "Copy Link", { shortcut: "c", section: "Copy" })] : []), + ], + }; +} + +export async function projects(s: Session, signal?: AbortSignal): Promise { + return await paged( + s.token, + `/projects?workspace=${s.workspace.gid}&archived=false&opt_fields=name,color`, + 500, + signal, + ); +} + +/** A new task; `standalone` when the form is the command itself (nothing to go back to). */ +export async function taskForm( + s: Session, + options: { project?: string; name?: string; standalone?: boolean } = {}, +) { + const all = await projects(s).catch(() => [] as Project[]); + const last = await storage.get("lastProject").catch(() => undefined); + const initial = options.project ?? (all.some((p) => p.gid === last) ? last! : ""); + return form({ + title: "New Asana task", + submitTitle: "Create Task", + fields: [ + field.text("name", "Task name", { required: true, placeholder: "What needs doing?", value: options.name ?? "" }), + field.textArea("notes", "Description"), + field.dropdown( + "project", + "Project", + [ + { value: "", title: "No project (My Tasks)" }, + ...[...all].sort((a, b) => a.name.localeCompare(b.name)) + .map((p) => ({ value: p.gid, title: p.name })), + ], + { value: initial }, + ), + field.text("due", "Due", { placeholder: "today, friday 3pm, next week, +3d, 2026-11-03…" }), + field.checkbox("me", "Assign to me", { value: true }), + ], + async submit(values) { + const when = parseWhen(String(values.due ?? "")); + const project = String(values.project ?? ""); + const task = await asana(s.token, "/tasks?opt_fields=name,permalink_url", { + method: "POST", + body: { + name: String(values.name).trim(), + notes: String(values.notes ?? "").trim() || undefined, + workspace: s.workspace.gid, + ...(project ? { projects: [project] } : {}), + ...(values.me ? { assignee: "me" } : {}), + ...(when ? dueBody(when) : {}), + }, + }); + await storage.set("lastProject", project).catch(() => {}); + notifyChanged(); + await toast(`Created “${task.name}”`, { style: "success" }); + if (!options.standalone) return pop(); + const projectName = all.find((p) => p.gid === project)?.name; + return detail({ + title: task.name, + markdown: `# ${task.name}\n\nCreated in **${projectName ?? "My Tasks"}**.`, + metadata: [ + meta.text("Workspace", s.workspace.name), + ...(projectName ? [meta.text("Project", projectName, { icon: Icons.project })] : []), + ...(when ? [meta.text("Due", shortDue(when.date, new Date(), when.time), { icon: Icons.calendar })] : []), + meta.link("Link", "Open in Asana", task.permalink_url), + ], + status: "Task created", + actions: [ + open(task.permalink_url, "Open in Asana"), + copy(task.permalink_url, "Copy Link", { shortcut: "c" }), + ], + }); + }, + }); +} diff --git a/extensions/clickup/api.ts b/extensions/clickup/api.ts new file mode 100644 index 0000000..f782383 --- /dev/null +++ b/extensions/clickup/api.ts @@ -0,0 +1,128 @@ +// Calls to the ClickUp API v2 with the user's personal token. + +import type { Item } from "@backslash/api"; +import { + API, + AuthError, + clickupError, + type ListRef, + setupItem, + type Task, + type Team, + tokenOf, + type User, +} from "./lib.ts"; + +export async function clickup( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: token, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`ClickUp is unreachable: ${(e as Error).message}`); + } + const json = await res.json().catch(() => undefined); + if (!res.ok) throw clickupError(res.status, json); + return json as T; +} + +export interface Session { + token: string; + me: User; + teams: Team[]; +} + +let cached: Session | undefined; + +/** Who I am and my workspaces ("teams" in the API), asked once per process. */ +export async function session(prefs: Record, signal?: AbortSignal): Promise { + const token = tokenOf(prefs); + if (cached?.token === token) return cached; + const [{ user }, { teams }] = await Promise.all([ + clickup<{ user: User }>(token, "/user", { signal }), + clickup<{ teams: Team[] }>(token, "/team", { signal }), + ]); + cached = { token, me: user, teams }; + return cached; +} + +export async function guarded( + prefs: Record, + load: (s: Session) => Promise, + signal?: AbortSignal, +): Promise { + try { + return await load(await session(prefs, signal)); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } +} + +/** My tasks in one workspace, page by page (100 each), up to 500. */ +export async function myTasks(s: Session, team: Team, closed: boolean, signal?: AbortSignal): Promise { + const out: Task[] = []; + for (let page = 0; page < 5; page++) { + const params = new URLSearchParams({ + page: String(page), + subtasks: "true", + include_closed: String(closed), + order_by: "due_date", + reverse: "true", + }); + params.append("assignees[]", String(s.me.id)); + const res = await clickup<{ tasks: Task[]; last_page?: boolean }>( + s.token, + `/team/${team.id}/task?${params}`, + { signal }, + ); + out.push(...res.tasks.map((t) => ({ ...t, team_id: t.team_id ?? team.id }))); + if (res.last_page === true || res.tasks.length < 100) break; + } + return out; +} + +/** Every list of every space and folder, for the new task form. */ +export async function allLists(s: Session): Promise<{ value: string; title: string }[]> { + const perTeam = await Promise.all(s.teams.map(async (team) => { + const { spaces } = await clickup<{ spaces: { id: string; name: string }[] }>( + s.token, + `/team/${team.id}/space?archived=false`, + ); + const perSpace = await Promise.all(spaces.map(async (space) => { + const [folders, loose] = await Promise.all([ + clickup<{ folders: { id: string; name: string; hidden?: boolean; lists: ListRef[] }[] }>( + s.token, + `/space/${space.id}/folder?archived=false`, + ).then((r) => r.folders).catch(() => []), + clickup<{ lists: ListRef[] }>(s.token, `/space/${space.id}/list?archived=false`) + .then((r) => r.lists).catch(() => []), + ]); + const prefix = s.teams.length > 1 ? `${team.name} › ${space.name}` : space.name; + return [ + ...loose.map((l) => ({ value: l.id, title: `${prefix} › ${l.name}` })), + ...folders.flatMap((f) => + f.lists.map((l) => ({ + value: l.id, + title: f.hidden ? `${prefix} › ${l.name}` : `${prefix} › ${f.name} › ${l.name}`, + })) + ), + ]; + })); + return perSpace.flat(); + })); + return perTeam.flat(); +} diff --git a/extensions/clickup/backslash.toml b/extensions/clickup/backslash.toml new file mode 100644 index 0000000..2bfbeb0 --- /dev/null +++ b/extensions/clickup/backslash.toml @@ -0,0 +1,32 @@ +[extension] +id = "clickup" +title = "ClickUp" +description = "Your ClickUp tasks across workspaces with their status, priority and due date; change status and create tasks" +version = "1.0.0" +author = "Backslash" +icon = "bs-circle-half" +license = "MIT" + +# The API, and people's pictures (attachments.clickup.com). +[permissions] +net = ["api.clickup.com", "attachments.clickup.com"] + +[[commands]] +name = "my-tasks" +title = "My ClickUp Tasks" +description = "Assigned to you in every workspace, by status; filter to do, in progress or done" +icon = "bs-circle-half" +keywords = ["clickup", "tasks", "my tasks", "todo", "status", "mes tâches", "tâches", "statut"] + +[[commands]] +name = "create-task" +title = "Create ClickUp Task" +description = "Name, list, priority and due date" +icon = "bs-plus" +keywords = ["clickup", "new", "task", "add", "create", "nouvelle tâche", "ajouter", "créer"] + +[[preferences]] +name = "token" +title = "Personal API token" +description = "ClickUp → avatar → Settings → Apps → API Token (pk_…)" +type = "secret" diff --git a/extensions/clickup/commands/create-task.ts b/extensions/clickup/commands/create-task.ts new file mode 100644 index 0000000..e2838d5 --- /dev/null +++ b/extensions/clickup/commands/create-task.ts @@ -0,0 +1,15 @@ +import { type Context, detail } from "@backslash/api"; +import { session } from "../api.ts"; +import { AuthError, setupItem } from "../lib.ts"; +import { taskForm } from "../views.ts"; + +// Text typed before opening the command becomes the task name. +export default async (ctx: Context) => { + try { + return await taskForm(await session(ctx.preferences), { name: ctx.query.trim(), standalone: true }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const setup = setupItem(e); + return detail({ title: "Connect ClickUp", markdown: setup.detail!.markdown!, actions: setup.actions }); + } +}; diff --git a/extensions/clickup/commands/my-tasks.ts b/extensions/clickup/commands/my-tasks.ts new file mode 100644 index 0000000..9044f0f --- /dev/null +++ b/extensions/clickup/commands/my-tasks.ts @@ -0,0 +1,58 @@ +import { action, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { guarded, myTasks, type Session, session } from "../api.ts"; +import { dueBucket, hasTime, matchesFilter, msDate, sortTasks, type Task } from "../lib.ts"; +import { live } from "../live.ts"; +import { sectionOrder, taskForm, taskItem } from "../views.ts"; + +let summary = ""; + +export default list({ + placeholder: "Filter tasks by name, list, tag, ID…", + filter: { + options: [ + { value: "active", title: "Not Done" }, + { value: "todo", title: "To Do" }, + { value: "progress", title: "In Progress" }, + { value: "done", title: "Done" }, + ], + }, + empty: { title: "No task here", description: "Tasks assigned to you show up here.", icon: Icons.done }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async ({ preferences }) => taskForm(await session(preferences)), { + icon: Icons.plus, + shortcut: "n", + }), + ], + items: ({ preferences, signal, filter }) => + live(() => + guarded(preferences, async (s: Session) => { + const closed = filter === "done"; + const all = (await Promise.all(s.teams.map((t) => myTasks(s, t, closed, signal)))).flat(); + const shown = all.filter((t) => matchesFilter(t, filter)); + const now = new Date(); + if (!filter || filter === "active") { + const overdue = shown.filter((t: Task) => { + const due = msDate(t.due_date); + return due && dueBucket(due, now, hasTime(due)) === "Overdue"; + }).length; + const moving = shown.filter((t) => t.status.type === "custom").length; + summary = [ + `${shown.length} task${shown.length === 1 ? "" : "s"}`, + moving ? `${moving} in progress` : "", + overdue ? `${overdue} overdue` : "", + ].filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [moving ? `${moving} in progress` : "", overdue ? `${overdue} overdue` : ""].filter(Boolean) + .join(" · ") || null, + }).catch(() => {}); + } else { + summary = `${shown.length} task${shown.length === 1 ? "" : "s"}`; + } + // Sections in status order; inside, by priority and due date. + const order = new Map(sectionOrder(shown).map((t, i) => [t.status.status, i])); + return sortTasks(shown) + .sort((a, b) => (order.get(a.status.status) ?? 0) - (order.get(b.status.status) ?? 0)) + .map((t) => taskItem(s, t, now)); + }, signal), signal), +}); diff --git a/extensions/clickup/lib.ts b/extensions/clickup/lib.ts new file mode 100644 index 0000000..f2624a6 --- /dev/null +++ b/extensions/clickup/lib.ts @@ -0,0 +1,435 @@ +// ClickUp: token, response types and pure mapping (statuses, priorities, dates). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://api.clickup.com/api/v2"; +export const TOKEN_URL = "https://app.clickup.com/settings/apps"; + +/** The token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export function tokenOf(prefs: Record): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your ClickUp account"); + if (!token.startsWith("pk_")) throw new AuthError("A ClickUp personal token starts with pk_"); + return token; +} + +/** `{ err, ECODE }` → an Error. */ +export function clickupError(status: number, body: unknown): Error { + const b = body as { err?: string; ECODE?: string } | undefined; + const message = b?.err ?? ""; + if (status === 401 || b?.ECODE === "OAUTH_017" || b?.ECODE === "OAUTH_019") { + return new AuthError("ClickUp rejected the API token"); + } + if (status === 429) return new Error("ClickUp is rate limiting: try again in a minute"); + if (status === 404) return new Error("Not found on ClickUp: it may have been deleted or moved"); + return new Error(`ClickUp answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Status { + status: string; + color: string; + type: string; + orderindex: number | string; +} + +export interface User { + id: number; + username: string | null; + email?: string; + initials?: string; + profilePicture?: string | null; +} + +export interface Task { + id: string; + custom_id?: string | null; + name: string; + text_content?: string | null; + status: Status; + date_created?: string; + date_updated?: string; + date_closed?: string | null; + creator?: User; + assignees: User[]; + tags: { name: string; tag_fg?: string; tag_bg?: string }[]; + parent?: string | null; + priority: { id: string; priority: string; color?: string } | null; + due_date: string | null; + start_date?: string | null; + time_estimate?: number | null; + list: { id: string; name: string }; + folder?: { id: string; name: string; hidden?: boolean }; + space?: { id: string }; + url: string; + team_id?: string; +} + +export interface Team { + id: string; + name: string; + color?: string; +} + +export interface ListRef { + id: string; + name: string; + statuses?: Status[]; +} + +// --------------------------------------------------------------------------- +// Statuses and priorities + +/** What's moving first: in progress (custom), to do (open), then done and closed. */ +const TYPE_ORDER = ["custom", "open", "done", "closed"]; + +export function typeRank(type: string): number { + const i = TYPE_ORDER.indexOf(type); + return i === -1 ? 1.5 : i; +} + +/** The palette color closest to a `#rrggbb` color. */ +export function colorOf(hex: string | null | undefined): Color { + // `#f50` is `#ff5500`. + const full = (hex ?? "").replace(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i, "#$1$1$2$2$3$3"); + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(full); + 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"; +} + +export function statusIcon(status: Status): { source: string; tint: Color } { + const tint = colorOf(status.color); + switch (status.type) { + case "open": + return { source: Icons.todo, tint }; + case "done": + case "closed": + return { source: Icons.done, tint }; + default: + return { source: /review|qa|test/i.test(status.status) ? Icons.inReview : Icons.inProgress, tint }; + } +} + +/** "in progress" → "In Progress", as ClickUp shows them. */ +export function statusName(status: string): string { + return status.replace(/(^|[\s-])(\p{L})/gu, (_, sep: string, c: string) => sep + c.toUpperCase()); +} + +export const PRIORITIES = [ + { id: 1, name: "Urgent", icon: Icons.urgent, color: "red" as Color }, + { id: 2, name: "High", icon: Icons.high, color: "yellow" as Color }, + { id: 3, name: "Normal", icon: Icons.medium, color: "blue" as Color }, + { id: 4, name: "Low", icon: Icons.low, color: "gray" as Color }, +]; + +export function priorityOf(task: Pick) { + const id = Number(task.priority?.id ?? 0); + return PRIORITIES.find((p) => p.id === id); +} + +export function sortTasks(tasks: Task[]): Task[] { + const due = (t: Task) => t.due_date ? Number(t.due_date) : Infinity; + const prio = (t: Task) => priorityOf(t)?.id ?? 5; + return [...tasks].sort((a, b) => + typeRank(a.status.type) - typeRank(b.status.type) || + a.status.status.localeCompare(b.status.status) || + prio(a) - prio(b) || due(a) - due(b) + ); +} + +/** Which tasks a status filter keeps. */ +export function matchesFilter(task: Task, filter: string | undefined): boolean { + const type = task.status.type; + switch (filter) { + case "todo": + return type === "open"; + case "progress": + return type === "custom"; + case "done": + return type === "done" || type === "closed"; + default: + return type !== "done" && type !== "closed"; + } +} + +// --------------------------------------------------------------------------- +// Dates and people + +/** ClickUp dates are milliseconds as strings. */ +export const msDate = (ms: string | null | undefined) => ms ? new Date(Number(ms)) : undefined; + +/** + * ClickUp doesn't say whether a due date has a time: a day without one is + * stored at 4:00 (or midnight) local time. + */ +export function hasTime(d: Date): boolean { + return !((d.getHours() === 4 || d.getHours() === 0) && d.getMinutes() === 0); +} + +export function dueColor(task: Task, now = new Date()): Color | undefined { + const due = msDate(task.due_date); + if (!due || task.status.type === "done" || task.status.type === "closed") return undefined; + const bucket = dueBucket(due, now, hasTime(due)); + return bucket === "Overdue" ? "red" : bucket === "Today" ? "orange" : undefined; +} + +/** "2h 30m" from milliseconds. */ +export function estimate(ms: number | null | undefined): string | undefined { + if (!ms) return undefined; + const minutes = Math.round(ms / 60_000); + const h = Math.floor(minutes / 60), m = minutes % 60; + return [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m"; +} + +/** A picture only from the host in `net`, else the name for initials. */ +export function avatarOf(user: User): string { + const pic = user.profilePicture ?? ""; + if (pic.startsWith("https://attachments.clickup.com/")) return pic; + return user.username ?? user.email ?? "?"; +} + +export const whereOf = (task: Task) => + task.folder && !task.folder.hidden ? `${task.folder.name} › ${task.list.name}` : task.list.name; + +export function taskMetadata(task: Task, options: { team?: string } = {}, now = new Date()): Metadata[] { + const icon = statusIcon(task.status); + const prio = priorityOf(task); + const lines: Metadata[] = [ + meta.text("Status", statusName(task.status.status), { icon: icon.source, color: icon.tint }), + prio + ? meta.text("Priority", prio.name, { icon: prio.icon, color: prio.color }) + : meta.text("Priority", "None", { icon: Icons.noPriority }), + ]; + const due = msDate(task.due_date); + if (due) { + lines.push( + meta.text("Due", shortDue(due, now, hasTime(due)), { icon: Icons.calendar, color: dueColor(task, now) }), + ); + } + const start = msDate(task.start_date); + if (start) lines.push(meta.text("Start", shortDue(start, now, hasTime(start)), { icon: Icons.calendar })); + for (const [i, u] of task.assignees.entries()) { + lines.push(meta.person(i === 0 ? "Assignees" : "", u.username ?? u.email ?? "Someone", avatarOf(u))); + } + if (task.tags.length) { + lines.push(meta.tags("Tags", task.tags.map((t) => ({ text: t.name, color: colorOf(t.tag_bg) })))); + } + const est = estimate(task.time_estimate); + if (est) lines.push(meta.text("Estimate", est, { icon: Icons.timer })); + lines.push(meta.separator()); + lines.push(meta.text("List", whereOf(task), { icon: Icons.layers })); + if (options.team) lines.push(meta.text("Workspace", options.team, { icon: Icons.home })); + if (task.custom_id) lines.push(meta.text("ID", task.custom_id, { icon: Icons.hash })); + const updated = msDate(task.date_updated); + if (updated) lines.push(meta.text("Updated", shortDue(updated, now, false))); + lines.push(meta.link("Link", "Open in ClickUp", task.url)); + return lines; +} + +export function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +/** `due_date` (ms) and `due_date_time` for the API. */ +export function dueFields(when: When | undefined): { due_date: number | null; due_date_time?: boolean } { + if (!when) return { due_date: null }; + const d = new Date(when.date); + if (!when.time) d.setHours(4, 0, 0, 0); + return { due_date: d.getTime(), due_date_time: when.time }; +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A personal API token from ClickUp", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect ClickUp", + "", + "1. In ClickUp, open your avatar → **Settings → Apps** (Enter opens it).", + "2. Under **API Token**, choose **Generate** (or **Copy** if you have one): it starts with `pk_`.", + "3. Paste it in **Settings → Extensions → ClickUp → Configure → Personal API token**.", + "", + "The token acts as you in every workspace you belong to; regenerating it in ClickUp revokes this one.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Get API Token", { icon: Icons.key })], + }; +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/clickup/lib_test.ts b/extensions/clickup/lib_test.ts new file mode 100644 index 0000000..5403946 --- /dev/null +++ b/extensions/clickup/lib_test.ts @@ -0,0 +1,193 @@ +import { assertEquals, assertInstanceOf, assertThrows } from "@std/assert"; +import { + AuthError, + avatarOf, + clickupError, + colorOf, + dueBucket, + dueChoices, + dueColor, + dueFields, + estimate, + hasTime, + matchesFilter, + parseWhen, + priorityOf, + shortDue, + sortTasks, + statusIcon, + statusName, + type Task, + taskMetadata, + tokenOf, + whereOf, +} from "./lib.ts"; + +Deno.test("token and errors", () => { + assertThrows(() => tokenOf({}), AuthError, "Connect"); + assertThrows(() => tokenOf({ token: "abc" }), AuthError, "pk_"); + assertEquals(tokenOf({ token: " pk_123_ABC " }), "pk_123_ABC"); + assertInstanceOf(clickupError(401, { err: "Token invalid", ECODE: "OAUTH_025" }), AuthError); + assertInstanceOf(clickupError(400, { err: "Oauth token not found", ECODE: "OAUTH_019" }), AuthError); + assertEquals( + clickupError(400, { err: "Status does not exist", ECODE: "ITEM_120" }).message, + "ClickUp answered 400: Status does not exist", + ); +}); + +Deno.test("status colors, icons and names", () => { + assertEquals(colorOf("#d3d3d3"), "gray"); + assertEquals(colorOf("#4194f6"), "blue"); + assertEquals(colorOf("#6bc950"), "green"); + assertEquals(colorOf("#f50"), "orange"); + assertEquals(colorOf("#a875ff"), "purple"); + assertEquals(statusIcon({ status: "to do", color: "#d3d3d3", type: "open", orderindex: 0 }), { + source: "bs-circle", + tint: "gray", + }); + assertEquals( + statusIcon({ status: "in review", color: "#a875ff", type: "custom", orderindex: 2 }).source, + "bs-circle-three-quarters", + ); + assertEquals( + statusIcon({ status: "in progress", color: "#4194f6", type: "custom", orderindex: 1 }).source, + "bs-circle-half", + ); + assertEquals( + statusIcon({ status: "complete", color: "#6bc950", type: "closed", orderindex: 3 }).source, + "bs-circle-check", + ); + assertEquals(statusName("in progress"), "In Progress"); + assertEquals(statusName("à faire"), "À Faire"); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const ms = (d: Date) => String(d.getTime()); + +function task(over: Partial): Task { + return { + id: "86abc", + name: "Task", + status: { status: "to do", color: "#d3d3d3", type: "open", orderindex: 0 }, + assignees: [], + tags: [], + priority: null, + due_date: null, + list: { id: "l1", name: "Sprint 12" }, + folder: { id: "f1", name: "Engineering", hidden: false }, + url: "https://app.clickup.com/t/86abc", + ...over, + }; +} + +Deno.test("filters by status type", () => { + const open = task({}); + const doing = task({ status: { status: "in progress", color: "#4194f6", type: "custom", orderindex: 1 } }); + const done = task({ status: { status: "done", color: "#6bc950", type: "done", orderindex: 2 } }); + const closed = task({ status: { status: "closed", color: "#6bc950", type: "closed", orderindex: 3 } }); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "active")), [true, true, false, false]); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "todo")), [true, false, false, false]); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "progress")), [false, true, false, false]); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "done")), [false, false, true, true]); +}); + +Deno.test("priorities and order", () => { + const urgent = task({ id: "u", priority: { id: "1", priority: "urgent", color: "#f50000" } }); + const low = task({ id: "l", priority: { id: "4", priority: "low", color: "#d8d8d8" } }); + const none = task({ id: "n" }); + const doing = task({ id: "d", status: { status: "in progress", color: "#4194f6", type: "custom", orderindex: 1 } }); + assertEquals(priorityOf(urgent)?.name, "Urgent"); + assertEquals(priorityOf(none), undefined); + assertEquals(sortTasks([none, low, urgent, doing]).map((t) => t.id), ["d", "u", "l", "n"]); +}); + +Deno.test("dates: ms strings, day-only dues, estimates", () => { + assertEquals(hasTime(new Date(2026, 9, 28, 4, 0)), false); + assertEquals(hasTime(new Date(2026, 9, 28, 15, 30)), true); + const late = task({ due_date: ms(new Date(2026, 9, 26, 4, 0)) }); + assertEquals(dueColor(late, NOW), "red"); + assertEquals(dueColor(task({ due_date: ms(new Date(2026, 9, 27, 4, 0)) }), NOW), "orange"); + assertEquals(dueColor(task({ due_date: ms(new Date(2026, 10, 27)) }), NOW), undefined); + assertEquals(estimate(9_000_000), "2h 30m"); + assertEquals(estimate(null), undefined); + assertEquals(dueFields(undefined), { due_date: null }); + assertEquals(dueFields(parseWhen("friday", NOW)), { + due_date: new Date(2026, 9, 30, 4).getTime(), + due_date_time: false, + }); + assertEquals(dueFields(parseWhen("friday 3pm", NOW)), { + due_date: new Date(2026, 9, 30, 15).getTime(), + due_date_time: true, + }); +}); + +Deno.test("metadata, place and avatars", () => { + const t = task({ + custom_id: "ENG-42", + due_date: ms(new Date(2026, 9, 28, 17, 0)), + priority: { id: "2", priority: "high", color: "#ffcc00" }, + assignees: [ + { id: 1, username: "Ada", profilePicture: "https://attachments.clickup.com/profilePictures/1_abc.jpg" }, + { id: 2, username: "Grace", profilePicture: "https://elsewhere.example/x.jpg" }, + ], + tags: [{ name: "backend", tag_bg: "#4194f6" }], + time_estimate: 3_600_000, + }); + const lines = taskMetadata(t, { team: "Acme" }, NOW); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Status")?.value, "To Do"); + assertEquals(by("Priority")?.value, "High"); + assertEquals(by("Due")?.value, "tomorrow 17:00"); + assertEquals(by("Assignees")?.avatar, "https://attachments.clickup.com/profilePictures/1_abc.jpg"); + assertEquals(by("Tags")?.tags, [{ text: "backend", color: "blue" }]); + assertEquals(by("Estimate")?.value, "1h"); + assertEquals(by("ID")?.value, "ENG-42"); + assertEquals(by("Workspace")?.value, "Acme"); + assertEquals(avatarOf({ id: 2, username: "Grace", profilePicture: "https://elsewhere.example/x.jpg" }), "Grace"); + assertEquals(whereOf(t), "Engineering › Sprint 12"); + assertEquals(whereOf(task({ folder: { id: "f", name: "hidden", hidden: true } })), "Sprint 12"); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/clickup/live.ts b/extensions/clickup/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/clickup/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +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); + }); + } +} diff --git a/extensions/clickup/views.ts b/extensions/clickup/views.ts new file mode 100644 index 0000000..23815c1 --- /dev/null +++ b/extensions/clickup/views.ts @@ -0,0 +1,204 @@ +// Items, actions and the new task form. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + meta, + open, + pop, + storage, + submenu, + toast, +} from "@backslash/api"; +import { allLists, clickup, type Session } from "./api.ts"; +import { + avatarOf, + dueChoices, + dueColor, + dueFields, + excerpt, + hasTime, + type ListRef, + msDate, + parseWhen, + PRIORITIES, + priorityOf, + shortDue, + type Status, + statusIcon, + statusName, + type Task, + taskMetadata, + typeRank, + whereOf, +} from "./lib.ts"; +import { notifyChanged } from "./live.ts"; + +async function update(s: Session, task: Task, body: Record, message: string) { + await clickup(s.token, `/task/${task.id}`, { method: "PUT", body }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +/** The statuses of the task's list (lists can override their space's). */ +async function statusesOf(s: Session, task: Task): Promise { + const list = await clickup(s.token, `/list/${task.list.id}`); + let statuses = list.statuses ?? []; + if (!statuses.length && task.space) { + const space = await clickup<{ statuses?: Status[] }>(s.token, `/space/${task.space.id}`); + statuses = space.statuses ?? []; + } + return [...statuses].sort((a, b) => Number(a.orderindex) - Number(b.orderindex)); +} + +export function taskActions(s: Session, task: Task): Action[] { + const status = submenu("Change Status", async () => { + const statuses = await statusesOf(s, task); + return statuses.filter((st) => st.status !== task.status.status).map((st) => + action(statusName(st.status), () => update(s, task, { status: st.status }, statusName(st.status)), { + icon: statusIcon(st).source, + }) + ); + }, { icon: Icons.inProgress, shortcut: "s", section: "Task" }); + const priority = submenu( + "Set Priority", + [ + ...PRIORITIES.map((p) => + action(p.name, () => update(s, task, { priority: p.id }, `Priority ${p.name.toLowerCase()}`), { icon: p.icon }) + ), + action("No Priority", () => update(s, task, { priority: null }, "Priority removed"), { icon: Icons.noPriority }), + ], + { icon: Icons.high, shortcut: "p", section: "Task" }, + ); + const due = submenu( + "Change Due Date", + dueChoices().map((c) => + action(c.title, () => { + const when = c.date ? parseWhen(c.date) : undefined; + return update( + s, + task, + dueFields(when), + when ? `Due ${shortDue(when.date, new Date(), false)}` : "Due date removed", + ); + }, { icon: c.date ? Icons.calendar : Icons.cross }) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ); + return [ + open(task.url, "Open in ClickUp"), + status, + priority, + due, + action("New Task in This List…", () => taskForm(s, { list: task.list.id }), { + icon: Icons.plus, + shortcut: "n", + section: "Task", + }), + copy(task.url, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${task.name}](${task.url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(task.custom_id ?? task.id, "Copy Task ID", { shortcut: "period", section: "Copy" }), + ]; +} + +export function taskItem(s: Session, task: Task, now = new Date()): Item { + const due = msDate(task.due_date); + const prio = priorityOf(task); + const team = s.teams.length > 1 ? s.teams.find((t) => t.id === task.team_id)?.name : undefined; + const others = task.assignees.filter((u) => u.id !== s.me.id); + return { + id: task.id, + title: task.name, + subtitle: whereOf(task), + icon: statusIcon(task.status), + section: statusName(task.status.status), + keywords: [ + task.custom_id ?? "", + task.list.name, + task.folder?.name ?? "", + ...task.tags.map((t) => t.name), + team ?? "", + ] + .filter(Boolean), + accessories: [ + ...task.tags.slice(0, 1).map((t) => ({ tag: t.name })), + ...(prio ? [{ icon: { source: prio.icon, tint: prio.color }, tooltip: `${prio.name} priority` }] : []), + ...(due + ? [{ icon: Icons.calendar, text: shortDue(due, now, hasTime(due)), color: dueColor(task, now), tooltip: "Due" }] + : []), + ...others.slice(0, 2).map((u) => ({ avatar: avatarOf(u), tooltip: u.username ?? undefined })), + ], + detail: { markdown: excerpt(task.text_content), metadata: taskMetadata(task, { team }, now) }, + actions: taskActions(s, task), + }; +} + +/** Statuses sorted like the sections: in progress, to do, done. */ +export function sectionOrder(tasks: Task[]): Task[] { + return [...tasks].sort((a, b) => + typeRank(a.status.type) - typeRank(b.status.type) || Number(a.status.orderindex) - Number(b.status.orderindex) + ); +} + +/** A new task; `standalone` when the form is the command itself (nothing to go back to). */ +export async function taskForm(s: Session, options: { list?: string; name?: string; standalone?: boolean } = {}) { + const lists = await allLists(s); + if (!lists.length) throw new Error("No ClickUp list found: create one in ClickUp first"); + const last = await storage.get("lastList").catch(() => undefined); + const initial = [options.list, last].find((id) => lists.some((l) => l.value === id)) ?? lists[0].value; + return form({ + title: "New ClickUp task", + submitTitle: "Create Task", + fields: [ + field.text("name", "Task name", { required: true, placeholder: "What needs doing?", value: options.name ?? "" }), + field.textArea("description", "Description"), + field.dropdown("list", "List", lists, { value: initial }), + field.dropdown( + "priority", + "Priority", + [{ value: "", title: "No priority" }, ...PRIORITIES.map((p) => ({ value: String(p.id), title: p.name }))], + { value: "" }, + ), + field.text("due", "Due", { placeholder: "today 5pm, friday, next week, +3d, 2026-11-03…" }), + field.checkbox("me", "Assign to me", { value: true }), + ], + async submit(values) { + const when = parseWhen(String(values.due ?? "")); + const task = await clickup(s.token, `/list/${values.list}/task`, { + method: "POST", + body: { + name: String(values.name).trim(), + description: String(values.description ?? "").trim() || undefined, + assignees: values.me ? [s.me.id] : [], + priority: values.priority ? Number(values.priority) : null, + ...(when ? dueFields(when) : {}), + }, + }); + await storage.set("lastList", String(values.list)).catch(() => {}); + notifyChanged(); + const where = lists.find((l) => l.value === values.list)?.title ?? ""; + await toast(`Created “${task.name}”`, { style: "success" }); + if (!options.standalone) return pop(); + return detail({ + title: task.name, + markdown: `# ${task.name}\n\nCreated in **${where}**.`, + metadata: [ + meta.text("List", where, { icon: Icons.layers }), + meta.text("Status", statusName(task.status?.status ?? "to do"), { + icon: task.status ? statusIcon(task.status).source : Icons.todo, + }), + ...(when ? [meta.text("Due", shortDue(when.date, new Date(), when.time), { icon: Icons.calendar })] : []), + meta.link("Link", "Open in ClickUp", task.url), + ], + status: "Task created", + actions: [open(task.url, "Open in ClickUp"), copy(task.url, "Copy Link", { shortcut: "c" })], + }); + }, + }); +} diff --git a/extensions/raindrop/api.ts b/extensions/raindrop/api.ts new file mode 100644 index 0000000..98a3a83 --- /dev/null +++ b/extensions/raindrop/api.ts @@ -0,0 +1,67 @@ +// Calls to the Raindrop.io REST API with the user's test token. + +import { storage } from "@backslash/api"; +import { API, type Collection, type Folder, folders, type Raindrop, raindropError, UNSORTED } from "./lib.ts"; + +export async function raindrop( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Raindrop.io is unreachable: ${(e as Error).message}`); + } + const json = await res.json().catch(() => undefined) as { result?: boolean; errorMessage?: string } | undefined; + if (!res.ok || json?.result === false) throw raindropError(res.ok ? 400 : res.status, json); + return json as T; +} + +/** Every collection, as paths ("Dev › Rust"). */ +export async function loadFolders(token: string, signal?: AbortSignal): Promise { + const [roots, children] = await Promise.all([ + raindrop<{ items: Collection[] }>(token, "/collections", { signal }), + raindrop<{ items: Collection[] }>(token, "/collections/childrens", { signal }), + ]); + const all = folders(roots.items, children.items); + await storage.set("folders", all).catch(() => {}); + return all; +} + +/** The collections saved last time (for the chips, at once), else loaded now. */ +export async function cachedFolders(token: string): Promise { + const saved = await storage.get("folders").catch(() => undefined); + if (saved?.length) return saved; + return await loadFolders(token).catch(() => []); +} + +export async function save( + token: string, + bookmark: { link: string; collection?: number; title?: string; tags?: string[]; note?: string }, +): Promise { + const { item } = await raindrop<{ item: Raindrop }>(token, "/raindrop", { + method: "POST", + body: { + link: bookmark.link, + collection: { $id: bookmark.collection ?? UNSORTED }, + ...(bookmark.title ? { title: bookmark.title } : {}), + ...(bookmark.tags?.length ? { tags: bookmark.tags } : {}), + ...(bookmark.note ? { note: bookmark.note } : {}), + // Raindrop fetches the title, excerpt and cover in the background. + pleaseParse: {}, + }, + }); + return item; +} diff --git a/extensions/raindrop/backslash.toml b/extensions/raindrop/backslash.toml new file mode 100644 index 0000000..a7f18da --- /dev/null +++ b/extensions/raindrop/backslash.toml @@ -0,0 +1,40 @@ +[extension] +id = "raindrop" +title = "Raindrop.io" +description = "Search your Raindrop.io bookmarks with their covers, tags and collection; save links, favorite, move and delete" +version = "1.0.0" +author = "Backslash" +icon = "bs-bookmark" +license = "MIT" + +# The REST API, and rdl.ink: Raindrop's own image proxy, which serves every +# cover (whatever site it comes from) from one host. +# No clipboard-read: paste a link in the search field instead (Ctrl+V), so the +# extension never reads what else is on the clipboard. +[permissions] +net = ["api.raindrop.io", "rdl.ink"] + +[[commands]] +name = "search" +title = "Search Raindrop Bookmarks" +description = "Across collections, with Raindrop's operators (#tag, type:article); paste a link to save it" +icon = "bs-bookmark" +keywords = ["raindrop", "bookmarks", "links", "read later", "favoris", "marque-pages", "signets", "liens"] + +[[commands]] +name = "save" +title = "Save Link to Raindrop" +description = "Saves a link to Unsorted; Raindrop fills in the title and cover" +icon = "bs-plus" +mode = "no-view" +keywords = ["raindrop", "save", "add", "bookmark", "link", "enregistrer", "ajouter", "favori"] +[[commands.arguments]] +name = "url" +placeholder = "https://…" +required = true + +[[preferences]] +name = "token" +title = "Test token" +description = "app.raindrop.io/settings/integrations → Create new app → Create test token" +type = "secret" diff --git a/extensions/raindrop/commands/save.ts b/extensions/raindrop/commands/save.ts new file mode 100644 index 0000000..e6212c9 --- /dev/null +++ b/extensions/raindrop/commands/save.ts @@ -0,0 +1,17 @@ +import { run } from "@backslash/api"; +import { save } from "../api.ts"; +import { AuthError, linkIn, tokenOf } from "../lib.ts"; + +export default run<{ url: string }>(async (ctx) => { + const link = linkIn(String(ctx.args.url ?? "")); + if (!link) throw new Error("That's not a link: https://example.com/page"); + let token: string; + try { + token = tokenOf(ctx.preferences); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Search Raindrop Bookmarks to see how`); + throw e; + } + await save(token, { link }); + return `Saved ${new URL(link).host.replace(/^www\./, "")} to Unsorted`; +}); diff --git a/extensions/raindrop/commands/search.ts b/extensions/raindrop/commands/search.ts new file mode 100644 index 0000000..d0b6d94 --- /dev/null +++ b/extensions/raindrop/commands/search.ts @@ -0,0 +1,61 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { cachedFolders, loadFolders, raindrop } from "../api.ts"; +import { AuthError, filterOptions, type Folder, linkIn, type Raindrop, setupItem, tokenOf } from "../lib.ts"; +import { bookmarkItem, saveItem } from "../views.ts"; + +let total = 0; + +export default async (ctx: Context) => { + let token = ""; + let all: Folder[] = []; + try { + token = tokenOf(ctx.preferences); + // The chips come from the collections saved last time; refreshed for the next time. + all = await cachedFolders(token); + loadFolders(token).then((fresh) => (all = fresh)).catch(() => {}); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + } + return list({ + placeholder: "Search bookmarks, #tag, type:article… or paste a link to save it", + filter: all.length ? { options: filterOptions(all) } : undefined, + empty: { + title: "No bookmark found", + description: "Raindrop.io searches titles, notes, tags and pages.", + icon: Icons.bookmark, + }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + const shown = items.filter((i) => i.id !== "save").length; + const n = `${total} bookmark${total === 1 ? "" : "s"}`; + return shown < total ? `${shown} of ${n}` : n; + }, + async search(query, { signal, filter }) { + let t: string; + try { + t = tokenOf(ctx.preferences); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } + const collection = Number(filter ?? 0) || 0; + const link = linkIn(query); + const params = new URLSearchParams({ perpage: "50", page: "0", sort: query.trim() ? "score" : "-created" }); + if (query.trim()) params.set("search", link ?? query.trim()); + try { + const res = await raindrop<{ items: Raindrop[]; count: number }>(t, `/raindrops/${collection}?${params}`, { + signal, + }); + total = res.count; + const items = res.items.map((r) => bookmarkItem(t, r, all, { showCollection: collection === 0 })); + if (!link) return items; + const existing = res.items.find((r) => r.link.replace(/\/$/, "") === link.replace(/\/$/, "")); + const target = collection > 0 || collection === -1 ? collection : -1; + return [saveItem(t, link, all, target, existing), ...items.filter((i) => i.id !== String(existing?._id))]; + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } + }, + }); +}; diff --git a/extensions/raindrop/lib.ts b/extensions/raindrop/lib.ts new file mode 100644 index 0000000..03ec26d --- /dev/null +++ b/extensions/raindrop/lib.ts @@ -0,0 +1,256 @@ +// Raindrop.io: token, response types and pure mapping (collections, covers, links). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://api.raindrop.io/rest/v1"; +export const TOKEN_URL = "https://app.raindrop.io/settings/integrations"; +export const UNSORTED = -1; +export const TRASH = -99; + +/** The token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export function tokenOf(prefs: Record): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your Raindrop.io account"); + return token; +} + +export function raindropError(status: number, body: unknown): Error { + const b = body as { errorMessage?: string; error?: string } | undefined; + const message = b?.errorMessage ?? (typeof b?.error === "string" ? b.error : ""); + if (status === 401) return new AuthError("Raindrop.io rejected the token"); + if (status === 429) return new Error("Raindrop.io is rate limiting: try again in a minute"); + if (status === 404) return new Error("Not found on Raindrop.io: it may have been deleted"); + return new Error(`Raindrop.io answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Collection { + _id: number; + title: string; + count?: number; + color?: string | null; + parent?: { $id: number } | null; + sort?: number; +} + +export interface Raindrop { + _id: number; + title: string; + excerpt?: string; + note?: string; + link: string; + domain?: string; + cover?: string; + type?: string; + tags?: string[]; + created?: string; + lastUpdate?: string; + important?: boolean; + broken?: boolean; + collection?: { $id: number }; + collectionId?: number; + highlights?: { text: string }[]; +} + +// --------------------------------------------------------------------------- +// Collections + +// A type, not an interface, so that it can be kept in storage as JSON. +export type Folder = { + id: number; + title: string; + /** "Dev › Rust". */ + path: string; + count: number; + color: Color; + root: boolean; +}; + +/** Roots and children as one list of paths, roots by Raindrop's order, children under their parent. */ +export function folders(roots: Collection[], children: Collection[]): Folder[] { + const byParent = new Map(); + for (const c of children) { + const p = c.parent?.$id; + if (p === undefined) continue; + byParent.set(p, [...(byParent.get(p) ?? []), c]); + } + const out: Folder[] = []; + const walk = (c: Collection, prefix: string, depth: number) => { + const path = prefix ? `${prefix} › ${c.title}` : c.title; + out.push({ id: c._id, title: c.title, path, count: c.count ?? 0, color: colorOf(c.color), root: depth === 0 }); + if (depth > 5) return; + for (const child of (byParent.get(c._id) ?? []).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))) { + walk(child, path, depth + 1); + } + }; + for (const r of [...roots].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))) walk(r, "", 0); + return out; +} + +export function collectionName(id: number | undefined, all: Folder[]): string { + if (id === UNSORTED) return "Unsorted"; + if (id === TRASH) return "Trash"; + return all.find((f) => f.id === id)?.path ?? "Unknown collection"; +} + +/** The chips: all, unsorted, then the biggest top-level collections. */ +export function filterOptions(all: Folder[], max = 6): { value: string; title: string }[] { + const roots = all.filter((f) => f.root).sort((a, b) => b.count - a.count).slice(0, max); + return [ + { value: "0", title: "All" }, + { value: String(UNSORTED), title: "Unsorted" }, + ...roots.map((f) => ({ value: String(f.id), title: f.title })), + ]; +} + +/** The palette color closest to a `#rrggbb` 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 "blue"; + 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"; +} + +// --------------------------------------------------------------------------- +// Bookmarks + +export const collectionOf = (r: Raindrop) => r.collection?.$id ?? r.collectionId; + +/** + * Covers come from any site; Raindrop's proxy (rdl.ink) serves them all from + * one host, resized. Covers already on rdl.ink are used as they are. + */ +export function coverUrl(cover: string | undefined, width = 640, height = 360): string | undefined { + if (!cover || !/^https?:\/\//i.test(cover)) return undefined; + if (/^https:\/\/rdl\.ink\//.test(cover)) return cover; + return `https://rdl.ink/render/${encodeURIComponent(cover)}?mode=crop&width=${width}&height=${height}&dpr=2`; +} + +export const TYPES: Record = { + link: { icon: Icons.link, tint: "blue", name: "Link" }, + article: { icon: Icons.text, tint: "teal", name: "Article" }, + image: { icon: Icons.image, tint: "pink", name: "Image" }, + video: { icon: Icons.video, tint: "red", name: "Video" }, + document: { icon: Icons.file, tint: "orange", name: "Document" }, + audio: { icon: Icons.music, tint: "purple", name: "Audio" }, +}; + +export const typeOf = (r: Raindrop) => TYPES[r.type ?? "link"] ?? TYPES.link; + +export function domainOf(r: Raindrop): string { + if (r.domain) return r.domain.replace(/^www\./, ""); + try { + return new URL(r.link).host.replace(/^www\./, ""); + } catch { + return r.link; + } +} + +/** A link typed or pasted in the search field: https://…, or a bare domain with a path. */ +export function linkIn(query: string): string | undefined { + const q = query.trim(); + if (!q || /\s/.test(q)) return undefined; + const candidate = /^https?:\/\//i.test(q) ? q : /^[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?$/i.test(q) ? `https://${q}` : ""; + if (!candidate) return undefined; + try { + const url = new URL(candidate); + return url.hostname.includes(".") ? url.href : undefined; + } catch { + return undefined; + } +} + +/** "a, #b c" → ["a", "b", "c"]. */ +export function parseTags(text: string): string[] { + return [...new Set(text.split(/[,\s]+/).map((t) => t.replace(/^#/, "").trim()).filter(Boolean))]; +} + +/** "today", "yesterday", "3d", "Oct 25", "Oct 2024". */ +export function savedAgo(iso: string | undefined, now = new Date()): string { + if (!iso) return ""; + const d = new Date(iso); + const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const days = Math.round((day(now) - day(d)) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString("en", { month: "short", day: "numeric" }); + return d.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +export function bookmarkMarkdown(r: Raindrop): string | undefined { + const parts: string[] = []; + if (r.excerpt?.trim()) parts.push(r.excerpt.trim()); + if (r.note?.trim()) parts.push(r.note.trim().split("\n").map((l) => `> ${l}`).join("\n")); + const hl = r.highlights ?? []; + if (hl.length) { + parts.push( + `**Highlights**\n\n${hl.slice(0, 3).map((h) => `- “${h.text.trim().slice(0, 200)}”`).join("\n")}${ + hl.length > 3 ? `\n- …and ${hl.length - 3} more` : "" + }`, + ); + } + return parts.join("\n\n") || undefined; +} + +export function bookmarkMetadata(r: Raindrop, all: Folder[], now = new Date()): Metadata[] { + const type = typeOf(r); + const id = collectionOf(r); + const folder = all.find((f) => f.id === id); + const lines: Metadata[] = [ + meta.link("Link", domainOf(r), r.link), + meta.text("Collection", collectionName(id, all), { + icon: id === UNSORTED ? Icons.inbox : Icons.folder, + color: folder?.color, + }), + ]; + if (r.tags?.length) lines.push(meta.tags("Tags", r.tags.map((t) => ({ text: `#${t}`, color: "blue" as Color })))); + lines.push(meta.text("Type", type.name, { icon: type.icon })); + if (r.important) lines.push(meta.text("Favorite", "Yes", { icon: Icons.heartFilled, color: "red" })); + if (r.broken) lines.push(meta.text("Status", "Broken link", { icon: Icons.alert, color: "red" })); + if (r.highlights?.length) lines.push(meta.text("Highlights", r.highlights.length, { icon: Icons.sparkle })); + if (r.created) { + lines.push(meta.separator(), meta.text("Saved", `${savedAgo(r.created, now)}`, { icon: Icons.clock })); + } + return lines; +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A test token from Raindrop.io", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Raindrop.io", + "", + "1. Open **app.raindrop.io → Settings → Integrations** (Enter opens it).", + "2. Under **For Developers**, choose **Create new app** (any name, e.g. *Backslash*) and open it.", + "3. Choose **Create test token** and copy it.", + "4. Paste it in **Settings → Extensions → Raindrop.io → Configure → Test token**.", + "", + "A test token gives access to your own bookmarks only; delete the app to revoke it.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Test Token", { icon: Icons.key })], + }; +} diff --git a/extensions/raindrop/lib_test.ts b/extensions/raindrop/lib_test.ts new file mode 100644 index 0000000..7b681f2 --- /dev/null +++ b/extensions/raindrop/lib_test.ts @@ -0,0 +1,127 @@ +import { assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + AuthError, + bookmarkMarkdown, + bookmarkMetadata, + type Collection, + collectionName, + coverUrl, + domainOf, + filterOptions, + folders, + linkIn, + parseTags, + type Raindrop, + raindropError, + savedAgo, + setupItem, + tokenOf, + typeOf, +} from "./lib.ts"; + +Deno.test("token and errors", () => { + assertThrows(() => tokenOf({}), AuthError, "Connect"); + assertEquals(tokenOf({ token: " ae2e3f-123 " }), "ae2e3f-123"); + assertInstanceOf(raindropError(401, { result: false, errorMessage: "Unauthorized" }), AuthError); + assertEquals( + raindropError(400, { result: false, errorMessage: "link is required" }).message, + "Raindrop.io answered 400: link is required", + ); + assertStringIncludes(setupItem(new AuthError("x")).detail!.markdown!, "Create test token"); +}); + +const roots: Collection[] = [ + { _id: 10, title: "Dev", count: 120, sort: 1, color: "#3f88ff" }, + { _id: 20, title: "Recipes", count: 40, sort: 0, color: "#e44a4a" }, + { _id: 30, title: "Travel", count: 3, sort: 2 }, +]; +const children: Collection[] = [ + { _id: 11, title: "Rust", count: 30, parent: { $id: 10 }, sort: 1 }, + { _id: 12, title: "TypeScript", count: 25, parent: { $id: 10 }, sort: 0 }, + { _id: 13, title: "Async", count: 4, parent: { $id: 11 } }, +]; + +Deno.test("collections as paths, chips", () => { + const all = folders(roots, children); + assertEquals(all.map((f) => f.path), [ + "Recipes", + "Dev", + "Dev › TypeScript", + "Dev › Rust", + "Dev › Rust › Async", + "Travel", + ]); + assertEquals(all.find((f) => f.id === 20)?.color, "red"); + assertEquals(collectionName(13, all), "Dev › Rust › Async"); + assertEquals(collectionName(-1, all), "Unsorted"); + assertEquals(collectionName(-99, all), "Trash"); + assertEquals(filterOptions(all, 2).map((o) => o.title), ["All", "Unsorted", "Dev", "Recipes"]); + assertEquals(filterOptions(all, 2).map((o) => o.value), ["0", "-1", "10", "20"]); +}); + +Deno.test("covers go through Raindrop's proxy", () => { + assertEquals( + coverUrl("https://images.example.com/og.png?x=1", 640, 360), + "https://rdl.ink/render/https%3A%2F%2Fimages.example.com%2Fog.png%3Fx%3D1?mode=crop&width=640&height=360&dpr=2", + ); + assertEquals(coverUrl("https://rdl.ink/render/abc?mode=crop"), "https://rdl.ink/render/abc?mode=crop"); + assertEquals(coverUrl(""), undefined); + assertEquals(coverUrl("data:image/png;base64,xx"), undefined); +}); + +Deno.test("links typed in the search field", () => { + assertEquals(linkIn("https://deno.com/blog"), "https://deno.com/blog"); + assertEquals(linkIn(" deno.com/blog/v2 "), "https://deno.com/blog/v2"); + assertEquals(linkIn("example.org"), "https://example.org/"); + assertEquals(linkIn("rust async"), undefined); + assertEquals(linkIn("#rust"), undefined); + assertEquals(linkIn("type:article"), undefined); + assertEquals(linkIn("http://localhost"), undefined); + assertEquals(linkIn(""), undefined); +}); + +Deno.test("tags, dates, types, domains", () => { + assertEquals(parseTags("reading, #rust design,rust"), ["reading", "rust", "design"]); + assertEquals(parseTags(""), []); + const now = new Date(2026, 9, 27, 14); + assertEquals(savedAgo(new Date(2026, 9, 27, 9).toISOString(), now), "today"); + assertEquals(savedAgo(new Date(2026, 9, 26, 9).toISOString(), now), "yesterday"); + assertEquals(savedAgo(new Date(2026, 9, 22).toISOString(), now), "5d"); + assertEquals(savedAgo(new Date(2026, 1, 3).toISOString(), now), "Feb 3"); + assertEquals(savedAgo(new Date(2024, 1, 3).toISOString(), now), "Feb 2024"); + assertEquals(typeOf({ _id: 1, title: "", link: "", type: "video" }).name, "Video"); + assertEquals(typeOf({ _id: 1, title: "", link: "", type: "weird" }).name, "Link"); + assertEquals(domainOf({ _id: 1, title: "", link: "https://www.example.com/a" }), "example.com"); + assertEquals(domainOf({ _id: 1, title: "", link: "x", domain: "www.deno.com" }), "deno.com"); +}); + +Deno.test("bookmark detail", () => { + const all = folders(roots, children); + const r: Raindrop = { + _id: 1, + title: "Async Rust", + link: "https://rust-lang.github.io/async-book/", + domain: "rust-lang.github.io", + excerpt: "The async book.", + note: "Read chapter 3\nthen 4", + tags: ["rust", "async"], + type: "article", + important: true, + collection: { $id: 13 }, + created: new Date().toISOString(), + highlights: [{ text: "Futures are lazy" }, { text: "a" }, { text: "b" }, { text: "c" }], + }; + assertEquals( + bookmarkMarkdown(r), + "The async book.\n\n> Read chapter 3\n> then 4\n\n**Highlights**\n\n- “Futures are lazy”\n- “a”\n- “b”\n- …and 1 more", + ); + const lines = bookmarkMetadata(r, all); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Link")?.link, r.link); + assertEquals(by("Collection")?.value, "Dev › Rust › Async"); + assertEquals(by("Tags")?.tags?.map((t) => t.text), ["#rust", "#async"]); + assertEquals(by("Type")?.value, "Article"); + assertEquals(by("Favorite")?.value, "Yes"); + assertEquals(by("Highlights")?.value, "4"); + assertEquals(bookmarkMarkdown({ _id: 2, title: "", link: "" }), undefined); +}); diff --git a/extensions/raindrop/views.ts b/extensions/raindrop/views.ts new file mode 100644 index 0000000..aee6e34 --- /dev/null +++ b/extensions/raindrop/views.ts @@ -0,0 +1,195 @@ +// Items, actions and the save form. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + meta, + open, + refresh, + replace, + submenu, + toast, +} from "@backslash/api"; +import { raindrop, save } from "./api.ts"; +import { + bookmarkMarkdown, + bookmarkMetadata, + collectionName, + collectionOf, + coverUrl, + domainOf, + type Folder, + parseTags, + type Raindrop, + savedAgo, + typeOf, + UNSORTED, +} from "./lib.ts"; + +export const appUrl = (r: Raindrop) => `https://app.raindrop.io/my/${collectionOf(r) ?? 0}/item/${r._id}/edit`; + +async function update(token: string, r: Raindrop, body: Record, message: string) { + await raindrop(token, `/raindrop/${r._id}`, { method: "PUT", body }); + await toast(message, { style: "success" }); + return refresh(); +} + +export function bookmarkActions(token: string, r: Raindrop, all: Folder[]): Action[] { + const here = collectionOf(r); + return [ + open(r.link, "Open Link"), + open(appUrl(r), "Open in Raindrop.io", { icon: Icons.bookmark, shortcut: "o" }), + action( + r.important ? "Remove from Favorites" : "Add to Favorites", + () => + update(token, r, { important: !r.important }, r.important ? "Removed from favorites" : "Added to favorites"), + { icon: r.important ? Icons.heart : Icons.heartFilled, shortcut: "f", section: "Bookmark" }, + ), + submenu( + "Move to Collection", + [ + ...(here === UNSORTED ? [] : [{ id: UNSORTED, path: "Unsorted" }]), + ...all.filter((f) => f.id !== here), + ].map((f) => + action(f.path, () => update(token, r, { collection: { $id: f.id } }, `Moved to ${f.path}`), { + icon: f.id === UNSORTED ? Icons.inbox : Icons.folder, + }) + ), + { icon: Icons.folder, shortcut: "m", section: "Bookmark" }, + ), + copy(r.link, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${r.title || domainOf(r)}](${r.link})`, "Copy as Markdown Link", { section: "Copy" }), + copy(r.title || r.link, "Copy Title", { shortcut: "period", section: "Copy" }), + action("Delete Bookmark", async () => { + await raindrop(token, `/raindrop/${r._id}`, { method: "DELETE" }); + await toast("Moved to Trash", { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Move “${r.title || domainOf(r)}” to the Trash?`, + shortcut: "Delete", + section: "Danger", + }), + ]; +} + +export function bookmarkItem(token: string, r: Raindrop, all: Folder[], options: { showCollection: boolean }): Item { + const type = typeOf(r); + const id = collectionOf(r); + const folder = all.find((f) => f.id === id); + const tags = r.tags ?? []; + return { + id: String(r._id), + title: r.title || domainOf(r), + subtitle: domainOf(r), + icon: { source: type.icon, tint: type.tint }, + keywords: [...tags, r.link, collectionName(id, all)], + accessories: [ + ...(r.important ? [{ icon: { source: Icons.heartFilled, tint: "red" as const }, tooltip: "Favorite" }] : []), + ...(r.broken ? [{ icon: { source: Icons.alert, tint: "red" as const }, tooltip: "Broken link" }] : []), + ...tags.slice(0, 2).map((t) => ({ text: `#${t}`, color: "blue" as const })), + ...(options.showCollection + ? [{ + icon: { source: id === UNSORTED ? Icons.inbox : Icons.folder, tint: folder?.color ?? "gray" }, + text: collectionName(id, all).split(" › ").pop(), + tooltip: collectionName(id, all), + }] + : []), + ...(r.created ? [{ text: savedAgo(r.created), tooltip: "Saved" }] : []), + ], + detail: { + image: coverUrl(r.cover), + markdown: bookmarkMarkdown(r), + metadata: bookmarkMetadata(r, all), + }, + actions: bookmarkActions(token, r, all), + }; +} + +function savedPage(r: Raindrop, all: Folder[]) { + return detail({ + title: "Saved", + markdown: `# ${r.title || domainOf(r)}\n\nSaved to **${collectionName(collectionOf(r), all)}**. ` + + "Raindrop.io fills in the title, excerpt and cover in a moment.", + metadata: [ + meta.link("Link", domainOf(r), r.link), + meta.text("Collection", collectionName(collectionOf(r), all), { icon: Icons.folder }), + ...(r.tags?.length ? [meta.tags("Tags", r.tags.map((t) => `#${t}`))] : []), + ], + status: "Bookmark saved", + actions: [ + open(appUrl(r), "Open in Raindrop.io", { icon: Icons.bookmark }), + open(r.link, "Open Link"), + copy(r.link, "Copy Link", { shortcut: "c" }), + ], + }); +} + +export function saveForm(token: string, link: string, all: Folder[], collection = UNSORTED) { + return form({ + title: "Save to Raindrop.io", + submitTitle: "Save Bookmark", + fields: [ + field.text("link", "Link", { required: true, value: link }), + field.text("title", "Title", { placeholder: "Leave empty: Raindrop.io reads it from the page" }), + field.dropdown( + "collection", + "Collection", + [{ value: String(UNSORTED), title: "Unsorted" }, ...all.map((f) => ({ value: String(f.id), title: f.path }))], + { value: String(collection) }, + ), + field.text("tags", "Tags", { placeholder: "reading, rust, design" }), + field.textArea("note", "Note"), + ], + async submit(values) { + const r = await save(token, { + link: String(values.link).trim(), + collection: Number(values.collection), + title: String(values.title ?? "").trim() || undefined, + tags: parseTags(String(values.tags ?? "")), + note: String(values.note ?? "").trim() || undefined, + }); + await toast("Saved to Raindrop.io", { style: "success" }); + // The search below doesn't reload on its own: show what was saved instead. + return replace(savedPage(r, all)); + }, + }); +} + +/** The first row when the search field holds a link. */ +export function saveItem(token: string, link: string, all: Folder[], collection: number, existing?: Raindrop): Item { + const where = collectionName(collection, all); + if (existing) { + return { + ...bookmarkItem(token, existing, all, { showCollection: true }), + id: "save", + section: "Already Saved", + }; + } + return { + id: "save", + title: `Save ${link.replace(/^https?:\/\//, "").replace(/\/$/, "")}`, + subtitle: `to ${where}`, + icon: { source: Icons.plus, tint: "green" }, + section: "Save", + actions: [ + action("Save Bookmark", async () => { + const r = await save(token, { link, collection }); + await toast(`Saved to ${collectionName(collectionOf(r) ?? collection, all)}`, { style: "success" }); + return refresh(); + }, { icon: Icons.bookmark }), + action("Save with Details…", () => saveForm(token, link, all, collection), { + icon: Icons.settings, + shortcut: "e", + }), + open(link, "Open Link"), + ], + }; +} diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts new file mode 100644 index 0000000..d8d4859 --- /dev/null +++ b/extensions/slack/api.ts @@ -0,0 +1,147 @@ +// Calls to the Slack Web API with the user's token, and what the views share. + +import { type Context, toast } from "@backslash/api"; +import { + type Conversation, + type Duration, + encodeParams, + expiryFor, + slackError, + type Status, + type User, + userToken, +} from "./lib.ts"; + +export const API = "https://slack.com/api"; + +export async function slack( + token: string, + method: string, + params: Record = {}, + signal?: AbortSignal, +): Promise { + let res: Response; + try { + res = await fetch(`${API}/${method}`, { + method: "POST", + signal, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/x-www-form-urlencoded" }, + body: encodeParams(params), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Slack is unreachable: ${(e as Error).message}`); + } + if (res.status === 429) { + await res.body?.cancel(); + throw new Error(`Slack is rate limiting: try again in ${res.headers.get("retry-after") ?? "a few"} seconds`); + } + const body = await res.json().catch(() => undefined) as { ok?: boolean; error?: string; needed?: string }; + if (!body) throw new Error(`Slack answered ${res.status}`); + if (!body.ok) throw slackError(body); + return body as T; +} + +/** Follows `response_metadata.next_cursor` up to `max` results. */ +export async function paged( + token: string, + method: string, + key: string, + params: Record, + max: number, + signal?: AbortSignal, +): Promise { + const out: T[] = []; + let cursor = ""; + do { + const page = await slack & { response_metadata?: { next_cursor?: string } }>( + token, + method, + { ...params, limit: 200, cursor: cursor || undefined }, + signal, + ); + out.push(...((page[key] as T[] | undefined) ?? [])); + cursor = page.response_metadata?.next_cursor ?? ""; + } while (cursor && out.length < max); + return out; +} + +export const tokenOf = (ctx: Pick) => userToken(ctx.preferences); + +export interface Team { + team_id: string; + team: string; + url: string; + user_id: string; + user: string; +} + +export const whoAmI = (token: string, signal?: AbortSignal) => slack(token, "auth.test", {}, signal); + +export async function getStatus(token: string, signal?: AbortSignal): Promise { + const { profile } = await slack< + { profile: { status_text?: string; status_emoji?: string; status_expiration?: number } } + >(token, "users.profile.get", {}, signal); + return { + text: profile.status_text ?? "", + emoji: profile.status_emoji ?? "", + expiration: profile.status_expiration ?? 0, + }; +} + +export async function setStatus(token: string, status: Status): Promise { + await slack(token, "users.profile.set", { + profile: { status_text: status.text, status_emoji: status.emoji, status_expiration: status.expiration }, + }); +} + +export const clearStatus = (token: string) => setStatus(token, { text: "", emoji: "", expiration: 0 }); + +export async function setStatusFor( + token: string, + status: { text: string; emoji: string }, + duration: Duration, +): Promise { + const full = { ...status, expiration: expiryFor(duration) }; + await setStatus(token, full); + return full; +} + +export interface Dnd { + dnd_enabled: boolean; + next_dnd_start_ts?: number; + next_dnd_end_ts?: number; + snooze_enabled?: boolean; + snooze_endtime?: number; + snooze_remaining?: number; +} + +export const getDnd = (token: string, signal?: AbortSignal) => slack(token, "dnd.info", {}, signal); + +export async function snooze(token: string, minutes: number): Promise { + const res = await slack<{ snooze_endtime: number }>(token, "dnd.setSnooze", { num_minutes: minutes }); + return res.snooze_endtime; +} + +export const endSnooze = (token: string) => slack(token, "dnd.endSnooze"); + +export function conversations(token: string, signal?: AbortSignal): Promise { + return paged( + token, + "users.conversations", + "channels", + { types: "public_channel,private_channel,mpim,im", exclude_archived: true }, + 1000, + signal, + ); +} + +/** Everyone in the workspace (for names and pictures in direct messages), up to 2000. */ +export async function users(token: string, signal?: AbortSignal): Promise> { + const all = await paged(token, "users.list", "members", {}, 2000, signal); + return new Map(all.map((u) => [u.id, u])); +} + +export async function done(message: string) { + await toast(message, { style: "success" }); +} diff --git a/extensions/slack/backslash.toml b/extensions/slack/backslash.toml new file mode 100644 index 0000000..38a810e --- /dev/null +++ b/extensions/slack/backslash.toml @@ -0,0 +1,61 @@ +[extension] +id = "slack" +title = "Slack" +description = "Set your Slack status from presets, pause notifications, jump to channels and search messages" +version = "1.0.0" +author = "Backslash" +icon = "bs-hash" +license = "MIT" + +# The Web API, and the people's pictures in direct messages: Slack hosts them +# on avatars.slack-edge.com, or Gravatar with a default on a.slack-edge.com. +[permissions] +net = ["slack.com", "avatars.slack-edge.com", "secure.gravatar.com", "a.slack-edge.com"] + +[[commands]] +name = "status" +title = "Set Slack Status" +description = "Presets (meeting, commuting, lunch, focus), custom status with expiry, Do Not Disturb" +icon = "bs-smile" +keywords = ["slack", "status", "meeting", "lunch", "focus", "away", "dnd", "do not disturb", "statut", "réunion", "déjeuner", "concentration", "ne pas déranger"] + +[[commands]] +name = "clear-status" +title = "Clear Slack Status" +description = "Removes your status text and emoji" +icon = "bs-x" +mode = "no-view" +keywords = ["slack", "status", "clear", "reset", "effacer", "statut"] + +[[commands]] +name = "pause-notifications" +title = "Pause Slack Notifications" +description = "Do Not Disturb for 30 minutes, 1 or 2 hours, or until tomorrow morning" +icon = "bs-bell-off" +mode = "no-view" +keywords = ["slack", "dnd", "do not disturb", "snooze", "mute", "notifications", "ne pas déranger", "pause", "silence"] +[[commands.arguments]] +name = "duration" +type = "choice" +options = ["30 minutes", "1 hour", "2 hours", "until tomorrow"] +required = true + +[[commands]] +name = "channels" +title = "Slack Channels" +description = "Channels and direct messages you are in; open them in the Slack app" +icon = "bs-hash" +keywords = ["slack", "channels", "dm", "direct messages", "conversations", "chat", "canaux", "messages privés"] + +[[commands]] +name = "search" +title = "Search Slack Messages" +description = "Messages from every channel you can see; supports in:#channel and from:@someone" +icon = "bs-search" +keywords = ["slack", "search", "messages", "find", "chercher", "rechercher"] + +[[preferences]] +name = "token" +title = "User token" +description = "A user token (xoxp-…) of your own Slack app, with the user scopes users.profile:read, users.profile:write, dnd:read, dnd:write, channels:read, groups:read, im:read, mpim:read, users:read and search:read" +type = "secret" diff --git a/extensions/slack/commands/channels.ts b/extensions/slack/commands/channels.ts new file mode 100644 index 0000000..380023e --- /dev/null +++ b/extensions/slack/commands/channels.ts @@ -0,0 +1,136 @@ +import { type Context, copy, Icons, type Item, list, meta, type Metadata, open } from "@backslash/api"; +import { conversations, slack, tokenOf, users as loadUsers, whoAmI } from "../api.ts"; +import { + appUrl, + avatarOf, + cleanText, + type Conversation, + conversationTitle, + guarded, + isUseful, + kindColor, + kindOf, + personName, + sortConversations, + unreadOf, + type User, + webUrl, +} from "../lib.ts"; + +const SECTIONS = { channel: "Channels", private: "Channels", group: "Group Messages", dm: "Direct Messages" }; + +let counts = { channels: 0, dms: 0, unread: 0 }; + +function conversationItem( + c: Conversation, + users: Map, + team: { id: string; url: string; me: string }, +): Item { + const kind = kindOf(c); + const title = conversationTitle(c, users, team.me); + const person = kind === "dm" ? users.get(c.user ?? "") : undefined; + const picture = avatarOf(person); + const unread = unreadOf(c); + const topic = cleanText(c.topic?.value?.trim() || c.purpose?.value?.trim() || ""); + const web = webUrl(team.url, c.id); + const lines: Metadata[] = [ + meta.text( + "Type", + { + channel: "Public channel", + private: "Private channel", + group: "Group message", + dm: "Direct message", + }[kind], + { icon: kind === "private" ? Icons.lock : kind === "channel" ? Icons.hash : Icons.person }, + ), + ]; + if (person) { + lines.push(meta.person("With", person.profile?.real_name || personName(person), picture)); + if (person.profile?.title) lines.push(meta.text("Title", person.profile.title)); + } + if (c.num_members) lines.push(meta.text("Members", c.num_members, { icon: Icons.person })); + if (unread !== undefined) { + lines.push(meta.text("Unread", unread, { icon: Icons.inbox, color: unread ? "blue" : undefined })); + } + if (c.is_ext_shared) lines.push(meta.text("Shared", "With another organization", { icon: Icons.globe })); + if (c.created) { + lines.push(meta.text("Created", new Date(c.created * 1000).toLocaleDateString("en", { dateStyle: "medium" }))); + } + lines.push(meta.separator(), meta.link("Link", web.replace(/^https:\/\//, ""), web)); + return { + id: c.id, + title, + subtitle: topic ? topic.split("\n")[0] : undefined, + icon: picture ?? { + source: kind === "private" ? Icons.lock : kind === "channel" ? Icons.hash : Icons.person, + tint: kindColor[kind], + }, + section: SECTIONS[kind], + keywords: [c.name ?? "", person?.name ?? "", person?.real_name ?? ""].filter(Boolean), + accessories: [ + ...(c.is_ext_shared ? [{ icon: Icons.globe, tooltip: "Shared with another organization" }] : []), + ...(unread ? [{ tag: String(unread), color: "blue" as const, tooltip: "Unread" }] : []), + ], + detail: { + markdown: [ + c.topic?.value && `**Topic:** ${cleanText(c.topic.value, { links: true })}`, + c.purpose?.value && cleanText(c.purpose.value, { links: true }), + ] + .filter(Boolean).join("\n\n") || undefined, + metadata: lines, + }, + actions: [ + open(appUrl(team.id, c.id), "Open in Slack", { icon: Icons.hash }), + open(web, "Open in Browser", { icon: Icons.globe }), + copy(web, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(kind === "dm" || kind === "group" ? title : `<#${c.id}>`, "Copy Mention", { section: "Copy" }), + copy(c.id, "Copy Channel ID", { shortcut: "period", section: "Copy" }), + ], + }; +} + +export default (ctx: Context) => + list({ + placeholder: "Jump to a channel or a person…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "channels", title: "Channels" }, + { value: "dms", title: "Direct Messages" }, + ], + }, + empty: { title: "No conversation here", icon: Icons.hash }, + status: (items) => + items[0]?.id === "setup" ? "Not connected" : [ + `${counts.channels} channel${counts.channels === 1 ? "" : "s"}`, + `${counts.dms} direct message${counts.dms === 1 ? "" : "s"}`, + counts.unread ? `${counts.unread} with unread` : "", + ].filter(Boolean).join(" · "), + items: ({ signal, filter }) => + guarded(async () => { + const token = tokenOf(ctx); + const [me, convos] = await Promise.all([whoAmI(token, signal), conversations(token, signal)]); + const needsPeople = convos.some((c) => c.is_im); + // Without users:read, direct messages show the person's ID. + const people = needsPeople ? await loadUsers(token, signal).catch(() => new Map()) : new Map(); + // Members missing from users.list (other workspaces of a shared channel). + const missing = convos.filter((c) => c.is_im && c.user && !people.has(c.user)).slice(0, 15); + await Promise.all(missing.map(async (c) => { + const res = await slack<{ user: User }>(token, "users.info", { user: c.user }, signal).catch(() => undefined); + if (res) people.set(res.user.id, res.user); + })); + const shown = sortConversations(convos.filter((c) => isUseful(c, people)), people); + counts = { + channels: shown.filter((c) => !c.is_im && !c.is_mpim).length, + dms: shown.filter((c) => c.is_im || c.is_mpim).length, + unread: shown.filter((c) => (unreadOf(c) ?? 0) > 0).length, + }; + const team = { id: me.team_id, url: me.url, me: me.user }; + return shown + .filter((c) => + filter === "channels" ? !c.is_im && !c.is_mpim : filter === "dms" ? c.is_im || c.is_mpim : true + ) + .map((c) => conversationItem(c, people, team)); + }), + }); diff --git a/extensions/slack/commands/clear-status.ts b/extensions/slack/commands/clear-status.ts new file mode 100644 index 0000000..df69125 --- /dev/null +++ b/extensions/slack/commands/clear-status.ts @@ -0,0 +1,18 @@ +import { run, updateCommandMetadata } from "@backslash/api"; +import { clearStatus, getStatus, tokenOf } from "../api.ts"; +import { AuthError } from "../lib.ts"; + +export default run(async (ctx) => { + let token: string; + try { + token = tokenOf(ctx); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Set Slack Status to see how`); + throw e; + } + const before = await getStatus(token); + if (!before.text && !before.emoji) return "No status to clear"; + await clearStatus(token); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return "Slack status cleared"; +}); diff --git a/extensions/slack/commands/pause-notifications.ts b/extensions/slack/commands/pause-notifications.ts new file mode 100644 index 0000000..e40ce80 --- /dev/null +++ b/extensions/slack/commands/pause-notifications.ts @@ -0,0 +1,15 @@ +import { run } from "@backslash/api"; +import { snooze, tokenOf } from "../api.ts"; +import { AuthError, clock, snoozeMinutes } from "../lib.ts"; + +export default run<{ duration: string }>(async (ctx) => { + let token: string; + try { + token = tokenOf(ctx); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Set Slack Status to see how`); + throw e; + } + const end = await snooze(token, snoozeMinutes(String(ctx.args.duration ?? "1 hour"))); + return `Slack notifications paused until ${clock(end)}`; +}); diff --git a/extensions/slack/commands/search.ts b/extensions/slack/commands/search.ts new file mode 100644 index 0000000..54549d6 --- /dev/null +++ b/extensions/slack/commands/search.ts @@ -0,0 +1,92 @@ +import { type Context, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { slack, tokenOf } from "../api.ts"; +import { appUrl, cleanText, firstLine, guarded, shortDate, tsDate } from "../lib.ts"; + +interface Match { + iid?: string; + ts: string; + text: string; + user?: string; + username?: string; + team?: string; + permalink: string; + channel: { id: string; name: string; is_im?: boolean; is_mpim?: boolean; is_private?: boolean }; +} + +let total = 0; + +function channelLabel(m: Match): string { + if (m.channel.is_im) return "Direct message"; + if (m.channel.is_mpim) return "Group message"; + return `#${m.channel.name}`; +} + +function matchItem(m: Match): Item { + const text = cleanText(m.text); + const when = tsDate(m.ts); + const where = channelLabel(m); + const who = m.username || m.user || "Someone"; + return { + id: m.iid ?? `${m.channel.id}:${m.ts}`, + title: firstLine(text) || "(attachment)", + subtitle: where, + icon: { + source: m.channel.is_im || m.channel.is_mpim ? Icons.person : m.channel.is_private ? Icons.lock : Icons.comment, + tint: "blue", + }, + accessories: [ + { avatar: who, tooltip: who }, + { text: shortDate(when), tooltip: when.toLocaleString("en-GB") }, + ], + detail: { + markdown: cleanText(m.text, { links: true }).replace(/\n/g, " \n") || "_No text: the message has attachments._", + metadata: [ + meta.person("From", who), + meta.text("In", where, { icon: m.channel.is_private ? Icons.lock : Icons.hash }), + meta.text("Sent", when.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + meta.separator(), + meta.link("Link", "Open message", m.permalink), + ], + }, + actions: [ + open(m.permalink, "Open Message"), + ...(m.team ? [open(appUrl(m.team, m.channel.id), "Open Channel in Slack", { icon: Icons.hash })] : []), + copy(m.permalink, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(text, "Copy Text", { shortcut: "period", section: "Copy" }), + ], + }; +} + +export default (ctx: Context) => + list({ + placeholder: "Search messages: words, in:#channel, from:@someone, before:2026-01-01…", + filter: { + options: [ + { value: "score", title: "Best Match" }, + { value: "timestamp", title: "Newest" }, + ], + }, + empty: { + title: "Search Slack", + description: "Type a few words. Slack's modifiers work too: in:#design from:@ada has:link", + icon: Icons.search, + }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : total ? `${items.length} of ${total} messages` : "", + search: (query, { signal, filter }) => + guarded(async () => { + const token = tokenOf(ctx); + total = 0; + if (!query.trim()) return []; + const res = await slack<{ messages: { matches: Match[]; total: number } }>(token, "search.messages", { + query, + count: 40, + sort: filter === "timestamp" ? "timestamp" : "score", + sort_dir: "desc", + highlight: false, + }, signal); + total = res.messages.total; + return res.messages.matches.map(matchItem); + }), + }); diff --git a/extensions/slack/commands/status.ts b/extensions/slack/commands/status.ts new file mode 100644 index 0000000..038f9b6 --- /dev/null +++ b/extensions/slack/commands/status.ts @@ -0,0 +1,255 @@ +import { + type Action, + action, + type Context, + field, + form, + Icons, + type Item, + list, + meta, + pop, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { clearStatus, done, endSnooze, getDnd, getStatus, setStatus, setStatusFor, snooze, tokenOf } from "../api.ts"; +import { + clock, + type Duration, + DURATIONS, + durationTitle, + emojiCode, + emojiOf, + expiryFor, + guarded, + type Preset, + PRESETS, + snoozeMinutes, + type Status, + statusSubtitle, + untilText, +} from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; + +const SNOOZES = ["30 minutes", "1 hour", "2 hours", "until tomorrow"]; + +function customForm(token: string, initial: Partial & { duration?: Duration } = {}) { + return form({ + title: "Custom Slack status", + submitTitle: "Set Status", + fields: [ + field.text("text", "Status", { placeholder: "What's happening?", value: initial.text ?? "" }), + field.text("emoji", "Emoji", { + placeholder: ":speech_balloon: or a code like palm_tree", + value: initial.emoji ?? ":speech_balloon:", + }), + field.dropdown("duration", "Clear after", DURATIONS, { value: initial.duration ?? "today" }), + field.checkbox("dnd", "Pause notifications as long (up to 1 day)", { value: false }), + ], + async submit(values) { + const text = String(values.text ?? "").trim(); + const emoji = emojiCode(String(values.emoji ?? "")) || (text ? ":speech_balloon:" : ""); + if (!text && !emoji) throw new Error("Type a status or an emoji"); + if (text.length > 100) throw new Error("A status has 100 characters at most"); + const duration = String(values.duration) as Duration; + const status = await setStatusFor(token, { text, emoji }, duration); + if (values.dnd) await pauseAlong(token, status.expiration); + notifyChanged(); + await done(`Status set ${untilText(status.expiration)}`); + return pop(); + }, + }); +} + +/** Do Not Disturb until the status clears, at most a day (Slack's limit for a snooze). */ +async function pauseAlong(token: string, expiration: number) { + const minutes = expiration > 0 ? Math.round((expiration * 1000 - Date.now()) / 60_000) : 24 * 60; + await snooze(token, Math.max(1, Math.min(minutes, 24 * 60))); +} + +async function applyPreset(token: string, preset: Preset, duration: Duration) { + const status = await setStatusFor(token, { text: preset.text, emoji: preset.emoji }, duration); + if (preset.dnd) await pauseAlong(token, status.expiration); + notifyChanged(); + await done(`${preset.title} ${untilText(status.expiration)}${preset.dnd ? " · notifications paused" : ""}`); +} + +function presetItem(token: string, preset: Preset, current: Status): Item { + const active = current.text === preset.text && current.emoji === preset.emoji; + const forDuration = (d: Duration) => + action(d === "never" ? "Don't Clear" : `For ${durationTitle(d)}`, () => applyPreset(token, preset, d), { + icon: Icons.clock, + }); + const expires = expiryFor(preset.duration); + return { + id: `preset:${preset.id}`, + title: preset.title, + subtitle: `${emojiOf(preset.emoji)} ${preset.text}`, + icon: preset.icon, + section: "Presets", + keywords: [preset.text, preset.emoji.replaceAll(":", "")], + accessories: [ + ...(active ? [{ tag: "Current", color: "green" as const }] : []), + ...(preset.dnd ? [{ icon: Icons.bellOff, tooltip: "Also pauses notifications" }] : []), + { text: durationTitle(preset.duration), tooltip: "Clears after" }, + ], + detail: { + markdown: `## ${emojiOf(preset.emoji)} ${preset.text}`, + metadata: [ + meta.text("Emoji", preset.emoji), + meta.text("Clears", expires ? untilText(expires) : "Never", { icon: Icons.clock }), + meta.text("Notifications", preset.dnd ? "Paused as long" : "Unchanged", { + icon: preset.dnd ? Icons.bellOff : Icons.bell, + }), + ], + }, + actions: [ + action(`Set “${preset.title}”`, () => applyPreset(token, preset, preset.duration), { icon: Icons.check }), + submenu("Set For…", DURATIONS.map((d) => forDuration(d.value)), { + icon: Icons.clock, + shortcut: "d", + }), + action( + "Customize…", + () => customForm(token, { text: preset.text, emoji: preset.emoji, duration: preset.duration }), + { + icon: Icons.settings, + shortcut: "e", + }, + ), + ], + }; +} + +function currentItem(token: string, status: Status): Item { + if (!status.text && !status.emoji) { + return { + id: "current", + title: "No status", + subtitle: "Pick a preset or write your own", + icon: { source: Icons.smile, tint: "gray" }, + section: "Current", + actions: [action("Custom Status…", () => customForm(token), { icon: Icons.plus })], + }; + } + return { + id: "current", + title: status.text || emojiOf(status.emoji), + subtitle: untilText(status.expiration), + // The user's own status emoji. + icon: emojiOf(status.emoji).startsWith(":") ? { source: Icons.smile, tint: "green" } : emojiOf(status.emoji), + section: "Current", + accessories: [{ tag: "Current", color: "green" }], + detail: { + markdown: `## ${emojiOf(status.emoji)} ${status.text}`, + metadata: [ + meta.text("Emoji", status.emoji || "None"), + meta.text("Clears", status.expiration ? untilText(status.expiration) : "Never", { icon: Icons.clock }), + ], + }, + actions: [ + action("Clear Status", async () => { + await clearStatus(token); + notifyChanged(); + await done("Status cleared"); + }, { icon: Icons.cross }), + action("Edit Status…", () => customForm(token, status), { icon: Icons.settings, shortcut: "e" }), + ], + }; +} + +function dndItems(token: string, snoozedUntil: number | undefined): Item[] { + const items: Item[] = []; + if (snoozedUntil) { + items.push({ + id: "dnd:active", + title: "Notifications paused", + subtitle: `until ${clock(snoozedUntil)}`, + icon: { source: Icons.bellOff, tint: "purple" }, + section: "Do Not Disturb", + accessories: [{ tag: "On", color: "purple" }], + actions: [ + action("Resume Notifications", async () => { + await endSnooze(token); + notifyChanged(); + await done("Notifications resumed"); + }, { icon: Icons.bell }), + ], + }); + } + for (const choice of SNOOZES) { + items.push({ + id: `dnd:${choice}`, + title: `Pause notifications ${/^until/.test(choice) ? choice : `for ${choice}`}`, + subtitle: /tomorrow/.test(choice) ? "Until 9:00 tomorrow" : undefined, + icon: { source: Icons.bellOff, tint: "gray" }, + section: "Do Not Disturb", + keywords: ["dnd", "do not disturb", "snooze", "mute"], + actions: [ + action("Pause Notifications", async () => { + const end = await snooze(token, snoozeMinutes(choice)); + notifyChanged(); + await done(`Notifications paused until ${clock(end)}`); + }, { icon: Icons.bellOff }), + ], + }); + } + return items; +} + +export default (ctx: Context) => { + const typed = ctx.query.trim(); + return list({ + placeholder: "Pick a status…", + status: (items) => items[0]?.id === "setup" ? "Not connected" : "Enter sets the status · Ctrl+D picks how long", + items: ({ signal }) => + live(() => + guarded(async () => { + const token = tokenOf(ctx); + const [status, dnd] = await Promise.all([ + getStatus(token, signal), + getDnd(token, signal).catch(() => undefined), + ]); + const snoozedUntil = dnd?.snooze_enabled && dnd.snooze_endtime ? dnd.snooze_endtime : undefined; + updateCommandMetadata({ + subtitle: statusSubtitle(status) ?? + (snoozedUntil ? `Notifications paused until ${clock(snoozedUntil)}` : null), + }).catch(() => {}); + const custom: Action = action("Custom Status…", () => customForm(token, typed ? { text: typed } : {}), { + icon: Icons.plus, + shortcut: "n", + }); + const head: Item[] = typed + ? [{ + id: "typed", + title: `Set status “${typed}”`, + subtitle: "Clears at the end of the day", + icon: { source: Icons.comment, tint: "blue" }, + section: "Current", + actions: [ + action("Set Status", async () => { + await setStatus(token, { text: typed, emoji: ":speech_balloon:", expiration: expiryFor("today") }); + notifyChanged(); + await done("Status set until tonight"); + }, { icon: Icons.check }), + action("Customize…", () => customForm(token, { text: typed }), { icon: Icons.settings }), + ], + }] + : []; + return [ + ...head, + currentItem(token, status), + ...PRESETS.map((p) => presetItem(token, p, status)), + { + id: "custom", + title: "Custom Status…", + subtitle: "Your text, emoji and when it clears", + icon: { source: Icons.plus, tint: "blue" }, + section: "Presets", + actions: [custom], + }, + ...dndItems(token, snoozedUntil), + ]; + }), signal), + }); +}; diff --git a/extensions/slack/lib.ts b/extensions/slack/lib.ts new file mode 100644 index 0000000..b097dec --- /dev/null +++ b/extensions/slack/lib.ts @@ -0,0 +1,490 @@ +// Slack: pure logic (status presets and expiry, mrkdwn cleanup, response mapping). + +import { type Color, Icons, type Item, open } from "@backslash/api"; + +export const APPS_URL = "https://api.slack.com/apps"; + +export const SCOPES = [ + "users.profile:read", + "users.profile:write", + "dnd:read", + "dnd:write", + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "users:read", + "search:read", +]; + +/** The token is missing or Slack refused it: the list shows how to connect. */ +export class AuthError extends Error {} + +export function userToken(prefs: Record): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your Slack account"); + if (/^xox[baer]-/.test(token)) { + throw new AuthError("This is not a user token: paste the “User OAuth Token” (xoxp-…)"); + } + if (!token.startsWith("xoxp-")) throw new AuthError("A Slack user token starts with xoxp-"); + return token; +} + +/** The message of a `{ ok: false }` answer. */ +export function slackError(body: { error?: string; needed?: string }): Error { + const code = body.error ?? "unknown_error"; + switch (code) { + case "not_authed": + case "invalid_auth": + case "token_revoked": + case "token_expired": + case "account_inactive": + return new AuthError("Slack rejected the token"); + case "missing_scope": + return new Error( + `The token lacks the ${ + body.needed ?? "needed" + } scope: add it to your Slack app's User Token Scopes and reinstall it`, + ); + case "ratelimited": + return new Error("Slack is rate limiting: try again in a minute"); + case "profile_status_set_failed_not_valid_emoji": + return new Error("Slack doesn't know this emoji: use a code like :palm_tree:"); + case "too_long": + return new Error("The status is too long (100 characters at most)"); + default: + return new Error(`Slack: ${code.replaceAll("_", " ")}`); + } +} + +/** Form-encoded parameters; objects (a profile) go as JSON, as Slack expects. */ +export function encodeParams(params: Record): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + body.set(key, typeof value === "object" ? JSON.stringify(value) : String(value)); + } + return body; +} + +// --------------------------------------------------------------------------- +// Emoji + +/** Common shortcodes; anything else (custom emoji) stays as :code:. */ +const EMOJI: Record = { + spiral_calendar_pad: "🗓️", + calendar: "📆", + date: "📅", + bus: "🚌", + train: "🚆", + train2: "🚆", + car: "🚗", + red_car: "🚗", + bike: "🚲", + airplane: "✈️", + knife_fork_plate: "🍽️", + fork_and_knife: "🍴", + sandwich: "🥪", + hamburger: "🍔", + pizza: "🍕", + coffee: "☕", + headphones: "🎧", + dart: "🎯", + brain: "🧠", + palm_tree: "🌴", + face_with_thermometer: "🤒", + house: "🏠", + house_with_garden: "🏡", + computer: "💻", + speech_balloon: "💬", + no_bell: "🔕", + zzz: "💤", + red_circle: "🔴", + large_green_circle: "🟢", + white_check_mark: "✅", + eyes: "👀", + wave: "👋", + rocket: "🚀", + books: "📚", + phone: "☎️", + telephone_receiver: "📞", + movie_camera: "🎥", + video_camera: "📹", + hospital: "🏥", + baby: "👶", + sunny: "☀️", + christmas_tree: "🎄", + tada: "🎉", + thinking_face: "🤔", + writing_hand: "✍️", + hammer_and_wrench: "🛠️", +}; + +/** `:palm_tree:` → 🌴 (unknown codes are returned as typed). */ +export function emojiOf(code: string | undefined): string { + const name = (code ?? "").trim().replace(/^:|:$/g, ""); + if (!name) return ""; + return EMOJI[name] ?? `:${name}:`; +} + +/** `palm_tree`, `:palm_tree:` or `🌴` → `:palm_tree:` (or the emoji itself when unknown). */ +export function emojiCode(input: string): string { + const text = input.trim(); + if (!text) return ""; + const known = Object.entries(EMOJI).find(([, glyph]) => glyph === text || glyph.replace("️", "") === text); + if (known) return `:${known[0]}:`; + if (/^:?[a-z0-9_+'-]+:?$/i.test(text)) return `:${text.replace(/^:|:$/g, "").toLowerCase()}:`; + return text; +} + +// --------------------------------------------------------------------------- +// Status + +export interface Status { + text: string; + emoji: string; + /** Unix seconds; 0 = doesn't clear. */ + expiration: number; +} + +export interface Preset { + id: string; + title: string; + text: string; + emoji: string; + duration: Duration; + icon: { source: string; tint: Color }; + /** Also pause notifications for the same time. */ + dnd?: boolean; +} + +export type Duration = "30m" | "1h" | "2h" | "4h" | "today" | "week" | "never"; + +export const DURATIONS: { value: Duration; title: string }[] = [ + { value: "30m", title: "30 minutes" }, + { value: "1h", title: "1 hour" }, + { value: "2h", title: "2 hours" }, + { value: "4h", title: "4 hours" }, + { value: "today", title: "Today" }, + { value: "week", title: "This week" }, + { value: "never", title: "Don't clear" }, +]; + +export const PRESETS: Preset[] = [ + { + id: "meeting", + title: "In a meeting", + text: "In a meeting", + emoji: ":spiral_calendar_pad:", + duration: "1h", + icon: { source: Icons.calendar, tint: "red" }, + }, + { + id: "commuting", + title: "Commuting", + text: "Commuting", + emoji: ":bus:", + duration: "30m", + icon: { source: Icons.arrowRight, tint: "blue" }, + }, + { + id: "lunch", + title: "Lunch", + text: "Out for lunch", + emoji: ":knife_fork_plate:", + duration: "1h", + icon: { source: Icons.coffee, tint: "orange" }, + }, + { + id: "focus", + title: "Focus", + text: "Focusing", + emoji: ":headphones:", + duration: "2h", + dnd: true, + icon: { source: Icons.headphones, tint: "purple" }, + }, + { + id: "remote", + title: "Working remotely", + text: "Working remotely", + emoji: ":house_with_garden:", + duration: "today", + icon: { source: Icons.home, tint: "green" }, + }, + { + id: "sick", + title: "Out sick", + text: "Out sick", + emoji: ":face_with_thermometer:", + duration: "today", + icon: { source: Icons.thermometer, tint: "pink" }, + }, + { + id: "vacation", + title: "Vacationing", + text: "Vacationing", + emoji: ":palm_tree:", + duration: "never", + icon: { source: Icons.sun, tint: "yellow" }, + }, +]; + +/** Unix seconds when a status of this duration clears (0: never). */ +export function expiryFor(duration: Duration, now = new Date()): number { + const minutes: Record = { "30m": 30, "1h": 60, "2h": 120, "4h": 240 }; + if (duration in minutes) return Math.floor(now.getTime() / 1000) + minutes[duration] * 60; + if (duration === "never") return 0; + const end = new Date(now); + end.setHours(23, 59, 59, 0); + if (duration === "week") { + // Until Sunday night (today when it's Sunday). + end.setDate(end.getDate() + ((7 - end.getDay()) % 7)); + } + return Math.floor(end.getTime() / 1000); +} + +export function durationTitle(duration: Duration): string { + return DURATIONS.find((d) => d.value === duration)?.title ?? duration; +} + +/** Minutes of Do Not Disturb for a choice; "until tomorrow" ends at 9:00 tomorrow. */ +export function snoozeMinutes(choice: string, now = new Date()): number { + const c = choice.trim().toLowerCase(); + if (/^30/.test(c)) return 30; + if (/^1\s*h|^1 hour|^60/.test(c)) return 60; + if (/^2\s*h|^2 hours|^120/.test(c)) return 120; + if (/tomorrow/.test(c)) { + const morning = new Date(now); + morning.setDate(morning.getDate() + 1); + morning.setHours(9, 0, 0, 0); + return Math.max(1, Math.round((morning.getTime() - now.getTime()) / 60_000)); + } + const n = Number(c.replace(/\s*(min|minutes?)$/, "")); + if (Number.isFinite(n) && n > 0) return Math.round(n); + throw new Error(`Unknown duration “${choice}”: 30 minutes, 1 hour, 2 hours or until tomorrow`); +} + +/** "15:30", "tomorrow 09:00" or "Mon 18:00". */ +export function clock(unix: number, now = new Date()): string { + const d = new Date(unix * 1000); + const time = d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + const day = (x: Date) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`; + if (day(d) === day(now)) return time; + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + if (day(d) === day(tomorrow)) return `tomorrow ${time}`; + const days = (d.getTime() - now.getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${d.toLocaleDateString("en", { weekday: "short" })} ${time}`; + return `${d.toLocaleDateString("en", { day: "numeric", month: "short" })} ${time}`; +} + +/** "until 15:30", "doesn't clear". */ +export function untilText(expiration: number, now = new Date()): string { + return expiration > 0 ? `until ${clock(expiration, now)}` : "doesn't clear"; +} + +/** The root search subtitle: "🗓️ In a meeting · until 15:30". */ +export function statusSubtitle(status: Status | undefined, now = new Date()): string | null { + if (!status || (!status.text && !status.emoji)) return null; + const head = [emojiOf(status.emoji), status.text].filter(Boolean).join(" "); + return status.expiration > 0 ? `${head} · ${untilText(status.expiration, now)}` : head; +} + +// --------------------------------------------------------------------------- +// Messages + +const ENTITIES: Record = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; + +/** + * Slack's mrkdwn to readable text: `<@U1|ada>` → @ada, `<#C1|general>` → + * #general, `` → label (a markdown link with `links`). + */ +export function cleanText(text: string, options: { links?: boolean; users?: Map } = {}): string { + return text + .replace(/<([^>]+)>/g, (_, inner: string) => { + const [target, label] = inner.split("|", 2); + if (target.startsWith("@")) { + const id = target.slice(1); + return `@${label ?? options.users?.get(id) ?? id}`; + } + if (target.startsWith("#")) return `#${label ?? target.slice(1)}`; + if (target.startsWith("!subteam^")) return `@${label?.replace(/^@/, "") ?? "group"}`; + if (target.startsWith("!")) return `@${(label ?? target.slice(1)).replace(/^@/, "").split("^")[0]}`; + if (target.startsWith("mailto:")) return label ?? target.slice(7); + if (options.links) return `[${label ?? target}](${target})`; + return label ?? target; + }) + .replace(/&(amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m); +} + +/** The first non-empty line, shortened. */ +export function firstLine(text: string, max = 110): string { + const line = text.split("\n").map((l) => l.trim()).find(Boolean) ?? ""; + return line.length > max ? `${line.slice(0, max).trimEnd()}…` : line; +} + +/** Slack timestamps ("1730123456.000200") as dates. */ +export const tsDate = (ts: string) => new Date(Number(ts.split(".")[0]) * 1000); + +/** "14:05", "yesterday", "3d", "Oct 25", "Oct 2024". */ +export function shortDate(date: Date, now = new Date()): string { + const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const days = Math.round((day(now) - day(date)) / 86_400_000); + if (days <= 0) return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString("en", { day: "numeric", month: "short" }); + } + return date.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +// --------------------------------------------------------------------------- +// Conversations + +export interface Conversation { + id: string; + name?: string; + is_channel?: boolean; + is_group?: boolean; + is_im?: boolean; + is_mpim?: boolean; + is_private?: boolean; + is_archived?: boolean; + is_general?: boolean; + is_ext_shared?: boolean; + is_shared?: boolean; + user?: string; + created?: number; + num_members?: number; + topic?: { value: string }; + purpose?: { value: string }; + unread_count?: number; + unread_count_display?: number; +} + +export interface User { + id: string; + name: string; + real_name?: string; + deleted?: boolean; + is_bot?: boolean; + profile?: { display_name?: string; real_name?: string; image_48?: string; image_72?: string; title?: string }; +} + +export type Kind = "channel" | "private" | "dm" | "group"; + +export function kindOf(c: Conversation): Kind { + if (c.is_im) return "dm"; + if (c.is_mpim) return "group"; + if (c.is_private || c.is_group) return "private"; + return "channel"; +} + +/** Who a person is to show: the display name, else the full name. */ +export function personName(user: User | undefined, fallback = "Someone"): string { + if (!user) return fallback; + return user.profile?.display_name || user.profile?.real_name || user.real_name || user.name || fallback; +} + +/** `mpdm-ada--grace--linus-1` → `ada, grace, linus` (without `me`). */ +export function groupName(name: string | undefined, me?: string): string { + const m = /^mpdm-(.+)-\d+$/.exec(name ?? ""); + if (!m) return name ?? "Group"; + const people = m[1].split("--"); + const others = people.filter((p) => p !== me); + return (others.length ? others : people).join(", "); +} + +export function conversationTitle(c: Conversation, users: Map, me?: string): string { + switch (kindOf(c)) { + case "dm": + return personName(users.get(c.user ?? ""), c.user ?? "Direct message"); + case "group": + return groupName(c.name, me); + default: + return `#${c.name ?? c.id}`; + } +} + +/** Pictures only from the hosts in `net`. */ +export function avatarOf(user: User | undefined): string | undefined { + const url = user?.profile?.image_72 ?? user?.profile?.image_48; + if (!url) return undefined; + try { + const host = new URL(url).host; + return ["avatars.slack-edge.com", "secure.gravatar.com", "a.slack-edge.com"].includes(host) ? url : undefined; + } catch { + return undefined; + } +} + +export const appUrl = (team: string, channel: string) => + `slack://channel?team=${encodeURIComponent(team)}&id=${encodeURIComponent(channel)}`; + +/** `https://acme.slack.com/` + `C1` → `https://acme.slack.com/archives/C1`. */ +export function webUrl(teamUrl: string, channel: string): string { + const base = teamUrl.endsWith("/") ? teamUrl : `${teamUrl}/`; + return `${base}archives/${channel}`; +} + +const KIND_ORDER: Kind[] = ["channel", "private", "group", "dm"]; + +/** Channels first, by name; then group and direct messages. */ +export function sortConversations(list: Conversation[], users: Map): Conversation[] { + return [...list].sort((a, b) => + KIND_ORDER.indexOf(kindOf(a)) - KIND_ORDER.indexOf(kindOf(b)) || + conversationTitle(a, users).localeCompare(conversationTitle(b, users)) + ); +} + +/** Direct messages with deactivated people or bots are noise in a launcher. */ +export function isUseful(c: Conversation, users: Map): boolean { + if (c.is_archived) return false; + if (kindOf(c) !== "dm") return true; + const u = users.get(c.user ?? ""); + return !u?.deleted && (c.user !== "USLACKBOT"); +} + +export function unreadOf(c: Conversation): number | undefined { + const n = c.unread_count_display ?? c.unread_count; + return typeof n === "number" ? n : undefined; +} + +export const kindColor: Record = { channel: "gray", private: "orange", group: "purple", dm: "blue" }; + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A user token of your own Slack app", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Slack", + "", + "Backslash talks to Slack with a **user token** of an app you create in your workspace:", + "", + "1. Open **api.slack.com/apps** (Enter) → **Create New App** → **From scratch**, and pick your workspace.", + "2. In **OAuth & Permissions → User Token Scopes**, add:", + ...SCOPES.map((s) => ` - \`${s}\``), + "3. **Install to Workspace** (some workspaces need an admin's approval), then copy the", + " **User OAuth Token**: it starts with `xoxp-`.", + "4. Paste it in **Settings → Extensions → Slack → Configure → User token**.", + "", + "The token stays in your system keyring.", + ].join("\n"), + }, + actions: [open(APPS_URL, "Create a Slack App", { icon: Icons.key })], + }; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } +} diff --git a/extensions/slack/lib_test.ts b/extensions/slack/lib_test.ts new file mode 100644 index 0000000..0937dca --- /dev/null +++ b/extensions/slack/lib_test.ts @@ -0,0 +1,166 @@ +import { assert, assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + AuthError, + cleanText, + clock, + type Conversation, + conversationTitle, + emojiCode, + emojiOf, + encodeParams, + expiryFor, + firstLine, + groupName, + isUseful, + kindOf, + setupItem, + shortDate, + slackError, + snoozeMinutes, + sortConversations, + statusSubtitle, + tsDate, + type User, + userToken, + webUrl, +} from "./lib.ts"; + +// A Tuesday afternoon, local time. +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const unix = (d: Date) => Math.floor(d.getTime() / 1000); + +Deno.test("user token: missing, bot token, user token", () => { + assertThrows(() => userToken({}), AuthError, "Connect"); + assertThrows(() => userToken({ token: "xoxb-1-2-3" }), AuthError, "User OAuth Token"); + assertThrows(() => userToken({ token: "hello" }), AuthError, "xoxp-"); + assertEquals(userToken({ token: " xoxp-123-456-abc \n" }), "xoxp-123-456-abc"); +}); + +Deno.test("Slack errors", () => { + assertInstanceOf(slackError({ error: "invalid_auth" }), AuthError); + assertInstanceOf(slackError({ error: "token_revoked" }), AuthError); + const scope = slackError({ error: "missing_scope", needed: "dnd:write" }); + assert(!(scope instanceof AuthError)); + assertStringIncludes(scope.message, "dnd:write"); + assertEquals(slackError({ error: "channel_not_found" }).message, "Slack: channel not found"); +}); + +Deno.test("parameters are form-encoded, the profile as JSON", () => { + const body = encodeParams({ + profile: { status_text: "Lunch & co", status_emoji: ":sandwich:", status_expiration: 0 }, + cursor: undefined, + limit: 200, + }); + assertEquals(body.get("limit"), "200"); + assertEquals(body.has("cursor"), false); + assertEquals(JSON.parse(body.get("profile")!).status_text, "Lunch & co"); +}); + +Deno.test("emoji shortcodes", () => { + assertEquals(emojiOf(":palm_tree:"), "🌴"); + assertEquals(emojiOf("spiral_calendar_pad"), "🗓️"); + assertEquals(emojiOf(":partyparrot:"), ":partyparrot:"); + assertEquals(emojiOf(""), ""); + assertEquals(emojiCode("palm_tree"), ":palm_tree:"); + assertEquals(emojiCode(":Headphones:"), ":headphones:"); + assertEquals(emojiCode("🌴"), ":palm_tree:"); + assertEquals(emojiCode("🗓"), ":spiral_calendar_pad:"); + assertEquals(emojiCode(""), ""); +}); + +Deno.test("status expiry", () => { + assertEquals(expiryFor("30m", NOW), unix(NOW) + 1800); + assertEquals(expiryFor("2h", NOW), unix(NOW) + 7200); + assertEquals(expiryFor("never", NOW), 0); + assertEquals(expiryFor("today", NOW), unix(new Date(2026, 9, 27, 23, 59, 59))); + // Tuesday → Sunday night. + assertEquals(expiryFor("week", NOW), unix(new Date(2026, 10, 1, 23, 59, 59))); + const sunday = new Date(2026, 10, 1, 10, 0, 0); + assertEquals(expiryFor("week", sunday), unix(new Date(2026, 10, 1, 23, 59, 59))); +}); + +Deno.test("snooze durations", () => { + assertEquals(snoozeMinutes("30 minutes", NOW), 30); + assertEquals(snoozeMinutes("1 hour", NOW), 60); + assertEquals(snoozeMinutes("2 hours", NOW), 120); + // 14:10 → 9:00 tomorrow. + assertEquals(snoozeMinutes("until tomorrow", NOW), 18 * 60 + 50); + assertEquals(snoozeMinutes("45", NOW), 45); + assertThrows(() => snoozeMinutes("forever", NOW), Error, "Unknown duration"); +}); + +Deno.test("clock and subtitle", () => { + assertEquals(clock(unix(new Date(2026, 9, 27, 15, 30)), NOW), "15:30"); + assertEquals(clock(unix(new Date(2026, 9, 28, 9, 0)), NOW), "tomorrow 09:00"); + assertEquals(clock(unix(new Date(2026, 9, 30, 18, 0)), NOW), "Fri 18:00"); + assertEquals( + statusSubtitle({ + text: "In a meeting", + emoji: ":spiral_calendar_pad:", + expiration: unix(new Date(2026, 9, 27, 15, 0)), + }, NOW), + "🗓️ In a meeting · until 15:00", + ); + assertEquals(statusSubtitle({ text: "Vacationing", emoji: ":palm_tree:", expiration: 0 }, NOW), "🌴 Vacationing"); + assertEquals(statusSubtitle({ text: "", emoji: "", expiration: 0 }, NOW), null); +}); + +Deno.test("mrkdwn cleanup", () => { + const raw = "Hey <@U024BE7LH|ada>, see <#C1234|design> and & "; + assertEquals(cleanText(raw), "Hey @ada, see #design and the spec & @here"); + assertEquals( + cleanText("", { links: true }), + "[the spec](https://example.com/spec)", + ); + assertEquals(cleanText("<@U1>", { users: new Map([["U1", "grace"]]) }), "@grace"); + assertEquals(cleanText(" 1 < 2"), "ada@example.com 1 < 2"); + assertEquals(cleanText(" ping"), "@oncall ping"); + assertEquals(firstLine("\n\n First line \nsecond"), "First line"); + assertEquals(firstLine("x".repeat(200), 10), "xxxxxxxxxx…"); +}); + +Deno.test("dates of messages", () => { + assertEquals(tsDate("1730123456.000200").getTime(), 1730123456000); + assertEquals(shortDate(new Date(2026, 9, 27, 9, 5), NOW), "09:05"); + assertEquals(shortDate(new Date(2026, 9, 26, 23, 0), NOW), "yesterday"); + assertEquals(shortDate(new Date(2026, 9, 24, 12, 0), NOW), "3d"); + assertStringIncludes(shortDate(new Date(2026, 8, 2), NOW), "Sep 2"); + assertStringIncludes(shortDate(new Date(2024, 2, 2), NOW), "2024"); +}); + +const users = new Map([ + ["U1", { id: "U1", name: "ada", profile: { display_name: "", real_name: "Ada Lovelace" } }], + ["U2", { id: "U2", name: "old", deleted: true, profile: { real_name: "Old Account" } }], +]); + +const convos: Conversation[] = [ + { id: "D1", is_im: true, user: "U1" }, + { id: "C2", name: "random", is_channel: true }, + { id: "G1", name: "mpdm-ada--grace--linus-1", is_mpim: true, is_private: true }, + { id: "C1", name: "general", is_channel: true, is_general: true }, + { id: "C3", name: "secret-project", is_channel: true, is_private: true }, + { id: "D2", is_im: true, user: "U2" }, + { id: "D3", is_im: true, user: "USLACKBOT" }, +]; + +Deno.test("conversations: kinds, titles, order", () => { + assertEquals(kindOf(convos[0]), "dm"); + assertEquals(kindOf(convos[2]), "group"); + assertEquals(kindOf(convos[4]), "private"); + assertEquals(conversationTitle(convos[0], users), "Ada Lovelace"); + assertEquals(conversationTitle(convos[1], users), "#random"); + assertEquals(groupName("mpdm-ada--grace--linus-1"), "ada, grace, linus"); + assertEquals(groupName("mpdm-steven--ada-1", "steven"), "ada"); + const useful = convos.filter((c) => isUseful(c, users)); + assertEquals(useful.map((c) => c.id), ["D1", "C2", "G1", "C1", "C3"]); + assertEquals(sortConversations(useful, users).map((c) => c.id), ["C1", "C2", "C3", "G1", "D1"]); + assertEquals(webUrl("https://acme.slack.com/", "C1"), "https://acme.slack.com/archives/C1"); + assertEquals(webUrl("https://acme.slack.com", "C1"), "https://acme.slack.com/archives/C1"); +}); + +Deno.test("setup row explains the scopes and links to the apps page", () => { + const item = setupItem(new AuthError("Connect your Slack account")); + assertEquals(item.id, "setup"); + assertStringIncludes(item.detail!.markdown!, "search:read"); + assertStringIncludes(item.detail!.markdown!, "xoxp-"); +}); diff --git a/extensions/slack/live.ts b/extensions/slack/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/slack/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +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); + }); + } +} diff --git a/extensions/ticktick/api.ts b/extensions/ticktick/api.ts new file mode 100644 index 0000000..73bc5b5 --- /dev/null +++ b/extensions/ticktick/api.ts @@ -0,0 +1,129 @@ +// Calls to the TickTick Open API, signed in with the user's own TickTick app. + +import { action, Icons, type Item, oauth, open, refresh } from "@backslash/api"; +import { + API, + AuthError, + DEFAULT_PORT, + DEVELOPER_URL, + INBOX, + type Project, + provider, + type Task, + tickError, +} from "./lib.ts"; + +export interface Client { + call(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise; + signOut(): Promise; +} + +/** Signs in through the browser the first time (the launcher handles the redirect). */ +export async function client(prefs: Record): Promise { + const p = provider(prefs); + const token = await oauth.token(p); + return { + async call(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method, + signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(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(`TickTick is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (!res.ok) { + if (res.status === 401) await oauth.signOut(p).catch(() => {}); + throw tickError(res.status, text); + } + return (text ? JSON.parse(text) : undefined) as T; + }, + signOut: () => oauth.signOut(p), + }; +} + +/** Runs `fn` on every item, `limit` at a time. */ +async function pool(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let next = 0; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]); + } + })); + return out; +} + +/** Every list (the inbox first) and its open tasks. */ +export async function everything(api: Client, signal?: AbortSignal): Promise<{ projects: Project[]; tasks: Task[] }> { + const lists = (await api.call("GET", "/project", undefined, signal)).filter((p) => !p.closed); + type Data = { project?: Project; tasks?: Task[] }; + const data = await pool([INBOX, ...lists], 5, async (p): Promise => { + try { + return await api.call("GET", `/project/${p.id}/data`, undefined, signal); + } catch (e) { + // The inbox isn't served to every account; the other lists are. + if (p === INBOX && !(e instanceof AuthError) && (e as Error).name !== "AbortError") return { tasks: [] }; + throw e; + } + }); + const inboxId = data[0].tasks?.[0]?.projectId ?? data[0].project?.id; + const projects = [{ ...INBOX, id: inboxId ?? INBOX.id }, ...lists]; + const tasks = data.flatMap((d) => d.tasks ?? []).filter((t) => t.status === 0); + return { projects, tasks }; +} + +export function setupItems(error: AuthError, prefs: Record): Item[] { + if (error.message === "TickTick signed you out") { + return [{ + id: "setup", + title: "TickTick signed you out", + subtitle: "Sign in again in the browser", + icon: { source: Icons.key, tint: "orange" }, + actions: [action("Sign In", () => refresh(), { icon: Icons.key })], + }]; + } + const port = Number(prefs.redirectPort) || DEFAULT_PORT; + return [{ + id: "setup", + title: error.message, + subtitle: "TickTick signs in with an app of your own", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect TickTick", + "", + "TickTick's Open API has no personal tokens: you sign in through an app you register (free):", + "", + "1. Open **developer.ticktick.com → Manage Apps** (Enter) and sign in with your TickTick account.", + "2. **+ App Name**: call it *Backslash*, then edit it and set the **OAuth redirect URL** to", + ` \`http://127.0.0.1:${port}/callback\``, + "3. Copy its **Client ID** and **Client Secret** to **Settings → Extensions → TickTick → Configure**.", + "4. Open **TickTick Tasks** again: your browser asks you to allow access, then the launcher comes back.", + "", + `Using another port? Change **Redirect port** too; it must match the redirect URL exactly.`, + "Dida365 (the Chinese service) is not supported.", + ].join("\n"), + }, + actions: [open(DEVELOPER_URL, "Open TickTick Developer", { icon: Icons.key })], + }]; +} + +export async function guarded(prefs: Record, load: (api: Client) => Promise): Promise { + try { + return await load(await client(prefs)); + } catch (e) { + if (e instanceof AuthError) return setupItems(e, prefs); + throw e; + } +} diff --git a/extensions/ticktick/backslash.toml b/extensions/ticktick/backslash.toml new file mode 100644 index 0000000..7fd1e27 --- /dev/null +++ b/extensions/ticktick/backslash.toml @@ -0,0 +1,49 @@ +[extension] +id = "ticktick" +title = "TickTick" +description = "Today's and upcoming TickTick tasks by project; complete, reschedule and quick-add tasks in plain language" +version = "1.0.0" +author = "Backslash" +icon = "bs-square-check" +license = "MIT" + +# The Open API, and the sign-in (token endpoint) on ticktick.com. +[permissions] +net = ["api.ticktick.com", "ticktick.com"] + +[[commands]] +name = "tasks" +title = "TickTick Tasks" +description = "Overdue, today and upcoming, across your lists; Enter completes" +icon = "bs-square-check" +keywords = ["ticktick", "tasks", "todo", "today", "upcoming", "tâches", "aujourd'hui", "à faire"] + +[[commands]] +name = "add" +title = "Add TickTick Task" +description = "“Call Bob tomorrow 3pm ^Work !high”: date, list and priority are understood" +icon = "bs-plus" +mode = "no-view" +keywords = ["ticktick", "add", "new", "task", "quick add", "ajouter", "nouvelle tâche"] +[[commands.arguments]] +name = "task" +placeholder = "Call Bob tomorrow 3pm ^Work !high" +required = true + +[[preferences]] +name = "clientId" +title = "Client ID" +description = "Create an app on developer.ticktick.com → Manage Apps, with the OAuth redirect URL http://127.0.0.1:43118/callback, and paste its Client ID" + +[[preferences]] +name = "clientSecret" +title = "Client Secret" +description = "The Client Secret of the same app" +type = "secret" + +[[preferences]] +name = "redirectPort" +title = "Redirect port" +description = "The port of the redirect URL registered in your app (default 43118)" +type = "number" +default = 43118 diff --git a/extensions/ticktick/commands/add.ts b/extensions/ticktick/commands/add.ts new file mode 100644 index 0000000..f22094c --- /dev/null +++ b/extensions/ticktick/commands/add.ts @@ -0,0 +1,18 @@ +import { run } from "@backslash/api"; +import { client, everything } from "../api.ts"; +import { AuthError } from "../lib.ts"; +import { createTask } from "../views.ts"; + +export default run<{ task: string }>(async (ctx) => { + const text = String(ctx.args.task ?? "").trim(); + if (!text) throw new Error("Type the task after the command"); + try { + const api = await client(ctx.preferences); + const { projects } = await everything(api); + const { summary } = await createTask(api, projects, text); + return summary; + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open TickTick Tasks to connect`); + throw e; + } +}); diff --git a/extensions/ticktick/commands/tasks.ts b/extensions/ticktick/commands/tasks.ts new file mode 100644 index 0000000..5514508 --- /dev/null +++ b/extensions/ticktick/commands/tasks.ts @@ -0,0 +1,59 @@ +import { action, type Context, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { client, everything, guarded } from "../api.ts"; +import { type Project, sortTasks, taskSection } from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; +import { taskForm, taskItem } from "../views.ts"; + +let summary = ""; +let lists: Project[] = []; + +export default (ctx: Context) => + list({ + placeholder: "Filter tasks by title, list, tag…", + filter: { + options: [ + { value: "today", title: "Today" }, + { value: "week", title: "Next 7 Days" }, + { value: "all", title: "All" }, + ], + }, + empty: { title: "Nothing to do", description: "Enjoy the rest of the day.", icon: Icons.squareCheck }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async () => taskForm(await client(ctx.preferences), lists), { + icon: Icons.plus, + shortcut: "n", + }), + action("Sign Out of TickTick", async () => { + await (await client(ctx.preferences)).signOut(); + notifyChanged(); + }, { icon: Icons.power, section: "Account" }), + ], + items: ({ signal, filter }) => + live(() => + guarded(ctx.preferences, async (api) => { + const { projects, tasks } = await everything(api, signal); + lists = projects; + const now = new Date(); + const sections = new Map(tasks.map((t) => [t.id, taskSection(t, now)])); + const overdue = tasks.filter((t) => sections.get(t.id) === "Overdue").length; + const today = tasks.filter((t) => sections.get(t.id) === "Today").length; + updateCommandMetadata({ + subtitle: + [today ? `${today} today` : "", overdue ? `${overdue} overdue` : ""].filter(Boolean).join(" · ") || + null, + }).catch(() => {}); + const keep = filter === "all" + ? () => true + : filter === "week" + ? (s: string) => s !== "Later" && s !== "No Due Date" + : (s: string) => s === "Overdue" || s === "Today"; + const shown = tasks.filter((t) => keep(sections.get(t.id)!)); + summary = [ + `${shown.length} task${shown.length === 1 ? "" : "s"}`, + overdue ? `${overdue} overdue` : "", + `${projects.length} lists`, + ].filter(Boolean).join(" · "); + return sortTasks(shown, now).map((t) => taskItem(api, t, projects, now)); + }), signal), + }); diff --git a/extensions/ticktick/lib.ts b/extensions/ticktick/lib.ts new file mode 100644 index 0000000..aa1eba8 --- /dev/null +++ b/extensions/ticktick/lib.ts @@ -0,0 +1,453 @@ +// TickTick Open API: the sign-in, response types and pure logic (quick add, dates, priorities). + +import { type Color, Icons, meta, type Metadata, type OAuthProvider } from "@backslash/api"; + +export const API = "https://api.ticktick.com/open/v1"; +export const DEVELOPER_URL = "https://developer.ticktick.com/manage"; +export const DEFAULT_PORT = 43118; + +/** The app isn't set up, or TickTick refused the token: the list shows how to connect. */ +export class AuthError extends Error {} + +/** The user's own TickTick app (TickTick has no personal tokens). */ +export function provider(prefs: Record): OAuthProvider { + const clientId = String(prefs.clientId ?? "").trim(); + const clientSecret = String(prefs.clientSecret ?? "").trim(); + if (!clientId) throw new AuthError("Connect your TickTick account"); + if (!clientSecret) throw new AuthError("Add the Client Secret of your TickTick app"); + const raw = prefs.redirectPort; + const port = raw === undefined || raw === null || raw === "" ? DEFAULT_PORT : Number(raw); + return { + id: "ticktick", + title: "TickTick", + authorizeUrl: "https://ticktick.com/oauth/authorize", + tokenUrl: "https://ticktick.com/oauth/token", + clientId, + clientSecret, + clientAuth: "basic", + scopes: ["tasks:read", "tasks:write"], + redirectPort: port > 0 && port < 65536 ? Math.round(port) : DEFAULT_PORT, + }; +} + +export function tickError(status: number, text: string): Error { + if (status === 401) return new AuthError("TickTick signed you out"); + if (status === 403) return new Error("TickTick refused it: the app needs the tasks:read and tasks:write scopes"); + if (status === 404) return new Error("Not found on TickTick: it may have been deleted"); + if (status === 429) return new Error("TickTick is rate limiting: try again in a minute"); + let message = ""; + try { + const body = JSON.parse(text); + message = body.errorMessage ?? body.error_description ?? body.message ?? ""; + } catch { + message = text.slice(0, 160); + } + return new Error(`TickTick answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Project { + id: string; + name: string; + color?: string | null; + closed?: boolean | null; + kind?: string; +} + +export interface Task { + id: string; + projectId: string; + title: string; + content?: string; + desc?: string; + isAllDay?: boolean; + startDate?: string | null; + dueDate?: string | null; + timeZone?: string; + repeatFlag?: string | null; + priority: number; + status: number; + items?: { id: string; title: string; status: number }[]; + tags?: string[]; + sortOrder?: number; +} + +export const INBOX: Project = { id: "inbox", name: "Inbox" }; + +// --------------------------------------------------------------------------- +// Dates + +/** `2026-10-29T15:00:00.000+0000` (TickTick's format) → a Date. */ +export function tickDate(s: string | null | undefined): Date | undefined { + if (!s) return undefined; + const d = new Date(s.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")); + return isNaN(d.getTime()) ? undefined : d; +} + +/** A Date in TickTick's format, UTC. */ +export function toTick(d: Date): string { + return d.toISOString().replace(/\.\d{3}Z$/, ".000+0000"); +} + +export function dueOf(task: Task): { date: Date; time: boolean } | undefined { + const date = tickDate(task.dueDate) ?? tickDate(task.startDate); + return date ? { date, time: !task.isAllDay } : undefined; +} + +/** The date fields of a task due at `when` (all day or at a time). */ +export function dateFields(when: When, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone) { + const at = when.time ? when.date : new Date(when.date.getFullYear(), when.date.getMonth(), when.date.getDate()); + return { startDate: toTick(at), dueDate: toTick(at), isAllDay: !when.time, timeZone }; +} + +export function taskSection(task: Task, now = new Date()): string { + const due = dueOf(task); + return dueBucket(due?.date, now, due?.time ?? false); +} + +export function sortTasks(tasks: Task[], now = new Date()): Task[] { + const rank = (t: Task) => BUCKETS.indexOf(taskSection(t, now)); + const when = (t: Task) => dueOf(t)?.date.getTime() ?? Infinity; + return [...tasks].sort((a, b) => + rank(a) - rank(b) || when(a) - when(b) || b.priority - a.priority || (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + ); +} + +// --------------------------------------------------------------------------- +// Priorities and colors + +export const PRIORITIES = [ + { value: 5, name: "High", color: "red" as Color }, + { value: 3, name: "Medium", color: "yellow" as Color }, + { value: 1, name: "Low", color: "blue" as Color }, + { value: 0, name: "None", color: "gray" as Color }, +]; + +export const priorityOf = (n: number) => PRIORITIES.find((p) => p.value === n) ?? PRIORITIES[3]; + +/** The palette color closest to a `#rrggbb` 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"; +} + +export const taskUrl = (t: Pick) => + `https://ticktick.com/webapp/#p/${t.projectId}/tasks/${t.id}`; + +/** "Every week" from `RRULE:FREQ=WEEKLY;INTERVAL=1`. */ +export function repeatText(rule: string | null | undefined): string | undefined { + if (!rule) return undefined; + const freq = /FREQ=(\w+)/.exec(rule)?.[1]; + const interval = Number(/INTERVAL=(\d+)/.exec(rule)?.[1] ?? 1); + const unit = { DAILY: "day", WEEKLY: "week", MONTHLY: "month", YEARLY: "year" }[freq ?? ""]; + if (!unit) return "Repeats"; + return interval > 1 ? `Every ${interval} ${unit}s` : `Every ${unit}`; +} + +export function checklist(task: Task): { done: number; total: number } { + const items = task.items ?? []; + return { done: items.filter((i) => i.status !== 0).length, total: items.length }; +} + +export function taskMetadata(task: Task, project: Project | undefined, now = new Date()): Metadata[] { + const lines: Metadata[] = []; + const due = dueOf(task); + lines.push( + due + ? meta.text("Due", shortDue(due.date, now, due.time), { + icon: Icons.calendar, + color: taskSection(task, now) === "Overdue" ? "red" : undefined, + }) + : meta.text("Due", "No date", { icon: Icons.calendar }), + ); + const repeat = repeatText(task.repeatFlag); + if (repeat) lines.push(meta.text("Repeats", repeat, { icon: Icons.repeat })); + const p = priorityOf(task.priority); + lines.push(meta.text("Priority", p.name, { icon: Icons.flag, color: p.value ? p.color : undefined })); + if (project) lines.push(meta.text("List", project.name, { icon: Icons.dot, color: colorOf(project.color) })); + if (task.tags?.length) lines.push(meta.tags("Tags", task.tags)); + const list = checklist(task); + if (list.total) lines.push(meta.progress("Checklist", list.done / list.total, `${list.done} of ${list.total}`)); + return lines; +} + +/** The note, and the checklist as a markdown list. */ +export function taskMarkdown(task: Task): string | undefined { + const parts: string[] = []; + const note = (task.content || task.desc || "").trim(); + if (note) parts.push(note.length > 600 ? `${note.slice(0, 600).trimEnd()}…` : note); + const items = task.items ?? []; + if (items.length) { + parts.push(items.map((i) => `- ${i.status !== 0 ? "~~" : ""}${i.title}${i.status !== 0 ? "~~" : ""}`).join("\n")); + } + return parts.join("\n\n") || undefined; +} + +// --------------------------------------------------------------------------- +// Quick add + +export interface QuickAdd { + title: string; + when?: When; + priority: number; + /** The list typed after ^ or ~, as typed. */ + project?: string; +} + +const PRIORITY_WORDS: Record = { high: 5, h: 5, medium: 3, med: 3, m: 3, low: 1, l: 1, none: 0 }; + +/** Words that are a date on their own, safe to take out of a title. */ +const SAFE_SINGLE = + /^(today|tonight|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d{1,2}(:\d{2})?(am|pm)|\d{1,2}:\d{2}|\d{1,2}h\d{2}|\d{4}-\d{2}-\d{2}|\+\d{1,3}[dw])$/i; + +const TIME_ONLY = /^\d{1,2}([:h.]\d{2})? ?(am|pm)?$/i; + +/** + * The longest, rightmost run of words that reads as a date, taken out of + * `words`. `want` limits it to a time alone or a day alone. + */ +function takeWhen(words: string[], now: Date, want?: "time" | "day"): { when: When; timeOnly: boolean } | undefined { + for (let len = Math.min(4, words.length); len >= 1; len--) { + for (let start = words.length - len; start >= 0; start--) { + const window = words.slice(start, start + len); + if (window.some((w) => /^[!#^~]/.test(w))) continue; + const text = window.join(" "); + if (len === 1 && !SAFE_SINGLE.test(text)) continue; + const timeOnly = TIME_ONLY.test(text); + if ((want === "time" && !timeOnly) || (want === "day" && timeOnly)) continue; + let when: When | undefined; + try { + when = parseWhen(text, now); + } catch { + continue; + } + if (!when || (want === "day" && when.time)) continue; + // "at 3pm", "on friday", "by monday", "due tomorrow". + const from = start > 0 && /^(at|on|by|due|@)$/i.test(words[start - 1]) ? start - 1 : start; + words.splice(from, start + len - from); + return { when, timeOnly }; + } + } + return undefined; +} + +/** + * "Call Bob tomorrow 3pm ^Work !high" → title "Call Bob", tomorrow at 15:00, + * list "Work", high priority. `#tags` stay in the title (the Open API takes no tags). + */ +export function parseQuickAdd(input: string, now = new Date()): QuickAdd { + let words = input.trim().split(/\s+/).filter(Boolean); + let priority = 0; + let project: string | undefined; + words = words.filter((w) => { + // !high, !med, !low, !none, or ! / !! / !!! for low, medium, high. + const bangs = /^!{1,3}$/.exec(w); + const word = /^!([a-z]+)$/i.exec(w); + const value = bangs ? [1, 3, 5][w.length - 1] : word ? PRIORITY_WORDS[word[1].toLowerCase()] : undefined; + if (value !== undefined) { + priority = value; + return false; + } + const list = /^[\^~](.+)$/.exec(w); + if (list) { + project = list[1].replace(/_/g, " "); + return false; + } + return true; + }); + const found = takeWhen(words, now); + let when = found?.when; + // A day and, elsewhere, a time: "tomorrow call Bob at 3pm". + if (found && (found.timeOnly || !found.when.time)) { + const other = takeWhen(words, now, found.timeOnly ? "day" : "time"); + if (other) { + const [day, time] = found.timeOnly ? [other.when.date, found.when.date] : [found.when.date, other.when.date]; + when = { + date: new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.getHours(), time.getMinutes()), + time: true, + }; + } + } + const title = words.join(" ").trim(); + return { title: title || input.trim(), when, priority, project }; +} + +/** The list a quick add names: exact name, then a prefix, ignoring case and emoji. */ +export function findProject(projects: Project[], name: string | undefined): Project | undefined { + if (!name) return undefined; + const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, "").trim(); + const n = norm(name); + return projects.find((p) => norm(p.name) === n) ?? projects.find((p) => norm(p.name).startsWith(n)); +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/ticktick/lib_test.ts b/extensions/ticktick/lib_test.ts new file mode 100644 index 0000000..0e0f542 --- /dev/null +++ b/extensions/ticktick/lib_test.ts @@ -0,0 +1,190 @@ +import { assertEquals, assertInstanceOf, assertThrows } from "@std/assert"; +import { + AuthError, + checklist, + colorOf, + dateFields, + dueBucket, + dueChoices, + dueOf, + findProject, + parseQuickAdd, + parseWhen, + priorityOf, + provider, + repeatText, + shortDue, + sortTasks, + type Task, + taskMarkdown, + taskSection, + tickDate, + tickError, + toTick, +} from "./lib.ts"; + +Deno.test("provider: the user's own app, basic auth, the registered port", () => { + assertThrows(() => provider({}), AuthError, "Connect"); + assertThrows(() => provider({ clientId: "abc" }), AuthError, "Client Secret"); + const p = provider({ clientId: " abc ", clientSecret: " s3cret ", redirectPort: 43118 }); + assertEquals(p.clientId, "abc"); + assertEquals(p.clientSecret, "s3cret"); + assertEquals(p.clientAuth, "basic"); + assertEquals(p.redirectPort, 43118); + assertEquals(p.scopes, ["tasks:read", "tasks:write"]); + assertEquals(new URL(p.tokenUrl).host, "ticktick.com"); + assertEquals(provider({ clientId: "a", clientSecret: "b", redirectPort: "" }).redirectPort, 43118); + assertEquals(provider({ clientId: "a", clientSecret: "b", redirectPort: 9000 }).redirectPort, 9000); +}); + +Deno.test("errors", () => { + assertInstanceOf(tickError(401, ""), AuthError); + assertEquals(tickError(500, '{"errorMessage":"boom"}').message, "TickTick answered 500: boom"); + assertEquals(tickError(400, "bad request").message, "TickTick answered 400: bad request"); +}); + +Deno.test("TickTick dates", () => { + assertEquals(tickDate("2026-10-29T15:00:00.000+0000")?.toISOString(), "2026-10-29T15:00:00.000Z"); + assertEquals(tickDate("2026-10-29T15:00:00+0100")?.toISOString(), "2026-10-29T14:00:00.000Z"); + assertEquals(tickDate(""), undefined); + assertEquals(tickDate("nope"), undefined); + assertEquals(toTick(new Date("2026-10-29T15:00:00Z")), "2026-10-29T15:00:00.000+0000"); + const allDay = dateFields({ date: new Date(2026, 9, 30, 12), time: false }, "Europe/Paris"); + assertEquals(allDay.isAllDay, true); + assertEquals(allDay.timeZone, "Europe/Paris"); + assertEquals(tickDate(allDay.dueDate)?.getTime(), new Date(2026, 9, 30).getTime()); + const atTime = dateFields({ date: new Date(2026, 9, 30, 15, 30), time: true }, "UTC"); + assertEquals(atTime.isAllDay, false); + assertEquals(tickDate(atTime.startDate)?.getTime(), new Date(2026, 9, 30, 15, 30).getTime()); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const task = (over: Partial): Task => ({ + id: "t", + projectId: "p", + title: "Task", + priority: 0, + status: 0, + ...over, +}); + +Deno.test("tasks: due, sections, order", () => { + const allDayToday = task({ id: "a", isAllDay: true, dueDate: toTick(new Date(2026, 9, 27)) }); + const lateAtTime = task({ id: "b", isAllDay: false, dueDate: toTick(new Date(2026, 9, 27, 9)) }); + const soon = task({ id: "c", isAllDay: false, dueDate: toTick(new Date(2026, 9, 28, 9)), priority: 5 }); + const none = task({ id: "d" }); + assertEquals(dueOf(allDayToday)?.time, false); + assertEquals(taskSection(allDayToday, NOW), "Today"); + assertEquals(taskSection(lateAtTime, NOW), "Overdue"); + assertEquals(taskSection(soon, NOW), "Tomorrow"); + assertEquals(taskSection(none, NOW), "No Due Date"); + assertEquals(sortTasks([none, soon, allDayToday, lateAtTime], NOW).map((t) => t.id), ["b", "a", "c", "d"]); + // A task with only a start date is due then. + assertEquals(taskSection(task({ isAllDay: true, startDate: toTick(new Date(2026, 9, 27)) }), NOW), "Today"); +}); + +Deno.test("priorities, colors, repeats, checklists", () => { + assertEquals(priorityOf(5).name, "High"); + assertEquals(priorityOf(3).color, "yellow"); + assertEquals(priorityOf(42).name, "None"); + assertEquals(colorOf("#F18181"), "red"); + assertEquals(colorOf("#4CA1FF"), "blue"); + assertEquals(repeatText("RRULE:FREQ=WEEKLY;INTERVAL=1"), "Every week"); + assertEquals(repeatText("RRULE:FREQ=DAILY;INTERVAL=2"), "Every 2 days"); + assertEquals(repeatText(null), undefined); + const t = task({ + content: "Bring the slides", + items: [{ id: "1", title: "Print", status: 1 }, { id: "2", title: "Staple", status: 0 }], + }); + assertEquals(checklist(t), { done: 1, total: 2 }); + assertEquals(taskMarkdown(t), "Bring the slides\n\n- ~~Print~~\n- Staple"); + assertEquals(taskMarkdown(task({})), undefined); +}); + +Deno.test("quick add: dates, times, lists and priority", () => { + const a = parseQuickAdd("Call Bob tomorrow 3pm ^Work !high", NOW); + assertEquals(a.title, "Call Bob"); + assertEquals(a.when, { date: new Date(2026, 9, 28, 15), time: true }); + assertEquals(a.project, "Work"); + assertEquals(a.priority, 5); + + const b = parseQuickAdd("Pay rent on friday", NOW); + assertEquals(b.title, "Pay rent"); + assertEquals(b.when, { date: new Date(2026, 9, 30), time: false }); + + const c = parseQuickAdd("tomorrow call mom at 18:30 !!", NOW); + assertEquals(c.title, "call mom"); + assertEquals(c.when, { date: new Date(2026, 9, 28, 18, 30), time: true }); + assertEquals(c.priority, 3); + + const d = parseQuickAdd("Dentist next week ~Personal_Stuff", NOW); + assertEquals(d.title, "Dentist"); + assertEquals(d.when?.date, new Date(2026, 10, 2)); + assertEquals(d.project, "Personal Stuff"); + + // Words that only look like dates stay in the title. + const e = parseQuickAdd("Call Tom about the sun cream for 1h", NOW); + assertEquals(e.title, "Call Tom about the sun cream for 1h"); + assertEquals(e.when, undefined); + assertEquals(e.priority, 0); + + const f = parseQuickAdd("Buy 3 apples #groceries 2026-11-03", NOW); + assertEquals(f.title, "Buy 3 apples #groceries"); + assertEquals(f.when, { date: new Date(2026, 10, 3), time: false }); + + assertEquals(parseQuickAdd("Read 3 nov issue !low", NOW).when?.date, new Date(2026, 10, 3)); + assertEquals(parseQuickAdd("today", NOW).title, "today"); +}); + +Deno.test("find the list a quick add names", () => { + const lists = [{ id: "1", name: "📥 Inbox" }, { id: "2", name: "Work" }, { id: "3", name: "Personal stuff" }]; + assertEquals(findProject(lists, "work")?.id, "2"); + assertEquals(findProject(lists, "personal")?.id, "3"); + assertEquals(findProject(lists, "inbox")?.id, "1"); + assertEquals(findProject(lists, "garden"), undefined); + assertEquals(findProject(lists, undefined), undefined); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/ticktick/live.ts b/extensions/ticktick/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/ticktick/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +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); + }); + } +} diff --git a/extensions/ticktick/views.ts b/extensions/ticktick/views.ts new file mode 100644 index 0000000..26797c3 --- /dev/null +++ b/extensions/ticktick/views.ts @@ -0,0 +1,161 @@ +// Items, actions and the new task form. + +import { type Action, action, copy, field, form, Icons, type Item, open, pop, submenu, toast } from "@backslash/api"; +import type { Client } from "./api.ts"; +import { + checklist, + colorOf, + dateFields, + dueChoices, + dueOf, + findProject, + INBOX, + parseQuickAdd, + parseWhen, + PRIORITIES, + priorityOf, + type Project, + repeatText, + shortDue, + type Task, + taskMarkdown, + taskMetadata, + taskSection, + taskUrl, +} from "./lib.ts"; +import { notifyChanged } from "./live.ts"; + +async function edit(api: Client, task: Task, fields: Record, message: string) { + await api.call("POST", `/task/${task.id}`, { id: task.id, projectId: task.projectId, ...fields }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +export function taskActions(api: Client, task: Task, projects: Project[]): Action[] { + return [ + action("Complete Task", async () => { + await api.call("POST", `/project/${task.projectId}/task/${task.id}/complete`); + notifyChanged(); + await toast(task.repeatFlag ? "Done, next one scheduled" : `Completed “${task.title}”`, { style: "success" }); + }, { icon: Icons.squareCheck }), + open(taskUrl(task), "Open in TickTick"), + submenu( + "Change Due Date", + dueChoices().filter((c) => c.date).map((c) => + action(c.title, () => { + const when = parseWhen(c.date!)!; + // Keep the time of day of a task due at a time. + const due = dueOf(task); + if (due?.time) when.date.setHours(due.date.getHours(), due.date.getMinutes()); + return edit(api, task, dateFields({ date: when.date, time: !!due?.time }), `Due ${c.title.toLowerCase()}`); + }, { icon: Icons.calendar }) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ), + submenu( + "Set Priority", + PRIORITIES.map((p) => + action(p.name, () => edit(api, task, { priority: p.value }, `Priority ${p.name.toLowerCase()}`), { + icon: Icons.flag, + }) + ), + { icon: Icons.flag, shortcut: "p", section: "Task" }, + ), + action("New Task…", () => taskForm(api, projects, { project: task.projectId }), { + icon: Icons.plus, + shortcut: "n", + section: "Task", + }), + copy(task.title, "Copy Title", { shortcut: "period", section: "Copy" }), + copy(taskUrl(task), "Copy Link", { shortcut: "c", section: "Copy" }), + action("Delete Task", async () => { + await api.call("DELETE", `/project/${task.projectId}/task/${task.id}`); + notifyChanged(); + await toast("Deleted", { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${task.title}”?`, + shortcut: "Delete", + section: "Danger", + }), + ]; +} + +export function taskItem(api: Client, task: Task, projects: Project[], now = new Date()): Item { + const project = projects.find((p) => p.id === task.projectId); + const due = dueOf(task); + const prio = priorityOf(task.priority); + const list = checklist(task); + const section = taskSection(task, now); + return { + id: task.id, + title: task.title || "Untitled", + subtitle: (task.content || task.desc || "").split("\n")[0].replace(/[*_`#>]/g, "").trim().slice(0, 80) || undefined, + icon: { source: Icons.square, tint: prio.color }, + section, + keywords: [project?.name ?? "", ...(task.tags ?? [])].filter(Boolean), + accessories: [ + ...(list.total + ? [{ + icon: Icons.squareCheck, + text: `${list.done}/${list.total}`, + color: list.done === list.total ? "green" as const : undefined, + tooltip: "Checklist", + }] + : []), + ...(task.repeatFlag ? [{ icon: Icons.repeat, tooltip: repeatText(task.repeatFlag) }] : []), + ...(due + ? [{ + icon: Icons.calendar, + // In "Today", the time says more than "today". + text: section === "Today" && !due.time ? "today" : shortDue(due.date, now, due.time).replace(/^today /, ""), + color: section === "Overdue" ? "red" as const : undefined, + tooltip: "Due", + }] + : []), + ...(project ? [{ icon: { source: Icons.dot, tint: colorOf(project.color) }, text: project.name }] : []), + ], + detail: { markdown: taskMarkdown(task), metadata: taskMetadata(task, project, now) }, + actions: taskActions(api, task, projects), + }; +} + +export async function createTask(api: Client, projects: Project[], text: string, projectId?: string) { + const q = parseQuickAdd(text); + const named = findProject(projects, q.project); + if (q.project && !named) throw new Error(`No TickTick list “${q.project}”`); + const target = named?.id ?? projectId; + const task = await api.call("POST", "/task", { + title: q.title, + priority: q.priority, + // Without a list, TickTick files it in the inbox. + ...(target && target !== INBOX.id && !/^inbox/.test(target) ? { projectId: target } : {}), + ...(q.when ? dateFields(q.when) : {}), + }); + notifyChanged(); + const where = named?.name ?? projects.find((p) => p.id === (target ?? task.projectId))?.name ?? "Inbox"; + const when = q.when ? ` · ${shortDue(q.when.date, new Date(), q.when.time)}` : ""; + return { task, summary: `Added “${q.title}” to ${where}${when}` }; +} + +export function taskForm(api: Client, projects: Project[], options: { project?: string } = {}) { + return form({ + title: "New TickTick task", + submitTitle: "Add Task", + fields: [ + field.text("title", "Task", { + required: true, + placeholder: "Call Bob tomorrow 3pm !high: dates and priority are understood", + }), + field.dropdown("project", "List", projects.map((p) => ({ value: p.id, title: p.name })), { + value: options.project ?? projects[0]?.id, + }), + ], + async submit(values) { + const { summary } = await createTask(api, projects, String(values.title), String(values.project)); + await toast(summary, { style: "success" }); + return pop(); + }, + }); +} diff --git a/extensions/trello/api.ts b/extensions/trello/api.ts new file mode 100644 index 0000000..ec07123 --- /dev/null +++ b/extensions/trello/api.ts @@ -0,0 +1,46 @@ +// Calls to the Trello REST API with the user's key and token. + +import type { Item } from "@backslash/api"; +import { type Account, account, API, AuthError, setupItem, trelloError } from "./lib.ts"; + +export async function trello( + a: Account, + path: string, + options: { method?: string; params?: Record; signal?: AbortSignal } = + {}, +): Promise { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(options.params ?? {})) if (v !== undefined) params.set(k, String(v)); + const sep = path.includes("?") ? "&" : "?"; + let res: Response; + try { + res = await fetch(`${API}${path}${params.size ? `${sep}${params}` : ""}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + // The documented header form, which keeps the token out of URLs and logs. + Authorization: `OAuth oauth_consumer_key="${a.key}", oauth_token="${a.token}"`, + Accept: "application/json", + }, + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Trello is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (!res.ok) throw trelloError(res.status, text); + return (text ? JSON.parse(text) : undefined) as T; +} + +export const CARD_FIELDS = + "name,desc,due,dueComplete,start,idList,idBoard,idMembers,labels,url,shortUrl,pos,dateLastActivity,badges"; +export const MEMBER_FIELDS = "fullName,username,avatarUrl,initials"; + +export async function guarded(prefs: Record, load: (a: Account) => Promise): Promise { + try { + return await load(account(prefs)); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e, prefs)]; + throw e; + } +} diff --git a/extensions/trello/backslash.toml b/extensions/trello/backslash.toml new file mode 100644 index 0000000..7e61893 --- /dev/null +++ b/extensions/trello/backslash.toml @@ -0,0 +1,44 @@ +[extension] +id = "trello" +title = "Trello" +description = "Your boards and cards with labels, due dates and members; move, complete, archive and create cards" +version = "1.0.0" +author = "Backslash" +icon = "bs-kanban" +license = "MIT" + +# The REST API, members' avatars and the scaled board backgrounds. +[permissions] +net = ["api.trello.com", "trello-members.s3.amazonaws.com", "trello-backgrounds.s3.amazonaws.com"] + +[[commands]] +name = "boards" +title = "Trello Boards" +description = "Starred boards first; Enter shows the cards, by list" +icon = "bs-kanban" +keywords = ["trello", "boards", "kanban", "cards", "lists", "tableaux", "cartes"] + +[[commands]] +name = "my-cards" +title = "My Trello Cards" +description = "Cards you are a member of, by due date" +icon = "bs-square-check" +keywords = ["trello", "cards", "my cards", "due", "tasks", "todo", "mes cartes", "échéance", "tâches"] + +[[commands]] +name = "create-card" +title = "Create Trello Card" +description = "Pick a board and list, a title, a due date" +icon = "bs-plus" +keywords = ["trello", "new", "card", "add", "create", "nouvelle carte", "ajouter", "créer"] + +[[preferences]] +name = "apiKey" +title = "API key" +description = "The API key of a Power-Up of yours: trello.com/power-ups/admin → New → API key" + +[[preferences]] +name = "token" +title = "Token" +description = "Generated from your API key page (“Token” link), or by opening the setup row in Trello Boards" +type = "secret" diff --git a/extensions/trello/commands/boards.ts b/extensions/trello/commands/boards.ts new file mode 100644 index 0000000..30ff05d --- /dev/null +++ b/extensions/trello/commands/boards.ts @@ -0,0 +1,45 @@ +import { type Context, list } from "@backslash/api"; +import { guarded, trello } from "../api.ts"; +import type { Board, Organization } from "../lib.ts"; +import { boardItem } from "../views.ts"; + +let counts = { boards: 0, starred: 0 }; + +export default (ctx: Context) => + list({ + placeholder: "Filter boards…", + empty: { title: "No open board", description: "Create a board in Trello first.", icon: "bs-kanban" }, + status: (items) => + items[0]?.id === "setup" + ? "Not connected" + : `${counts.boards} board${counts.boards === 1 ? "" : "s"}${ + counts.starred ? ` · ${counts.starred} starred` : "" + }`, + items: ({ preferences, signal }) => + guarded(preferences, async (a) => { + const [boards, orgs] = await Promise.all([ + trello(a, "/members/me/boards", { + params: { + filter: "open", + fields: "name,desc,url,shortUrl,starred,idOrganization,dateLastActivity,prefs", + lists: "open", + list_fields: "name", + }, + signal, + }), + trello(a, "/members/me/organizations", { params: { fields: "displayName" }, signal }) + .catch(() => [] as Organization[]), + ]); + const names = new Map(orgs.map((o) => [o.id, o.displayName])); + counts = { boards: boards.length, starred: boards.filter((b) => b.starred).length }; + const workspace = (b: Board) => + b.idOrganization ? names.get(b.idOrganization) ?? "Other Workspace" : "Personal"; + return [...boards] + .sort((x, y) => + Number(!!y.starred) - Number(!!x.starred) || + (x.starred ? 0 : workspace(x).localeCompare(workspace(y))) || + (y.dateLastActivity ?? "").localeCompare(x.dateLastActivity ?? "") + ) + .map((b) => boardItem(a, b, ctx.preferences, workspace(b))); + }), + }); diff --git a/extensions/trello/commands/create-card.ts b/extensions/trello/commands/create-card.ts new file mode 100644 index 0000000..54e2df5 --- /dev/null +++ b/extensions/trello/commands/create-card.ts @@ -0,0 +1,14 @@ +import { type Context, detail } from "@backslash/api"; +import { account, AuthError, setupItem } from "../lib.ts"; +import { cardForm } from "../views.ts"; + +// Text typed before opening the command becomes the title. +export default async (ctx: Context) => { + try { + return await cardForm(account(ctx.preferences), {}, { standalone: true, title: ctx.query.trim() }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const setup = setupItem(e, ctx.preferences); + return detail({ title: "Connect Trello", markdown: setup.detail!.markdown!, actions: setup.actions }); + } +}; diff --git a/extensions/trello/commands/my-cards.ts b/extensions/trello/commands/my-cards.ts new file mode 100644 index 0000000..9fa521c --- /dev/null +++ b/extensions/trello/commands/my-cards.ts @@ -0,0 +1,59 @@ +import { list, updateCommandMetadata } from "@backslash/api"; +import { CARD_FIELDS, guarded, MEMBER_FIELDS, trello } from "../api.ts"; +import { type Board, type Card, cardSection, dueOf, sortByDue } from "../lib.ts"; +import { live } from "../live.ts"; +import { cardItem } from "../views.ts"; + +let summary = ""; + +export default list({ + placeholder: "Filter your cards by title, label, board, list…", + filter: { + options: [ + { value: "all", title: "All My Cards" }, + { value: "due", title: "Due This Week" }, + ], + }, + empty: { title: "No card here", description: "Cards you are a member of show up here.", icon: "bs-square-check" }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + items: ({ preferences, signal, filter }) => + live(() => + guarded(preferences, async (a) => { + const [cards, boards] = await Promise.all([ + trello(a, "/members/me/cards", { + params: { filter: "visible", fields: CARD_FIELDS, members: true, member_fields: MEMBER_FIELDS }, + signal, + }), + trello(a, "/members/me/boards", { + params: { filter: "all", fields: "name", lists: "all", list_fields: "name" }, + signal, + }), + ]); + const boardName = new Map(boards.map((b) => [b.id, b.name])); + const listName = new Map(boards.flatMap((b) => (b.lists ?? []).map((l) => [l.id, l.name] as const))); + const now = new Date(); + const sections = new Map(cards.map((c) => [c.id, cardSection(c, now)])); + const overdue = cards.filter((c) => sections.get(c.id) === "Overdue").length; + const today = cards.filter((c) => sections.get(c.id) === "Today").length; + summary = [ + `${cards.length} card${cards.length === 1 ? "" : "s"}`, + overdue ? `${overdue} overdue` : "", + today ? `${today} due today` : "", + ].filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [overdue ? `${overdue} overdue` : "", today ? `${today} due today` : ""].filter(Boolean) + .join(" · ") || null, + }).catch(() => {}); + const week = now.getTime() + 7 * 86_400_000; + const shown = filter === "due" + ? cards.filter((c) => !c.dueComplete && c.due && dueOf(c)!.getTime() < week) + : cards; + return sortByDue(shown).map((c) => + cardItem(a, c, { + board: boardName.get(c.idBoard), + list: listName.get(c.idList), + section: sections.get(c.id), + }, now) + ); + }), signal), +}); diff --git a/extensions/trello/lib.ts b/extensions/trello/lib.ts new file mode 100644 index 0000000..31e4012 --- /dev/null +++ b/extensions/trello/lib.ts @@ -0,0 +1,456 @@ +// Trello: account, response types and pure mapping (colors, due dates, metadata). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://api.trello.com/1"; +export const KEY_URL = "https://trello.com/power-ups/admin"; + +/** The key or token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export interface Account { + key: string; + token: string; +} + +export function account(prefs: Record): Account { + const key = String(prefs.apiKey ?? "").trim(); + const token = String(prefs.token ?? "").trim(); + if (!key) throw new AuthError("Connect your Trello account"); + if (!/^[0-9a-f]{32}$/i.test(key)) { + throw new AuthError("This doesn't look like a Trello API key (32 hexadecimal characters)"); + } + if (!token) throw new AuthError("Generate a token for your API key"); + return { key, token }; +} + +/** The page where Trello issues a token for the user's own key. */ +export function authorizeUrl(key: string): string { + const params = new URLSearchParams({ + expiration: "never", + scope: "read,write", + response_type: "token", + name: "Backslash", + key, + }); + return `https://trello.com/1/authorize?${params}`; +} + +/** Trello answers errors in plain text ("invalid token", "unauthorized card permission requested"). */ +export function trelloError(status: number, text: string): Error { + const message = text.trim().slice(0, 200); + if (status === 401 && /invalid (key|token)|expired token|unauthorized permission/i.test(message)) { + return new AuthError("Trello rejected the API key or token"); + } + if (status === 401) return new Error(`Trello refused it: ${message || "unauthorized"}`); + if (status === 404) return new Error("Not found on Trello: it may have been deleted or moved"); + if (status === 429) return new Error("Trello is rate limiting: try again in a few seconds"); + return new Error(`Trello answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Label { + id: string; + name: string; + color: string | null; +} + +export interface Member { + id: string; + fullName: string; + username: string; + avatarUrl?: string | null; + initials?: string; +} + +export interface Card { + id: string; + name: string; + desc: string; + due: string | null; + dueComplete: boolean; + start?: string | null; + idList: string; + idBoard: string; + idMembers: string[]; + labels: Label[]; + url: string; + shortUrl?: string; + pos: number; + dateLastActivity: string; + members?: Member[]; + badges?: { + comments?: number; + attachments?: number; + checkItems?: number; + checkItemsChecked?: number; + description?: boolean; + }; +} + +export interface List { + id: string; + name: string; + pos: number; + closed?: boolean; +} + +export interface Board { + id: string; + name: string; + desc?: string; + url: string; + shortUrl?: string; + starred?: boolean; + closed?: boolean; + idOrganization?: string | null; + dateLastActivity?: string | null; + prefs?: { + backgroundColor?: string | null; + backgroundTopColor?: string | null; + backgroundImageScaled?: { width: number; height: number; url: string }[] | null; + }; + lists?: List[]; +} + +export interface Organization { + id: string; + displayName: string; +} + +// --------------------------------------------------------------------------- +// Colors + +/** Trello's label colors (also `green_dark`, `sky_light`…) in the launcher's palette. */ +export function labelColor(color: string | null | undefined): Color { + const base = (color ?? "").replace(/_(dark|light)$/, ""); + const map: Record = { + green: "green", + yellow: "yellow", + orange: "orange", + red: "red", + purple: "purple", + blue: "blue", + sky: "teal", + lime: "green", + pink: "pink", + black: "gray", + }; + return map[base] ?? "gray"; +} + +/** The palette color closest to a `#rrggbb` 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"; +} + +export function boardColor(board: Board): Color { + return colorOf(board.prefs?.backgroundTopColor ?? board.prefs?.backgroundColor); +} + +/** A scaled background around 600px wide, only from the host in `net`. */ +export function boardImage(board: Board): string | undefined { + const scaled = (board.prefs?.backgroundImageScaled ?? []) + .filter((s) => s.url.startsWith("https://trello-backgrounds.s3.amazonaws.com/")); + if (!scaled.length) return undefined; + const fit = [...scaled].sort((a, b) => Math.abs(a.width - 640) - Math.abs(b.width - 640)); + return fit[0].url; +} + +/** `https://trello-members.s3.amazonaws.com//` → its 50px picture. */ +export function avatarOf(member: Member): string { + const url = member.avatarUrl ?? ""; + if (url.startsWith("https://trello-members.s3.amazonaws.com/")) return /\.png$/.test(url) ? url : `${url}/50.png`; + return member.fullName || member.username; +} + +// --------------------------------------------------------------------------- +// Cards + +export const dueOf = (card: Card) => card.due ? new Date(card.due) : undefined; + +export function cardSection(card: Card, now = new Date()): string { + if (card.dueComplete) return "Completed"; + return dueBucket(dueOf(card), now); +} + +/** Due first (soonest first), then the most recently active. */ +export function sortByDue(cards: Card[]): Card[] { + const key = (c: Card) => c.dueComplete ? 2 : c.due ? 0 : 1; + return [...cards].sort((a, b) => + key(a) - key(b) || (a.due ?? "").localeCompare(b.due ?? "") || + b.dateLastActivity.localeCompare(a.dateLastActivity) + ); +} + +/** Cards of a board by list position, then card position. */ +export function sortByList(cards: Card[], lists: List[]): Card[] { + const order = new Map(lists.map((l) => [l.id, l.pos])); + return [...cards].sort((a, b) => (order.get(a.idList) ?? 1e15) - (order.get(b.idList) ?? 1e15) || a.pos - b.pos); +} + +export function checklist(card: Card): { done: number; total: number } { + return { done: card.badges?.checkItemsChecked ?? 0, total: card.badges?.checkItems ?? 0 }; +} + +export function dueColor(card: Card, now = new Date()): Color | undefined { + const due = dueOf(card); + if (!due) return undefined; + if (card.dueComplete) return "green"; + if (due < now) return "red"; + if (due.getTime() - now.getTime() < 86_400_000) return "orange"; + return undefined; +} + +export function cardIcon(card: Card): { source: string; tint: Color } { + if (card.dueComplete) return { source: Icons.squareCheck, tint: "green" }; + const first = card.labels.find((l) => l.color); + return { source: Icons.square, tint: first ? labelColor(first.color) : "gray" }; +} + +/** Labels without a name show their color, as on the board. */ +export const labelText = (l: Label) => l.name || (l.color ?? "label").replace(/_/g, " "); + +export function cardMetadata( + card: Card, + where: { board?: string; list?: string }, + now = new Date(), +): Metadata[] { + const lines: Metadata[] = []; + if (where.board) lines.push(meta.text("Board", where.board, { icon: Icons.kanban })); + if (where.list) lines.push(meta.text("List", where.list, { icon: Icons.layers })); + const due = dueOf(card); + if (due) { + lines.push(meta.text("Due", `${shortDue(due, now)}${card.dueComplete ? " · complete" : ""}`, { + icon: card.dueComplete ? Icons.squareCheck : Icons.calendar, + color: dueColor(card, now), + })); + } + if (card.start) lines.push(meta.text("Start", shortDue(new Date(card.start), now, false), { icon: Icons.calendar })); + if (card.labels.length) { + lines.push(meta.tags("Labels", card.labels.map((l) => ({ text: labelText(l), color: labelColor(l.color) })))); + } + for (const [i, m] of (card.members ?? []).entries()) { + lines.push(meta.person(i === 0 ? "Members" : "", m.fullName || m.username, avatarOf(m))); + } + const list = checklist(card); + if (list.total) lines.push(meta.progress("Checklist", list.done / list.total, `${list.done} of ${list.total}`)); + if (card.badges?.comments) lines.push(meta.text("Comments", card.badges.comments, { icon: Icons.comment })); + if (card.badges?.attachments) lines.push(meta.text("Attachments", card.badges.attachments, { icon: Icons.file })); + lines.push(meta.separator()); + lines.push(meta.text("Last activity", shortDue(new Date(card.dateLastActivity), now, false))); + lines.push(meta.link("Link", (card.shortUrl ?? card.url).replace(/^https:\/\//, ""), card.shortUrl ?? card.url)); + return lines; +} + +/** The first paragraphs of the description, for the side panel. */ +export function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +export function setupItem(error: AuthError, prefs: Record): Item { + const key = String(prefs.apiKey ?? "").trim(); + const hasKey = /^[0-9a-f]{32}$/i.test(key); + return { + id: "setup", + title: error.message, + subtitle: hasKey ? "Your API key is set: generate a token for it" : "An API key and a token from Trello", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Trello", + "", + "Trello gives personal access with an **API key** and a **token**:", + "", + "1. Open **trello.com/power-ups/admin**, create a Power-Up (any name, your workspace),", + " then **API key → Generate a new API key**.", + "2. Paste the key in **Settings → Extensions → Trello → Configure → API key**.", + "3. Come back here: Enter opens Trello's page that **generates a token** for that key", + " (read and write, no expiry). Paste it in **Token**.", + "", + "You can revoke the token any time in Trello → Settings → Applications.", + ].join("\n"), + }, + actions: hasKey + ? [ + open(authorizeUrl(key), "Generate Token", { icon: Icons.key }), + open(KEY_URL, "Open Power-Up Admin", { icon: Icons.settings }), + ] + : [open(KEY_URL, "Create an API Key", { icon: Icons.key })], + }; +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/trello/lib_test.ts b/extensions/trello/lib_test.ts new file mode 100644 index 0000000..818b2c5 --- /dev/null +++ b/extensions/trello/lib_test.ts @@ -0,0 +1,204 @@ +import { assert, assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + account, + AuthError, + authorizeUrl, + avatarOf, + type Board, + boardColor, + boardImage, + type Card, + cardIcon, + cardMetadata, + cardSection, + colorOf, + dueBucket, + dueChoices, + dueColor, + labelColor, + labelText, + parseWhen, + setupItem, + shortDue, + sortByDue, + sortByList, + trelloError, +} from "./lib.ts"; + +const KEY = "0123456789abcdef0123456789abcdef"; + +Deno.test("account: key and token", () => { + assertThrows(() => account({}), AuthError, "Connect"); + assertThrows(() => account({ apiKey: "nope" }), AuthError, "API key"); + assertThrows(() => account({ apiKey: KEY }), AuthError, "token"); + assertEquals(account({ apiKey: ` ${KEY} `, token: " ATTA123 " }), { key: KEY, token: "ATTA123" }); + const url = new URL(authorizeUrl(KEY)); + assertEquals(url.host, "trello.com"); + assertEquals(url.searchParams.get("key"), KEY); + assertEquals(url.searchParams.get("scope"), "read,write"); + assertEquals(url.searchParams.get("expiration"), "never"); +}); + +Deno.test("errors", () => { + assertInstanceOf(trelloError(401, "invalid token"), AuthError); + assertInstanceOf(trelloError(401, "invalid key"), AuthError); + assert(!(trelloError(401, "unauthorized card permission requested") instanceof AuthError)); + assertStringIncludes(trelloError(404, "The requested resource was not found.").message, "Not found"); + assertEquals(trelloError(500, "").message, "Trello answered 500"); +}); + +Deno.test("setup row: with a key, Enter generates the token", () => { + const none = setupItem(new AuthError("Connect your Trello account"), {}); + assertEquals(none.actions![0].title, "Create an API Key"); + const withKey = setupItem(new AuthError("Generate a token for your API key"), { apiKey: KEY }); + assertEquals(withKey.actions![0].title, "Generate Token"); + assertStringIncludes((withKey.actions![0] as { target: string }).target, KEY); +}); + +Deno.test("colors", () => { + assertEquals(labelColor("green"), "green"); + assertEquals(labelColor("sky_dark"), "teal"); + assertEquals(labelColor("black_light"), "gray"); + assertEquals(labelColor(null), "gray"); + assertEquals(colorOf("#0079BF"), "blue"); + assertEquals(colorOf("#D29034"), "orange"); + assertEquals(colorOf("#838C91"), "gray"); + assertEquals(boardColor({ id: "b", name: "B", url: "", prefs: { backgroundColor: "#519839" } }), "green"); + assertEquals(labelText({ id: "l", name: "", color: "purple_dark" }), "purple dark"); +}); + +Deno.test("board background: the scaled image closest to 640px from the allowed host", () => { + const board: Board = { + id: "b", + name: "B", + url: "", + prefs: { + backgroundImageScaled: [ + { width: 140, height: 100, url: "https://trello-backgrounds.s3.amazonaws.com/x/140x100/a.jpg" }, + { width: 640, height: 480, url: "https://trello-backgrounds.s3.amazonaws.com/x/640x480/a.jpg" }, + { width: 1920, height: 1080, url: "https://trello-backgrounds.s3.amazonaws.com/x/1920x1080/a.jpg" }, + ], + }, + }; + assertEquals(boardImage(board), "https://trello-backgrounds.s3.amazonaws.com/x/640x480/a.jpg"); + assertEquals( + boardImage({ + ...board, + prefs: { backgroundImageScaled: [{ width: 640, height: 480, url: "https://images.unsplash.com/x" }] }, + }), + undefined, + ); + assertEquals( + avatarOf({ id: "m", fullName: "Ada", username: "ada", avatarUrl: "https://trello-members.s3.amazonaws.com/1/abc" }), + "https://trello-members.s3.amazonaws.com/1/abc/50.png", + ); + assertEquals(avatarOf({ id: "m", fullName: "Ada Lovelace", username: "ada", avatarUrl: null }), "Ada Lovelace"); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); + +function card(over: Partial): Card { + return { + id: "c", + name: "Card", + desc: "", + due: null, + dueComplete: false, + idList: "l1", + idBoard: "b1", + idMembers: [], + labels: [], + url: "https://trello.com/c/abc/1-card", + shortUrl: "https://trello.com/c/abc", + pos: 1, + dateLastActivity: "2026-10-20T10:00:00.000Z", + ...over, + }; +} + +Deno.test("cards: sections, order, icon, due color", () => { + const late = card({ id: "late", due: new Date(2026, 9, 26, 12).toISOString() }); + const soon = card({ id: "soon", due: new Date(2026, 9, 27, 18).toISOString() }); + const done = card({ id: "done", due: new Date(2026, 9, 20).toISOString(), dueComplete: true }); + const none = card({ id: "none", labels: [{ id: "x", name: "Bug", color: "red" }] }); + assertEquals(cardSection(late, NOW), "Overdue"); + assertEquals(cardSection(soon, NOW), "Today"); + assertEquals(cardSection(done, NOW), "Completed"); + assertEquals(cardSection(none, NOW), "No Due Date"); + assertEquals(sortByDue([none, done, soon, late]).map((c) => c.id), ["late", "soon", "none", "done"]); + assertEquals(cardIcon(none).tint, "red"); + assertEquals(cardIcon(done).tint, "green"); + assertEquals(dueColor(late, NOW), "red"); + assertEquals(dueColor(soon, NOW), "orange"); + assertEquals(dueColor(none, NOW), undefined); + const lists = [{ id: "l2", name: "Doing", pos: 2 }, { id: "l1", name: "To Do", pos: 1 }]; + const a = card({ id: "a", idList: "l2", pos: 5 }); + const b = card({ id: "b", idList: "l1", pos: 9 }); + const c = card({ id: "c", idList: "l1", pos: 3 }); + assertEquals(sortByList([a, b, c], lists).map((x) => x.id), ["c", "b", "a"]); +}); + +Deno.test("card metadata", () => { + const lines = cardMetadata( + card({ + due: new Date(2026, 9, 28, 17).toISOString(), + labels: [{ id: "x", name: "Design", color: "purple" }], + members: [{ id: "m", fullName: "Ada Lovelace", username: "ada", avatarUrl: null }], + badges: { checkItems: 4, checkItemsChecked: 3, comments: 2 }, + }), + { board: "Roadmap", list: "Doing" }, + NOW, + ); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Board")?.value, "Roadmap"); + assertEquals(by("Due")?.value, "tomorrow 17:00"); + assertEquals(by("Labels")?.tags, [{ text: "Design", color: "purple" }]); + assertEquals(by("Members")?.avatar, "Ada Lovelace"); + assertEquals(by("Checklist")?.progress, 0.75); + assertEquals(by("Comments")?.value, "2"); + assertEquals(by("Link")?.link, "https://trello.com/c/abc"); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/trello/live.ts b/extensions/trello/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/trello/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +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); + }); + } +} diff --git a/extensions/trello/views.ts b/extensions/trello/views.ts new file mode 100644 index 0000000..b801a14 --- /dev/null +++ b/extensions/trello/views.ts @@ -0,0 +1,296 @@ +// Items, actions and forms shared by the commands. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + pop, + storage, + submenu, + toast, +} from "@backslash/api"; +import { CARD_FIELDS, guarded, MEMBER_FIELDS, trello } from "./api.ts"; +import { + type Account, + avatarOf, + type Board, + boardColor, + boardImage, + type Card, + cardIcon, + cardMetadata, + checklist, + dueColor, + dueOf, + excerpt, + labelColor, + labelText, + type List, + type Member, + parseWhen, + shortDue, + sortByList, +} from "./lib.ts"; +import { live, notifyChanged } from "./live.ts"; + +async function updateCard(a: Account, card: Card, params: Record, message: string) { + await trello(a, `/cards/${card.id}`, { method: "PUT", params }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +export function cardActions(a: Account, card: Card, where: { list?: string }): Action[] { + const moveTo = submenu("Move to List", async () => { + const lists = await trello(a, `/boards/${card.idBoard}/lists`, { + params: { filter: "open", fields: "name,pos" }, + }); + return lists.filter((l) => l.id !== card.idList).map((l) => + action(l.name, () => updateCard(a, card, { idList: l.id, pos: "top" }, `Moved to ${l.name}`), { + icon: Icons.arrowRight, + }) + ); + }, { icon: Icons.layers, shortcut: "m", section: "Card" }); + const due = dueOf(card); + return [ + open(card.url, "Open in Trello"), + moveTo, + ...(due + ? [action( + card.dueComplete ? "Mark Due Incomplete" : "Mark Due Complete", + () => + updateCard( + a, + card, + { dueComplete: !card.dueComplete }, + card.dueComplete ? "Due marked incomplete" : "Due complete", + ), + { icon: card.dueComplete ? Icons.square : Icons.squareCheck, shortcut: "d", section: "Card" }, + )] + : []), + action("New Card in This List…", () => cardForm(a, { idList: card.idList, listName: where.list }), { + icon: Icons.plus, + shortcut: "n", + section: "Card", + }), + copy(card.shortUrl ?? card.url, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${card.name}](${card.shortUrl ?? card.url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(card.name, "Copy Title", { shortcut: "period", section: "Copy" }), + action("Archive Card", () => updateCard(a, card, { closed: true }, "Card archived"), { + icon: Icons.archive, + destructive: true, + confirm: `Archive “${card.name}”?`, + shortcut: "Delete", + section: "Danger", + }), + ]; +} + +export function cardItem( + a: Account, + card: Card, + where: { board?: string; list?: string; section?: string }, + now = new Date(), + options: { avatars?: boolean } = {}, +): Item { + const due = dueOf(card); + const list = checklist(card); + const members = card.members ?? []; + return { + id: card.id, + title: card.name, + // In a board, sections are its lists; elsewhere, where the card is. + subtitle: where.section === where.list ? undefined : [where.board, where.list].filter(Boolean).join(" › "), + icon: cardIcon(card), + section: where.section, + keywords: [ + ...card.labels.map(labelText), + ...members.map((m) => m.fullName), + where.board ?? "", + where.list ?? "", + ].filter(Boolean), + accessories: [ + ...card.labels.slice(0, 2).map((l) => ({ tag: labelText(l), color: labelColor(l.color) })), + ...(list.total + ? [{ + icon: Icons.squareCheck, + text: `${list.done}/${list.total}`, + color: list.done === list.total ? "green" as const : undefined, + tooltip: "Checklist", + }] + : []), + ...(card.badges?.comments + ? [{ icon: Icons.comment, text: String(card.badges.comments), tooltip: "Comments" }] + : []), + ...(due ? [{ icon: Icons.clock, text: shortDue(due, now), color: dueColor(card, now), tooltip: "Due" }] : []), + ...(options.avatars === false ? [] : members).slice(0, 2).map((m: Member) => ({ + avatar: avatarOf(m), + tooltip: m.fullName, + })), + ], + detail: { + markdown: excerpt(card.desc), + metadata: cardMetadata(card, where, now), + }, + actions: cardActions(a, card, where), + }; +} + +/** The cards of a board, by list, live after a move or an archive. */ +export function boardCards(a: Account, board: Board, prefs: Record): ListSpec { + let me = ""; + let total = 0; + return list({ + title: board.name, + placeholder: `Filter cards of ${board.name}…`, + filter: { options: [{ value: "all", title: "All Cards" }, { value: "mine", title: "My Cards" }] }, + empty: { title: "No card here", icon: Icons.kanban }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : `${items.length} of ${total} cards`, + actions: [ + action("New Card…", () => cardForm(a, { idBoard: board.id }), { icon: Icons.plus, shortcut: "n" }), + open(board.url, "Open Board in Trello"), + ], + items: ({ signal, filter }) => + live(() => + guarded(prefs, async () => { + const [lists, cards, self] = await Promise.all([ + trello(a, `/boards/${board.id}/lists`, { params: { filter: "open", fields: "name,pos" }, signal }), + trello(a, `/boards/${board.id}/cards/open`, { + params: { fields: CARD_FIELDS, members: true, member_fields: MEMBER_FIELDS }, + signal, + }), + me + ? Promise.resolve({ id: me }) + : trello<{ id: string }>(a, "/members/me", { params: { fields: "id" }, signal }), + ]); + me = self.id; + total = cards.length; + const names = new Map(lists.map((l) => [l.id, l.name])); + const shown = filter === "mine" ? cards.filter((c) => c.idMembers.includes(me)) : cards; + return sortByList(shown, lists).map((c) => { + const name = names.get(c.idList) ?? "Other"; + return cardItem(a, c, { board: board.name, list: name, section: name }); + }); + }), signal), + }); +} + +export function boardItem(a: Account, board: Board, prefs: Record, workspace?: string): Item { + const image = boardImage(board); + return { + id: board.id, + title: board.name, + subtitle: board.starred ? workspace : board.desc?.split("\n")[0] || undefined, + icon: { source: Icons.kanban, tint: boardColor(board) }, + section: board.starred ? "Starred" : workspace ?? "Boards", + keywords: [workspace ?? ""].filter(Boolean), + accessories: [ + ...(board.starred ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Starred" }] : []), + ...(board.dateLastActivity + ? [{ text: shortDue(new Date(board.dateLastActivity), new Date(), false), tooltip: "Last activity" }] + : []), + ], + detail: { + image, + markdown: excerpt(board.desc), + metadata: [ + ...(workspace ? [meta.text("Workspace", workspace, { icon: Icons.home })] : []), + ...(board.lists ? [meta.text("Lists", board.lists.length, { icon: Icons.layers })] : []), + ...(board.dateLastActivity + ? [meta.text("Last activity", shortDue(new Date(board.dateLastActivity), new Date(), false))] + : []), + meta.separator(), + meta.link("Link", (board.shortUrl ?? board.url).replace(/^https:\/\//, ""), board.url), + ], + }, + actions: [ + action("Show Cards", () => boardCards(a, board, prefs), { icon: Icons.layers }), + open(board.url, "Open in Trello"), + action("New Card…", () => cardForm(a, { idBoard: board.id }), { icon: Icons.plus, shortcut: "n" }), + copy(board.shortUrl ?? board.url, "Copy Link", { shortcut: "c", section: "Copy" }), + ], + }; +} + +/** Every open list of every open board, as "Board › List". */ +export async function allLists(a: Account): Promise<{ value: string; title: string; board: string }[]> { + const boards = await trello(a, "/members/me/boards", { + params: { filter: "open", fields: "name,starred", lists: "open", list_fields: "name,pos" }, + }); + const sorted = [...boards].sort((x, y) => Number(!!y.starred) - Number(!!x.starred) || x.name.localeCompare(y.name)); + return sorted.flatMap((b) => + [...(b.lists ?? [])].sort((x, y) => x.pos - y.pos).map((l) => ({ + value: l.id, + title: `${b.name} › ${l.name}`, + board: b.id, + })) + ); +} + +/** A new card; `standalone` when the form is the command itself (no list to go back to). */ +export async function cardForm( + a: Account, + where: { idList?: string; idBoard?: string; listName?: string } = {}, + options: { standalone?: boolean; title?: string } = {}, +) { + const lists = await allLists(a); + if (!lists.length) throw new Error("No open Trello board with a list: create one in Trello first"); + const last = await storage.get("lastList").catch(() => undefined); + const initial = lists.find((l) => l.value === where.idList) ?? lists.find((l) => l.board === where.idBoard) ?? + lists.find((l) => l.value === last) ?? lists[0]; + return form({ + title: "New Trello card", + submitTitle: "Create Card", + fields: [ + field.dropdown("list", "List", lists.map(({ value, title }) => ({ value, title })), { value: initial.value }), + field.text("name", "Title", { required: true, placeholder: "What needs doing?", value: options.title ?? "" }), + field.textArea("desc", "Description", { placeholder: "Markdown" }), + field.text("due", "Due", { placeholder: "tomorrow 5pm, friday, +3d, 2026-11-03…" }), + field.checkbox("top", "Put it at the top of the list", { value: true }), + field.checkbox("me", "Add me to the card", { value: true }), + ], + async submit(values) { + const when = parseWhen(String(values.due ?? "")); + const due = when ? (when.time ? when.date : new Date(when.date.setHours(12, 0, 0, 0))) : undefined; + const me = values.me ? (await trello<{ id: string }>(a, "/members/me", { params: { fields: "id" } })).id : ""; + const card = await trello(a, "/cards", { + method: "POST", + params: { + idList: String(values.list), + name: String(values.name).trim(), + desc: String(values.desc ?? "").trim() || undefined, + due: due?.toISOString(), + pos: values.top ? "top" : "bottom", + idMembers: me || undefined, + }, + }); + await storage.set("lastList", String(values.list)).catch(() => {}); + notifyChanged(); + const where = lists.find((l) => l.value === values.list)?.title ?? ""; + await toast(`Card created in ${where}`, { style: "success" }); + if (!options.standalone) return pop(); + return detail({ + title: card.name, + markdown: `# ${card.name}\n\nCreated in **${where}**.`, + metadata: [ + meta.text("List", where, { icon: Icons.layers }), + ...(due ? [meta.text("Due", shortDue(due), { icon: Icons.calendar })] : []), + meta.link("Link", card.shortUrl ?? card.url, card.shortUrl ?? card.url), + ], + status: "Card created", + actions: [ + open(card.url, "Open in Trello"), + copy(card.shortUrl ?? card.url, "Copy Link", { shortcut: "c" }), + ], + }); + }, + }); +} From e0ef085c6b1b4b9976cd36e61f6d4887bad66cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:40:10 +0000 Subject: [PATCH 06/10] Crypto, Stocks, Movies & TV, Books, Internet Radio, YouTube and Reddit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/books/api.ts | 88 ++++++ extensions/books/backslash.toml | 39 +++ extensions/books/commands/reading-list.ts | 39 +++ extensions/books/commands/search.ts | 35 +++ extensions/books/lib.ts | 279 ++++++++++++++++++ extensions/books/lib_test.ts | 155 ++++++++++ extensions/books/views.ts | 190 +++++++++++++ extensions/crypto/api.ts | 175 ++++++++++++ extensions/crypto/backslash.toml | 52 ++++ extensions/crypto/commands/coins.ts | 50 ++++ extensions/crypto/commands/portfolio.ts | 104 +++++++ extensions/crypto/lib.ts | 244 ++++++++++++++++ extensions/crypto/lib_test.ts | 185 ++++++++++++ extensions/crypto/views.ts | 261 +++++++++++++++++ extensions/movies/api.ts | 175 ++++++++++++ extensions/movies/backslash.toml | 53 ++++ extensions/movies/commands/search.ts | 55 ++++ extensions/movies/lib.ts | 328 ++++++++++++++++++++++ extensions/movies/lib_test.ts | 239 ++++++++++++++++ extensions/movies/views.ts | 165 +++++++++++ extensions/radio/api.ts | 93 ++++++ extensions/radio/backslash.toml | 60 ++++ extensions/radio/commands/stations.ts | 86 ++++++ extensions/radio/commands/stop.ts | 8 + extensions/radio/lib.ts | 199 +++++++++++++ extensions/radio/lib_test.ts | 168 +++++++++++ extensions/radio/player.ts | 126 +++++++++ extensions/radio/views.ts | 198 +++++++++++++ extensions/reddit/api.ts | 135 +++++++++ extensions/reddit/backslash.toml | 41 +++ extensions/reddit/commands/reddit.ts | 39 +++ extensions/reddit/commands/subreddits.ts | 69 +++++ extensions/reddit/lib.ts | 305 ++++++++++++++++++++ extensions/reddit/lib_test.ts | 214 ++++++++++++++ extensions/reddit/views.ts | 190 +++++++++++++ extensions/stocks/api.ts | 191 +++++++++++++ extensions/stocks/backslash.toml | 38 +++ extensions/stocks/commands/stocks.ts | 60 ++++ extensions/stocks/lib.ts | 248 ++++++++++++++++ extensions/stocks/lib_test.ts | 134 +++++++++ extensions/stocks/views.ts | 262 +++++++++++++++++ extensions/youtube/api.ts | 184 ++++++++++++ extensions/youtube/backslash.toml | 45 +++ extensions/youtube/commands/search.ts | 33 +++ extensions/youtube/lib.ts | 279 ++++++++++++++++++ extensions/youtube/lib_test.ts | 172 ++++++++++++ extensions/youtube/player.ts | 108 +++++++ extensions/youtube/views.ts | 168 +++++++++++ 48 files changed, 6764 insertions(+) create mode 100644 extensions/books/api.ts create mode 100644 extensions/books/backslash.toml create mode 100644 extensions/books/commands/reading-list.ts create mode 100644 extensions/books/commands/search.ts create mode 100644 extensions/books/lib.ts create mode 100644 extensions/books/lib_test.ts create mode 100644 extensions/books/views.ts create mode 100644 extensions/crypto/api.ts create mode 100644 extensions/crypto/backslash.toml create mode 100644 extensions/crypto/commands/coins.ts create mode 100644 extensions/crypto/commands/portfolio.ts create mode 100644 extensions/crypto/lib.ts create mode 100644 extensions/crypto/lib_test.ts create mode 100644 extensions/crypto/views.ts create mode 100644 extensions/movies/api.ts create mode 100644 extensions/movies/backslash.toml create mode 100644 extensions/movies/commands/search.ts create mode 100644 extensions/movies/lib.ts create mode 100644 extensions/movies/lib_test.ts create mode 100644 extensions/movies/views.ts create mode 100644 extensions/radio/api.ts create mode 100644 extensions/radio/backslash.toml create mode 100644 extensions/radio/commands/stations.ts create mode 100644 extensions/radio/commands/stop.ts create mode 100644 extensions/radio/lib.ts create mode 100644 extensions/radio/lib_test.ts create mode 100644 extensions/radio/player.ts create mode 100644 extensions/radio/views.ts create mode 100644 extensions/reddit/api.ts create mode 100644 extensions/reddit/backslash.toml create mode 100644 extensions/reddit/commands/reddit.ts create mode 100644 extensions/reddit/commands/subreddits.ts create mode 100644 extensions/reddit/lib.ts create mode 100644 extensions/reddit/lib_test.ts create mode 100644 extensions/reddit/views.ts create mode 100644 extensions/stocks/api.ts create mode 100644 extensions/stocks/backslash.toml create mode 100644 extensions/stocks/commands/stocks.ts create mode 100644 extensions/stocks/lib.ts create mode 100644 extensions/stocks/lib_test.ts create mode 100644 extensions/stocks/views.ts create mode 100644 extensions/youtube/api.ts create mode 100644 extensions/youtube/backslash.toml create mode 100644 extensions/youtube/commands/search.ts create mode 100644 extensions/youtube/lib.ts create mode 100644 extensions/youtube/lib_test.ts create mode 100644 extensions/youtube/player.ts create mode 100644 extensions/youtube/views.ts diff --git a/extensions/books/api.ts b/extensions/books/api.ts new file mode 100644 index 0000000..67197d9 --- /dev/null +++ b/extensions/books/api.ts @@ -0,0 +1,88 @@ +// The Open Library API (no key), and the reading list. + +import { storage } from "@backslash/api"; +import { + type Book, + fromDoc, + isbnQuery, + type Saved, + SEARCH_FIELDS, + type SearchDoc, + setStatus, + type Status, + type Work, +} from "./lib.ts"; + +const BASE = "https://openlibrary.org"; +// Open Library asks clients to identify themselves. +const HEADERS = { + Accept: "application/json", + "User-Agent": "Backslash-Books/1.0 (+https://github.com/backslash-app/plugins)", +}; + +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(path: string, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(20_000); + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + headers: HEADERS, + }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Error( + timeout.aborted + ? "Open Library is taking too long to answer" + : `Open Library is unreachable: ${(e as Error).message}`, + ); + } + if (!res.ok) { + await res.body?.cancel(); + if (res.status === 429) throw new Error("Open Library is rate limiting, try again in a moment"); + if (res.status >= 500) throw new Error(`Open Library is having trouble (${res.status}), try again later`); + throw new Error(`Open Library answered ${res.status}`); + } + return await res.json() as T; +} + +export async function searchBooks( + query: string, + signal?: AbortSignal, + limit = 25, +): Promise<{ books: Book[]; total: number }> { + const isbn = isbnQuery(query); + const params = new URLSearchParams({ fields: SEARCH_FIELDS, limit: String(limit) }); + if (isbn) params.set("isbn", isbn); + else params.set("q", query.trim()); + const res = await getJson<{ numFound?: number; docs?: SearchDoc[] }>(`/search.json?${params}`, signal); + return { books: (res.docs ?? []).filter((d) => d.key && d.title).map(fromDoc), total: res.numFound ?? 0 }; +} + +const works = new Map(); + +export async function work(key: string, signal?: AbortSignal): Promise { + const hit = works.get(key); + if (hit) return hit; + const w = await getJson(`${key}.json`, signal); + works.set(key, w); + return w; +} + +// --------------------------------------------------------------------------- +// Reading list + +export const readingList = async () => await load("reading-list") ?? []; + +export async function setBookStatus(book: Book, status: Status | undefined): Promise { + await save("reading-list", setStatus(await readingList(), book, status)); +} diff --git a/extensions/books/backslash.toml b/extensions/books/backslash.toml new file mode 100644 index 0000000..fd49839 --- /dev/null +++ b/extensions/books/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "books" +title = "Books" +description = "Search Open Library for books with covers, authors, editions and subjects, copy ISBNs and citations, and keep a reading list" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# The Open Library API, and covers.openlibrary.org where it serves covers. +[permissions] +net = ["openlibrary.org", "covers.openlibrary.org"] + +[[commands]] +name = "search" +title = "Search Books" +description = "Find a book by title, author or ISBN" +icon = "bs-book" +keywords = [ + "book", + "books", + "open library", + "isbn", + "author", + "novel", + "citation", + "livre", + "livres", + "auteur", + "roman", + "bibliothèque", +] + +[[commands]] +name = "reading-list" +title = "Reading List" +description = "Books you want to read, are reading and have read" +icon = "bs-bookmark" +keywords = ["reading list", "books", "to read", "goodreads", "liste de lecture", "à lire", "lectures", "livres"] diff --git a/extensions/books/commands/reading-list.ts b/extensions/books/commands/reading-list.ts new file mode 100644 index 0000000..6291dd2 --- /dev/null +++ b/extensions/books/commands/reading-list.ts @@ -0,0 +1,39 @@ +import { type Context, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { readingList } from "../api.ts"; +import { sortSaved, type Status, STATUSES } from "../lib.ts"; +import { bookItem } from "../views.ts"; + +let counts: Record = { want: 0, reading: 0, read: 0 }; + +export default (_ctx: Context) => + list({ + placeholder: "Filter your books by title, author or subject…", + filter: { + options: [ + { value: "all", title: "All" }, + ...(Object.keys(STATUSES) as Status[]).map((value) => ({ value, title: STATUSES[value].title })), + ], + }, + empty: { + title: "No books here", + description: "Add books from Search Books: Ctrl+S on a book picks Want to Read, Reading or Read", + icon: Icons.bookmark, + }, + status: () => [`${counts.reading} reading`, `${counts.want} to read`, `${counts.read} read`].join(" · "), + async items({ filter }) { + const saved = await readingList(); + counts = { want: 0, reading: 0, read: 0 }; + for (const s of saved) counts[s.status]++; + const current = sortSaved(saved).find((s) => s.status === "reading"); + updateCommandMetadata({ + subtitle: current ? `Reading ${current.book.title}` : counts.want ? `${counts.want} to read` : null, + }).catch(() => {}); + const shown = filter && filter in STATUSES ? saved.filter((s) => s.status === filter) : saved; + return sortSaved(shown).map((s) => + bookItem(s.book, { + status: s.status, + section: filter && filter in STATUSES ? undefined : STATUSES[s.status].title, + }) + ); + }, + }); diff --git a/extensions/books/commands/search.ts b/extensions/books/commands/search.ts new file mode 100644 index 0000000..6e94cde --- /dev/null +++ b/extensions/books/commands/search.ts @@ -0,0 +1,35 @@ +import { Icons, list } from "@backslash/api"; +import { readingList, searchBooks } from "../api.ts"; +import { sortSaved, STATUSES } from "../lib.ts"; +import { bookItem } from "../views.ts"; + +let total = 0; + +export default list({ + placeholder: "Search books by title, author or ISBN…", + empty: { + title: "Search Open Library", + description: "Type a title (dune), an author (le guin) or an ISBN (9780441172719)", + icon: Icons.book, + }, + status: (items) => + items.some((i) => !i.section) + ? `${items.length} of ${new Intl.NumberFormat("en-US").format(total)} book${ + total === 1 ? "" : "s" + } · Open Library` + : items.length + ? "Your reading list · type to search Open Library" + : "Open Library", + async search(query, { signal }) { + const saved = await readingList(); + if (!query.trim()) { + return sortSaved(saved).filter((s) => s.status !== "read").map((s) => + bookItem(s.book, { status: s.status, section: STATUSES[s.status].title }) + ); + } + const status = new Map(saved.map((s) => [s.book.key, s.status])); + const found = await searchBooks(query, signal); + total = found.total; + return found.books.map((b) => bookItem(b, { status: status.get(b.key) })); + }, +}); diff --git a/extensions/books/lib.ts b/extensions/books/lib.ts new file mode 100644 index 0000000..1f8d4d1 --- /dev/null +++ b/extensions/books/lib.ts @@ -0,0 +1,279 @@ +// Open Library answers, citations and the reading list. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +/** One document of `GET /search.json` (with the `fields` we ask for). */ +export interface SearchDoc { + key: string; + title: string; + subtitle?: string; + author_name?: string[]; + author_key?: string[]; + first_publish_year?: number; + edition_count?: number; + cover_i?: number; + isbn?: string[]; + subject?: string[]; + number_of_pages_median?: number; + ratings_average?: number; + ratings_count?: number; + publisher?: string[]; + language?: string[]; + want_to_read_count?: number; + already_read_count?: number; +} + +/** `GET /works/OL…W.json`, the fields we read. */ +export interface Work { + key: string; + title: string; + subtitle?: string; + description?: string | { type?: string; value: string }; + first_sentence?: string | { value: string }; + subjects?: string[]; + subject_places?: string[]; + subject_times?: string[]; + subject_people?: string[]; + first_publish_date?: string; + links?: { title: string; url: string }[]; + covers?: number[]; +} + +export interface Book { + /** `/works/OL45804W` */ + key: string; + title: string; + subtitle?: string; + authors: string[]; + authorKeys: string[]; + year?: number; + editions?: number; + cover?: number; + isbn?: string; + subjects: string[]; + pages?: number; + rating?: number; + ratings?: number; + publisher?: string; + languages: string[]; + wantToRead?: number; + alreadyRead?: number; +} + +export type Status = "want" | "reading" | "read"; + +export const STATUSES: Record = { + want: { title: "Want to Read", color: "blue", icon: "bs-bookmark" }, + reading: { title: "Reading", color: "orange", icon: "bs-book" }, + read: { title: "Read", color: "green", icon: "bs-circle-check" }, +}; + +export interface Saved { + book: Book; + status: Status; + added: number; + updated: number; +} + +// --------------------------------------------------------------------------- +// Mapping + +export const SEARCH_FIELDS = [ + "key", + "title", + "subtitle", + "author_name", + "author_key", + "first_publish_year", + "edition_count", + "cover_i", + "isbn", + "subject", + "number_of_pages_median", + "ratings_average", + "ratings_count", + "publisher", + "language", + "want_to_read_count", + "already_read_count", +].join(","); + +/** ISBN-13 first (the one shops and libraries use), then ISBN-10. */ +export function bestIsbn(isbns: string[] | undefined): string | undefined { + const clean = (isbns ?? []).map((i) => i.replace(/[^\dX]/gi, "").toUpperCase()); + return clean.find((i) => i.length === 13 && /^97[89]/.test(i)) ?? clean.find((i) => i.length === 10); +} + +/** Subjects worth showing: no "Fiction, general"-style catalog noise, no duplicates, short. */ +export function cleanSubjects(subjects: string[] | undefined, limit = 8): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of subjects ?? []) { + const s = raw.trim(); + const k = s.toLowerCase(); + if (!s || s.length > 32 || seen.has(k)) continue; + if (/\b(general|accessible book|protected daisy|in library|nyt:|award|lending library|large type)\b/i.test(s)) { + continue; + } + if (/[=:]/.test(s)) continue; + seen.add(k); + out.push(s); + if (out.length === limit) break; + } + return out; +} + +export function fromDoc(d: SearchDoc): Book { + return { + key: d.key, + title: d.title, + subtitle: d.subtitle, + authors: d.author_name ?? [], + authorKeys: d.author_key ?? [], + year: d.first_publish_year, + editions: d.edition_count, + cover: d.cover_i, + isbn: bestIsbn(d.isbn), + subjects: cleanSubjects(d.subject), + pages: d.number_of_pages_median, + rating: d.ratings_count ? d.ratings_average : undefined, + ratings: d.ratings_count, + publisher: d.publisher?.[0], + languages: d.language ?? [], + wantToRead: d.want_to_read_count, + alreadyRead: d.already_read_count, + }; +} + +export const coverUrl = (id: number | undefined, size: "S" | "M" | "L") => + id && id > 0 ? `https://covers.openlibrary.org/b/id/${id}-${size}.jpg` : undefined; + +export const workUrl = (key: string) => `https://openlibrary.org${key}`; +export const authorUrl = (key: string) => `https://openlibrary.org/authors/${key}`; + +/** Text of a work's description, without Open Library's trailing source notes and link refs. */ +export function description(value: Work["description"] | Work["first_sentence"]): string | undefined { + const text = typeof value === "string" ? value : value?.value; + if (!text) return undefined; + return text + .replace(/\r\n/g, "\n") + .split(/\n-{3,}\n/)[0] + .replace(/^\[\d+\]:\s*\S+\s*$/gm, "") + .replace(/\(\[source\]\[\d+\]\)/gi, "") + .replace(/\[([^\]]+)\]\[\d+\]/g, "$1") + .replace(/\n{3,}/g, "\n\n") + .trim() || undefined; +} + +/** A query that is an ISBN (with or without dashes) → the ISBN. */ +export function isbnQuery(query: string): string | undefined { + const digits = query.replace(/[\s-]/g, "").toUpperCase(); + return /^(97[89]\d{10}|\d{9}[\dX])$/.test(digits) ? digits : undefined; +} + +// --------------------------------------------------------------------------- +// Citations + +const PARTICLES = new Set([ + "de", + "da", + "di", + "du", + "van", + "von", + "der", + "den", + "le", + "la", + "del", + "della", + "st.", + "ter", +]); + +/** "J. R. R. Tolkien" → "Tolkien, J. R. R."; "Ursula K. Le Guin" → "Le Guin, U. K.". */ +export function apaName(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) return name.trim(); + let i = parts.length - 1; + while (i > 1 && PARTICLES.has(parts[i - 1].toLowerCase())) i--; + const last = parts.slice(i).join(" "); + const initials = parts.slice(0, i).map((p) => + p.split("-").map((x) => `${x.replace(/\./g, "").charAt(0).toUpperCase()}.`).join("-") + ); + return `${last}, ${initials.join(" ")}`; +} + +/** APA 7: `Herbert, F. (1965). Dune. Chilton Books.` */ +export function citation(b: Pick): string { + const names = b.authors.slice(0, 20).map(apaName); + const authors = names.length === 0 + ? "" + : names.length === 1 + ? names[0] + : `${names.slice(0, -1).join(", ")}, & ${names[names.length - 1]}`; + const title = b.subtitle ? `${b.title}: ${b.subtitle}` : b.title; + const parts = [ + authors ? `${authors}${authors.endsWith(".") ? "" : "."}` : "", + `(${b.year ?? "n.d."}).`, + `${title}${/[.?!]$/.test(title) ? "" : "."}`, + b.publisher ? `${b.publisher}.` : "", + ]; + return parts.filter(Boolean).join(" "); +} + +// --------------------------------------------------------------------------- +// Formatting + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +/** "Frank Herbert", "Terry Pratchett & Neil Gaiman", "A, B & 3 others". */ +export function byline(authors: string[]): string { + if (!authors.length) return "Unknown author"; + if (authors.length <= 2) return authors.join(" & "); + return `${authors[0]}, ${authors[1]} & ${authors.length - 2} other${authors.length === 3 ? "" : "s"}`; +} + +const LANGS: Record = { + eng: "English", + fre: "French", + ger: "German", + spa: "Spanish", + ita: "Italian", + por: "Portuguese", + rus: "Russian", + jpn: "Japanese", + chi: "Chinese", + dut: "Dutch", + pol: "Polish", + swe: "Swedish", + ara: "Arabic", + kor: "Korean", + tur: "Turkish", + gre: "Greek", + heb: "Hebrew", + cze: "Czech", + dan: "Danish", + fin: "Finnish", + nor: "Norwegian", + hun: "Hungarian", +}; + +export const languageNames = (codes: string[]) => codes.map((c) => LANGS[c] ?? c); + +/** Reading list order: reading first, then want to read, then read; recent first. */ +export function sortSaved(list: Saved[]): Saved[] { + const order: Record = { reading: 0, want: 1, read: 2 }; + return [...list].sort((a, b) => order[a.status] - order[b.status] || b.updated - a.updated); +} + +export function setStatus(list: Saved[], book: Book, status: Status | undefined, now = Date.now()): Saved[] { + const rest = list.filter((s) => s.book.key !== book.key); + if (!status) return rest; + const prev = list.find((s) => s.book.key === book.key); + return [{ book, status, added: prev?.added ?? now, updated: now }, ...rest]; +} diff --git a/extensions/books/lib_test.ts b/extensions/books/lib_test.ts new file mode 100644 index 0000000..e5cabd9 --- /dev/null +++ b/extensions/books/lib_test.ts @@ -0,0 +1,155 @@ +import { assertEquals } from "@std/assert"; +import { + apaName, + bestIsbn, + type Book, + byline, + citation, + cleanSubjects, + coverUrl, + description, + fromDoc, + isbnQuery, + languageNames, + type Saved, + type SearchDoc, + setStatus, + sortSaved, +} from "./lib.ts"; +import { bookItem } from "./views.ts"; + +// Shaped like `GET /search.json?q=dune&fields=…`. +const dune: SearchDoc = { + key: "/works/OL893415W", + title: "Dune", + author_name: ["Frank Herbert"], + author_key: ["OL79034A"], + first_publish_year: 1965, + edition_count: 120, + cover_i: 11481354, + isbn: ["0441172717", "9780441172719", "9780340960196"], + subject: ["Science Fiction", "Fiction, general", "Dune (Imaginary place)", "Science fiction", "nyt:paperback=2021"], + number_of_pages_median: 604, + ratings_average: 4.27, + ratings_count: 512, + publisher: ["Chilton Books", "Ace"], + language: ["eng", "fre"], + want_to_read_count: 2300, + already_read_count: 650, +}; + +Deno.test("search documents", () => { + const b = fromDoc(dune); + assertEquals(b.isbn, "9780441172719"); + assertEquals(b.subjects, ["Science Fiction", "Dune (Imaginary place)"]); + assertEquals(b.rating, 4.27); + assertEquals(b.publisher, "Chilton Books"); + const bare = fromDoc({ key: "/works/OL1W", title: "Untitled", ratings_average: 3, ratings_count: 0 }); + assertEquals(bare.authors, []); + assertEquals(bare.rating, undefined); + assertEquals(bare.isbn, undefined); +}); + +Deno.test("ISBNs", () => { + assertEquals(bestIsbn(["0441172717"]), "0441172717"); + assertEquals(bestIsbn(["978-0-441-17271-9", "0441172717"]), "9780441172719"); + assertEquals(bestIsbn(["080442957x"]), "080442957X"); + assertEquals(bestIsbn([]), undefined); + assertEquals(isbnQuery("978-0441172719"), "9780441172719"); + assertEquals(isbnQuery("0 441 17271 7"), "0441172717"); + assertEquals(isbnQuery("dune 1965"), undefined); + assertEquals(isbnQuery("1234567890123"), undefined); +}); + +Deno.test("descriptions", () => { + assertEquals( + description({ + type: "/type/text", + value: "Set on the desert planet Arrakis.\r\n\r\n----------\r\nContains:\r\n[1]: https://example.org", + }), + "Set on the desert planet Arrakis.", + ); + assertEquals( + description("A tale ([source][1])\n\nSee [the wiki][2].\n\n[1]: https://a.org\n[2]: https://b.org"), + "A tale \n\nSee the wiki.", + ); + assertEquals(description(undefined), undefined); + assertEquals(description({ value: " " }), undefined); +}); + +Deno.test("subjects", () => { + assertEquals( + cleanSubjects([ + "Fantasy", + "fantasy", + "Accessible book", + "Protected DAISY", + "Fiction, fantasy, general", + "Large type books", + "A subject that is much too long to show as a tag", + "Magic", + ]), + ["Fantasy", "Magic"], + ); +}); + +Deno.test("APA citations", () => { + assertEquals(apaName("Frank Herbert"), "Herbert, F."); + assertEquals(apaName("J. R. R. Tolkien"), "Tolkien, J. R. R."); + assertEquals(apaName("Ursula K. Le Guin"), "Le Guin, U. K."); + assertEquals(apaName("Jean-Paul Sartre"), "Sartre, J.-P."); + assertEquals(apaName("Ludwig van Beethoven"), "van Beethoven, L."); + assertEquals(apaName("Homer"), "Homer"); + assertEquals(citation(fromDoc(dune)), "Herbert, F. (1965). Dune. Chilton Books."); + assertEquals( + citation({ authors: ["Terry Pratchett", "Neil Gaiman"], year: 1990, title: "Good Omens", subtitle: "The Nice" }), + "Pratchett, T., & Gaiman, N. (1990). Good Omens: The Nice.", + ); + assertEquals( + citation({ authors: ["A B", "C D", "E F"], title: "What?", publisher: "Pub" }), + "B, A., D, C., & F, E. (n.d.). What? Pub.", + ); + assertEquals(citation({ authors: [], year: 2001, title: "Anonymous" }), "(2001). Anonymous."); +}); + +Deno.test("formatting", () => { + assertEquals(byline([]), "Unknown author"); + assertEquals(byline(["A", "B"]), "A & B"); + assertEquals(byline(["A", "B", "C"]), "A, B & 1 other"); + assertEquals(byline(["A", "B", "C", "D"]), "A, B & 2 others"); + assertEquals(languageNames(["eng", "fre", "xyz"]), ["English", "French", "xyz"]); + assertEquals(coverUrl(11481354, "L"), "https://covers.openlibrary.org/b/id/11481354-L.jpg"); + assertEquals(coverUrl(-1, "S"), undefined); + assertEquals(coverUrl(undefined, "S"), undefined); +}); + +Deno.test("reading list", () => { + const a: Book = fromDoc(dune); + const b: Book = fromDoc({ key: "/works/OL2W", title: "Hyperion" }); + const c: Book = fromDoc({ key: "/works/OL3W", title: "Solaris" }); + let list: Saved[] = []; + list = setStatus(list, a, "want", 1); + list = setStatus(list, b, "read", 2); + list = setStatus(list, c, "want", 3); + list = setStatus(list, a, "reading", 4); + assertEquals(list.find((s) => s.book.key === a.key)?.added, 1); + assertEquals(sortSaved(list).map((s) => s.book.title), ["Dune", "Solaris", "Hyperion"]); + assertEquals(setStatus(list, b, undefined).length, 2); +}); + +Deno.test("book item", () => { + const item = bookItem(fromDoc(dune), { status: "reading" }); + assertEquals(item.icon, "https://covers.openlibrary.org/b/id/11481354-S.jpg"); + assertEquals(item.detail?.image, "https://covers.openlibrary.org/b/id/11481354-L.jpg"); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text), ["Reading", "1965"]); + assertEquals(item.detail?.stats?.map((s) => s.value), [1965, 120, "★ 4.3"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), [ + "Show Description", + "Open on Open Library", + "Reading List: Reading", + ]); + const cite = item.actions?.find((a) => a.title === "Copy Citation (APA)"); + assertEquals(cite && "text" in cite ? cite.text : undefined, "Herbert, F. (1965). Dune. Chilton Books."); + const inSection = bookItem(fromDoc(dune), { status: "reading", section: "Reading" }); + assertEquals(inSection.accessories?.map((a) => a.text), ["1965"]); +}); diff --git a/extensions/books/views.ts b/extensions/books/views.ts new file mode 100644 index 0000000..24bbfcd --- /dev/null +++ b/extensions/books/views.ts @@ -0,0 +1,190 @@ +// Books as launcher items, the work page and the reading list actions. + +import { + type Accessory, + type Action, + action, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + type Outcome, + refresh, + replace, + type Stat, + submenu, + toast, +} from "@backslash/api"; +import { setBookStatus, work } from "./api.ts"; +import { + authorUrl, + type Book, + byline, + citation, + cleanSubjects, + compact, + coverUrl, + description, + languageNames, + type Status, + STATUSES, + workUrl, +} from "./lib.ts"; + +/** What to show after a status change: the list reloads, a book page is redrawn with the new status. */ +type After = (status: Status | undefined, ctx: Context) => Awaitable; +type Awaitable = T | Promise; + +const reload: After = () => refresh(); + +function statusActions(book: Book, current: Status | undefined, after: After): Action[] { + const set = (status: Status | undefined) => async (ctx: Context) => { + await setBookStatus(book, status); + await toast(status ? `${book.title}: ${STATUSES[status].title}` : `${book.title} removed from the reading list`, { + style: "success", + }); + return after(status, ctx); + }; + return [ + ...(Object.keys(STATUSES) as Status[]).filter((s) => s !== current).map((s) => + action(STATUSES[s].title, set(s), { icon: STATUSES[s].icon }) + ), + ...(current ? [action("Remove from Reading List", set(undefined), { icon: Icons.trash, destructive: true })] : []), + ]; +} + +function bookStats(b: Book): Stat[] { + const stats: Stat[] = []; + if (b.year) stats.push({ label: "First published", value: b.year }); + if (b.editions) stats.push({ label: "Editions", value: b.editions }); + if (b.rating !== undefined) stats.push({ label: "Rating", value: `★ ${b.rating.toFixed(1)}` }); + else if (b.pages) stats.push({ label: "Pages", value: b.pages }); + return stats; +} + +function bookMetadata(b: Book, status: Status | undefined): Metadata[] { + const lines: Metadata[] = []; + if (status) lines.push(meta.tags("Reading list", [{ text: STATUSES[status].title, color: STATUSES[status].color }])); + b.authors.slice(0, 4).forEach((name, i) => lines.push(meta.person(i ? "" : "By", name))); + if (b.subjects.length) lines.push(meta.tags("Subjects", b.subjects.slice(0, 6))); + if (b.pages) lines.push(meta.text("Pages", b.pages, { icon: Icons.file })); + if (b.publisher) lines.push(meta.text("Publisher", b.publisher)); + if (b.languages.length) lines.push(meta.text("Languages", languageNames(b.languages).slice(0, 5).join(", "))); + if (b.isbn) lines.push(meta.text("ISBN", b.isbn, { icon: Icons.hash })); + if (b.rating !== undefined && b.ratings) { + lines.push(meta.progress("Rating", b.rating / 5, `${b.rating.toFixed(2)} / 5 · ${compact(b.ratings)} ratings`)); + } + if (b.wantToRead || b.alreadyRead) { + lines.push( + meta.text( + "Readers", + [ + b.alreadyRead ? `${compact(b.alreadyRead)} read` : "", + b.wantToRead ? `${compact(b.wantToRead)} want to` : "", + ].filter(Boolean).join(" · "), + { icon: Icons.person }, + ), + ); + } + lines.push(meta.separator(), meta.link("Open Library", `openlibrary.org${b.key}`, workUrl(b.key))); + return lines; +} + +function bookActions(b: Book, status: Status | undefined, withDetails: boolean, after: After = reload): Action[] { + return [ + ...(withDetails + ? [action("Show Description", (ctx) => bookView(b, status, ctx.signal), { icon: Icons.book })] + : []), + open(workUrl(b.key), "Open on Open Library"), + submenu( + status ? `Reading List: ${STATUSES[status].title}` : "Add to Reading List", + statusActions(b, status, after), + { + icon: Icons.bookmark, + shortcut: "s", + }, + ), + ...(b.authorKeys[0] + ? [open(authorUrl(b.authorKeys[0]), `Open ${b.authors[0] ?? "Author"} on Open Library`, { + icon: Icons.person, + section: "Open", + })] + : []), + ...(b.isbn ? [copy(b.isbn, "Copy ISBN", { section: "Copy", shortcut: "i" })] : []), + copy(citation(b), "Copy Citation (APA)", { section: "Copy", shortcut: "c" }), + copy(workUrl(b.key), "Copy Open Library Link", { section: "Copy" }), + copy(`${b.title} by ${byline(b.authors)}`, "Copy Title and Author", { section: "Copy" }), + ]; +} + +export function bookItem(b: Book, o: { status?: Status; section?: string } = {}): Item { + const accessories: Accessory[] = []; + if (o.status && !o.section?.startsWith(STATUSES[o.status].title)) { + accessories.push({ + tag: STATUSES[o.status].title, + color: STATUSES[o.status].color, + tooltip: "In your reading list", + }); + } + if (b.year) accessories.push({ text: String(b.year), tooltip: "First published" }); + return { + id: `${o.section ?? ""}:${b.key}`, + title: b.title, + subtitle: byline(b.authors), + section: o.section, + icon: coverUrl(b.cover, "S") ?? { source: Icons.book, tint: "orange" }, + keywords: [...b.authors, ...(b.isbn ? [b.isbn] : []), ...b.subjects.slice(0, 4)], + accessories, + detail: { + image: coverUrl(b.cover, "L"), + stats: bookStats(b), + markdown: `**${b.title}**${b.subtitle ? `\n\n*${b.subtitle}*` : ""}`, + metadata: bookMetadata(b, o.status), + }, + actions: bookActions(b, o.status, true), + }; +} + +/** The work's description, subjects, places and times. */ +export async function bookView(b: Book, status: Status | undefined, signal: AbortSignal): Promise { + const w = await work(b.key, signal); + const text = description(w.description); + const first = description(w.first_sentence); + const md = [ + `# ${w.title || b.title}`, + ...(w.subtitle || b.subtitle ? [`*${w.subtitle || b.subtitle}*`] : []), + "", + `by **${byline(b.authors)}**${b.year ? ` · ${b.year}` : ""}`, + "", + text ?? (first ? `> ${first}` : "Open Library has no description of this book yet."), + ...(w.links?.length ? ["", "## Links", "", ...w.links.slice(0, 6).map((l) => `- [${l.title}](${l.url})`)] : []), + ]; + const metadata: Metadata[] = [...bookMetadata(b, status)]; + const subjects = cleanSubjects(w.subjects, 10); + const extra: Metadata[] = []; + if (subjects.length) extra.push(meta.tags("Subjects", subjects)); + const places = cleanSubjects(w.subject_places, 5); + if (places.length) extra.push(meta.tags("Places", places.map((text) => ({ text, color: "teal" as const })))); + const times = cleanSubjects(w.subject_times, 4); + if (times.length) extra.push(meta.tags("Times", times.map((text) => ({ text, color: "purple" as const })))); + const people = cleanSubjects(w.subject_people, 5); + if (people.length) extra.push(meta.tags("Characters", people.map((text) => ({ text, color: "pink" as const })))); + return detail({ + title: b.title, + markdown: md.join("\n"), + stats: bookStats(b), + metadata: [...metadata.filter((m) => m.label !== "Subjects"), meta.separator(), ...extra], + status: `${b.editions ? `${b.editions} edition${b.editions === 1 ? "" : "s"} · ` : ""}Open Library`, + actions: bookActions( + b, + status, + false, + async (next, ctx) => replace(await bookView(b, next, ctx.signal)), + ), + }); +} diff --git a/extensions/crypto/api.ts b/extensions/crypto/api.ts new file mode 100644 index 0000000..05d46e0 --- /dev/null +++ b/extensions/crypto/api.ts @@ -0,0 +1,175 @@ +// The CoinGecko public API (no key needed, ~10-30 calls a minute), with a +// cache so that typing and reopening don't burn the rate limit. + +import { storage } from "@backslash/api"; +import type { Holding, Market, SearchCoin } from "./lib.ts"; + +const API = "https://api.coingecko.com/api/v3"; + +type Json = Parameters[1]; + +export class RateLimited extends Error {} + +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(() => {}); +} + +interface Cached { + at: number; + data: T; +} + +const memory = new Map>(); + +/** Set when the last answer came from the cache because CoinGecko refused or was unreachable. */ +export let stale: { at: number; reason: string } | undefined; + +async function request(path: string, apiKey: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + signal, + headers: { Accept: "application/json", ...(apiKey ? { "x-cg-demo-api-key": apiKey } : {}) }, + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`CoinGecko is unreachable: ${(e as Error).message}`); + } + if (res.status === 429) { + await res.body?.cancel(); + const wait = Number(res.headers.get("retry-after")); + throw new RateLimited( + `CoinGecko's rate limit is reached${wait ? `, try again in ${wait} s` : ", try again in a minute"}`, + ); + } + if (res.status === 401 || res.status === 403) { + await res.body?.cancel(); + throw new Error(apiKey ? "CoinGecko rejected the Demo API key" : `CoinGecko refused the request (${res.status})`); + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`CoinGecko answered ${res.status}`); + } + return await res.json() as T; +} + +/** + * A GET answer, fresh for `ttl` seconds. When CoinGecko refuses (rate limit) or + * can't be reached, the last answer is used, however old, and `stale` says so. + */ +export async function cached(path: string, ttl: number, apiKey: string, signal?: AbortSignal): Promise { + const key = `cache:${path}`; + const hit = (memory.get(key) as Cached | undefined) ?? await load>(key); + if (hit && Date.now() - hit.at < ttl * 1000) { + memory.set(key, hit); + return hit.data; + } + try { + const data = await request(path, apiKey, signal); + const entry = { at: Date.now(), data }; + memory.set(key, entry); + await save(key, entry); + stale = undefined; + return data; + } catch (e) { + if ((e as Error).name === "AbortError" || !hit) throw e; + stale = { at: hit.at, reason: (e as Error).message }; + return hit.data; + } +} + +const marketsParams = (currency: string) => + `vs_currency=${currency}&sparkline=true&price_change_percentage=24h%2C7d&precision=full`; + +/** The top coins by market cap. */ +export function topMarkets(currency: string, apiKey: string, signal?: AbortSignal, count = 50): Promise { + return cached( + `/coins/markets?${marketsParams(currency)}&order=market_cap_desc&per_page=${count}&page=1`, + 90, + apiKey, + signal, + ); +} + +/** Markets of the given coins, in the given order. */ +export async function marketsOf( + ids: string[], + currency: string, + apiKey: string, + signal?: AbortSignal, +): Promise { + if (!ids.length) return []; + const sorted = [...new Set(ids)].sort(); + const found = await cached( + `/coins/markets?${marketsParams(currency)}&ids=${encodeURIComponent(sorted.join(","))}&per_page=250`, + 60, + apiKey, + signal, + ); + const byId = new Map(found.map((m) => [m.id, m])); + return ids.map((id) => byId.get(id)).filter((m): m is Market => !!m); +} + +export async function searchCoins(query: string, apiKey: string, signal?: AbortSignal): Promise { + const res = await cached<{ coins?: SearchCoin[] }>( + `/search?query=${encodeURIComponent(query.trim().toLowerCase())}`, + 3600, + apiKey, + signal, + ); + return res.coins ?? []; +} + +// --------------------------------------------------------------------------- +// Watchlist and portfolio + +/** Changes to the watchlist or portfolio, so live lists reload. */ +export const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +export const watchlist = async () => await load("watchlist") ?? []; + +export async function toggleWatch(id: string): Promise { + const list = await watchlist(); + const watched = !list.includes(id); + await save("watchlist", watched ? [...list, id] : list.filter((x) => x !== id)); + changed(); + return watched; +} + +export async function moveWatch(id: string, by: -1 | 1): Promise { + const list = await watchlist(); + const i = list.indexOf(id); + const j = i + by; + if (i < 0 || j < 0 || j >= list.length) return; + [list[i], list[j]] = [list[j], list[i]]; + await save("watchlist", list); + changed(); +} + +export const holdings = async () => await load("holdings") ?? []; + +export async function setHoldings(list: Holding[]): Promise { + await save("holdings", list); + changed(); +} + +/** Yields `load()` again each time the watchlist or portfolio changes, 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); + }); + } +} diff --git a/extensions/crypto/backslash.toml b/extensions/crypto/backslash.toml new file mode 100644 index 0000000..2510123 --- /dev/null +++ b/extensions/crypto/backslash.toml @@ -0,0 +1,52 @@ +[extension] +id = "crypto" +title = "Crypto" +description = "Cryptocurrency prices, 24 h and 7 day changes, market caps and a watchlist from CoinGecko, and the value of your holdings" +version = "1.0.0" +author = "Backslash" +icon = "bs-coin" +license = "MIT" + +# The CoinGecko public API, and the hosts its coin logos are served from +# (coin-images is the current CDN, assets the older one still in some answers). +[permissions] +net = ["api.coingecko.com", "coin-images.coingecko.com", "assets.coingecko.com"] + +[[commands]] +name = "coins" +title = "Crypto Prices" +description = "Top coins by market cap and your watchlist; type to search any coin" +icon = "bs-coin" +keywords = [ + "crypto", + "bitcoin", + "btc", + "ethereum", + "coin", + "price", + "coingecko", + "market", + "cryptomonnaie", + "cours", + "prix", + "marché", +] + +[[commands]] +name = "portfolio" +title = "Crypto Portfolio" +description = "What your coins are worth, with each holding's share and 24 h change" +icon = "bs-chart-up" +keywords = ["crypto", "portfolio", "holdings", "wallet", "balance", "portefeuille", "avoirs", "solde"] + +[[preferences]] +name = "currency" +title = "Currency" +description = "Prices are shown in: usd, eur, gbp, jpy, chf, cad, aud, cny, inr, krw, brl, btc or eth" +default = "usd" + +[[preferences]] +name = "apiKey" +title = "Demo API key (optional)" +description = "A free CoinGecko Demo key raises the rate limit: coingecko.com/en/developers/dashboard" +type = "secret" diff --git a/extensions/crypto/commands/coins.ts b/extensions/crypto/commands/coins.ts new file mode 100644 index 0000000..a19854b --- /dev/null +++ b/extensions/crypto/commands/coins.ts @@ -0,0 +1,50 @@ +import { type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { holdings, marketsOf, searchCoins, stale, topMarkets, watchlist } from "../api.ts"; +import { type Market, searchToMarket, tickerLine } from "../lib.ts"; +import { coinItem, guarded, settings } from "../views.ts"; + +const ago = (at: number) => { + const min = Math.round((Date.now() - at) / 60_000); + return min < 1 ? "just now" : min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago`; +}; + +export default (ctx: Context) => { + const s = settings(ctx.preferences); + return list({ + placeholder: "Search coins by name or symbol…", + empty: { title: "No coin found", description: "Try a name (solana) or a symbol (SOL)", icon: Icons.coin }, + status: (items) => + items[0]?.id === "rate-limit" + ? "Rate limited" + : `${items.length} coin${items.length === 1 ? "" : "s"} · ${s.currency.toUpperCase()}${ + stale ? ` · CoinGecko is rate limiting, prices from ${ago(stale.at)}` : " · CoinGecko" + }`, + search: (query, { signal }) => + guarded(async () => { + const [watched, held] = await Promise.all([watchlist(), holdings()]); + const heldById = new Map(held.map((h) => [h.id, h])); + const item = (m: Market, section?: string) => + coinItem(m, { currency: s.currency, watched: watched.includes(m.id), held: heldById.get(m.id), section }); + + if (query.trim()) { + const found = (await searchCoins(query, s.apiKey, signal)).slice(0, 20); + const priced = await marketsOf(found.map((c) => c.id), s.currency, s.apiKey, signal).catch((e) => { + if ((e as Error).name === "AbortError") throw e; + return [] as Market[]; + }); + const byId = new Map(priced.map((m) => [m.id, m])); + return found.map((c) => item(byId.get(c.id) ?? searchToMarket(c))); + } + + const [mine, top] = await Promise.all([ + marketsOf(watched, s.currency, s.apiKey, signal), + topMarkets(s.currency, s.apiKey, signal), + ]); + const first = mine[0] ?? top[0]; + if (first) updateCommandMetadata({ subtitle: tickerLine(first, s.currency) }).catch(() => {}); + const items: Item[] = mine.map((m) => item(m, "Watchlist")); + for (const m of top) if (!watched.includes(m.id)) items.push(item(m, "Top Coins")); + return items; + }), + }); +}; diff --git a/extensions/crypto/commands/portfolio.ts b/extensions/crypto/commands/portfolio.ts new file mode 100644 index 0000000..5e4a519 --- /dev/null +++ b/extensions/crypto/commands/portfolio.ts @@ -0,0 +1,104 @@ +import { + action, + type Context, + copy, + Icons, + type Item, + list, + meta, + open, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { holdings, live, marketsOf, setHoldings, stale } from "../api.ts"; +import { amount, change24h, changeColor, coinUrl, logo, money, percent, signedMoney, valuePortfolio } from "../lib.ts"; +import { coinIcon, coinMetadata, coinPicker, holdingForm, settings } from "../views.ts"; + +let summary = ""; + +export default (ctx: Context) => { + const s = settings(ctx.preferences); + const add = action("Add Holding…", () => coinPicker(s), { icon: Icons.plus, shortcut: "n" }); + return list({ + placeholder: "Filter holdings…", + empty: { + title: "No holdings yet", + description: "Press Ctrl+N to add a coin and the amount you hold. Amounts stay on this computer.", + icon: Icons.coin, + }, + actions: [add], + status: () => summary, + items: ({ signal }) => + live(async (): Promise => { + const held = await holdings(); + if (!held.length) { + summary = ""; + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return []; + } + const markets = await marketsOf(held.map((h) => h.id), s.currency, s.apiKey, signal); + const { rows, total, change } = valuePortfolio(held, markets); + const pct = total - change ? (change / (total - change)) * 100 : 0; + summary = `Total ${money(total, s.currency)} · ${signedMoney(change, s.currency)} (${percent(pct)}) in 24 h${ + stale ? " · CoinGecko is rate limiting, saved prices" : "" + }`; + updateCommandMetadata({ subtitle: `Total ${money(total, s.currency)}` }).catch(() => {}); + + return rows.map(({ holding: h, market: m, value, change: diff, share }) => { + const symbol = h.symbol.toUpperCase(); + const c24 = m ? change24h(m) : undefined; + return { + id: h.id, + title: h.name, + subtitle: `${amount(h.amount)} ${symbol}`, + icon: coinIcon({ image: m?.image ?? h.image }), + keywords: [h.symbol, h.id], + accessories: [ + { text: `${Math.round(share * 100)} %`, tooltip: "Share of the portfolio" }, + ...(m + ? [{ + text: percent(c24), + color: changeColor(c24), + icon: { source: (c24 ?? 0) >= 0 ? Icons.chartUp : Icons.chartDown, tint: changeColor(c24) }, + tooltip: "24 h change", + }] + : []), + { text: money(value, s.currency), tooltip: m ? `at ${money(m.current_price, s.currency)}` : "No price" }, + ], + detail: { + image: logo(m?.image ?? h.image, "large"), + stats: [ + { label: "Value", value: money(value, s.currency) }, + { label: "24 h", value: signedMoney(diff, s.currency), color: changeColor(diff) }, + { label: "Share", value: `${Math.round(share * 100)} %` }, + ], + metadata: [ + meta.text("Amount", `${amount(h.amount)} ${symbol}`, { icon: Icons.coin }), + ...(m ? [meta.text("Price", money(m.current_price, s.currency))] : []), + meta.progress("Of portfolio", share), + meta.separator(), + ...(m ? coinMetadata(m, s.currency) : [meta.link("CoinGecko", h.id, coinUrl(h.id))]), + ], + }, + actions: [ + action("Edit Amount…", () => holdingForm(h, h), { icon: Icons.text }), + open(coinUrl(h.id), "Open on CoinGecko"), + add, + copy(money(value, s.currency), "Copy Value", { section: "Copy", shortcut: "c" }), + copy(String(h.amount), "Copy Amount", { section: "Copy" }), + action("Remove Holding", async () => { + await setHoldings((await holdings()).filter((x) => x.id !== h.id)); + await toast(`${h.name} removed from the portfolio`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${h.name} from the portfolio?`, + section: "Danger", + shortcut: "Delete", + }), + ], + }; + }); + }, signal), + }); +}; diff --git a/extensions/crypto/lib.ts b/extensions/crypto/lib.ts new file mode 100644 index 0000000..dc7e54a --- /dev/null +++ b/extensions/crypto/lib.ts @@ -0,0 +1,244 @@ +// CoinGecko answers, money formatting and text sparklines. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +/** One coin of `GET /coins/markets`. */ +export interface Market { + id: string; + symbol: string; + name: string; + image?: string | null; + current_price: number | null; + market_cap: number | null; + market_cap_rank: number | null; + fully_diluted_valuation?: number | null; + total_volume: number | null; + high_24h?: number | null; + low_24h?: number | null; + price_change_24h?: number | null; + price_change_percentage_24h?: number | null; + price_change_percentage_24h_in_currency?: number | null; + price_change_percentage_7d_in_currency?: number | null; + circulating_supply?: number | null; + total_supply?: number | null; + max_supply?: number | null; + ath?: number | null; + ath_change_percentage?: number | null; + ath_date?: string | null; + last_updated?: string | null; + sparkline_in_7d?: { price: number[] } | null; +} + +/** One coin of `GET /search`. */ +export interface SearchCoin { + id: string; + name: string; + symbol: string; + market_cap_rank: number | null; + thumb?: string; + large?: string; +} + +/** A holding of the portfolio, as stored. */ +export interface Holding { + id: string; + symbol: string; + name: string; + image?: string; + amount: number; +} + +// --------------------------------------------------------------------------- +// Currency + +export const CURRENCIES = ["usd", "eur", "gbp", "jpy", "chf", "cad", "aud", "cny", "inr", "krw", "brl", "btc", "eth"]; + +/** The `currency` preference, lowercased; anything unknown falls back to usd. */ +export function normalizeCurrency(input: unknown): string { + const c = String(input ?? "").trim().toLowerCase().replace(/[^a-z]/g, ""); + return CURRENCIES.includes(c) ? c : "usd"; +} + +const CRYPTO_UNITS: Record = { btc: "₿", eth: "Ξ" }; + +/** Enough digits for a price: 67,123.45 · 0.6123 · 0.00001234. */ +function priceDigits(value: number): Intl.NumberFormatOptions { + const abs = Math.abs(value); + if (abs === 0 || abs >= 1) return { minimumFractionDigits: 2, maximumFractionDigits: 2 }; + return { maximumSignificantDigits: 4 }; +} + +/** `$67,123.45`, `€0.6123`, `₿0.0234`. */ +export function money(value: number | null | undefined, currency: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const unit = CRYPTO_UNITS[currency]; + if (unit) return `${unit}${new Intl.NumberFormat("en-US", priceDigits(value)).format(value)}`; + return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase(), ...priceDigits(value) }) + .format(value); +} + +/** `$1.32T`, `€45.6B`: market caps and volumes. */ +export function compactMoney(value: number | null | undefined, currency: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const options: Intl.NumberFormatOptions = { notation: "compact", maximumFractionDigits: 2 }; + const unit = CRYPTO_UNITS[currency]; + if (unit) return `${unit}${new Intl.NumberFormat("en-US", options).format(value)}`; + return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase(), ...options }) + .format(value); +} + +/** `+$209.14`, `−€3.20`: a change of value. */ +export function signedMoney(value: number | null | undefined, currency: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${value > 0 ? "+" : value < 0 ? "−" : ""}${money(Math.abs(value), currency)}`; +} + +/** `19.7M`: supplies. */ +export const compact = (value: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(value); + +/** `+2.31 %`, `−0.84 %`. */ +export function percent(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const sign = value > 0 ? "+" : value < 0 ? "−" : ""; + return `${sign}${Math.abs(value).toFixed(2)} %`; +} + +/** Green when up, red when down. */ +export const changeColor = (value: number | null | undefined): Color | undefined => + !value ? undefined : value > 0 ? "green" : "red"; + +/** An amount of coins: `0.5`, `1,250`, `0.00012`. */ +export const amount = (value: number) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value); + +/** `"1,5"`, `"0.25 "`, `"2e-3"` → a positive number, or undefined. */ +export function parseAmount(input: string): number | undefined { + let text = input.trim().replace(/\s/g, ""); + // One comma and no dot is a decimal comma (1,5); otherwise commas group thousands (1,250,000.5). + text = /^[^,.]*,[^,.]*$/.test(text) ? text.replace(",", ".") : text.replace(/,/g, ""); + if (!/^(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(text)) return undefined; + const n = Number(text); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +// --------------------------------------------------------------------------- +// Sparklines + +const BARS = "▁▂▃▄▅▆▇█"; + +/** Prices → `▂▃▅▇█▆▅▃`, averaged into `width` buckets. */ +export function sparkline(prices: number[] | null | undefined, width = 28): string { + const values = (prices ?? []).filter((p) => Number.isFinite(p)); + if (values.length < 2) return ""; + const n = Math.min(width, values.length); + const buckets: number[] = []; + for (let i = 0; i < n; i++) { + const slice = values.slice(Math.floor((i * values.length) / n), Math.floor(((i + 1) * values.length) / n)); + buckets.push(slice.reduce((a, b) => a + b, 0) / slice.length); + } + const min = Math.min(...buckets); + const max = Math.max(...buckets); + if (max === min) return BARS[3].repeat(n); + return buckets.map((v) => BARS[Math.round(((v - min) / (max - min)) * (BARS.length - 1))]).join(""); +} + +/** The change over the sparkline's window, when the API didn't give one. */ +export function sparkChange(prices: number[] | null | undefined): number | undefined { + const values = (prices ?? []).filter((p) => Number.isFinite(p)); + if (values.length < 2 || !values[0]) return undefined; + return ((values[values.length - 1] - values[0]) / values[0]) * 100; +} + +// --------------------------------------------------------------------------- +// Coins + +export const change24h = (m: Market) => m.price_change_percentage_24h_in_currency ?? m.price_change_percentage_24h; +export const change7d = (m: Market) => + m.price_change_percentage_7d_in_currency ?? sparkChange(m.sparkline_in_7d?.price); + +export const coinUrl = (id: string) => `https://www.coingecko.com/en/coins/${encodeURIComponent(id)}`; + +/** The coin's logo in a size, from any of CoinGecko's sizes (`thumb`, `small`, `large`). */ +export function logo(url: string | null | undefined, size: "thumb" | "small" | "large"): string | undefined { + if (!url || !/^https:\/\//.test(url) || url.includes("missing_")) return undefined; + return url.replace(/\/(thumb|small|large)\//, `/${size}/`); +} + +/** "Price · ▲ 2.31 %" for the root search subtitle. */ +export function tickerLine(m: Market, currency: string): string { + const c = change24h(m); + const arrow = c === null || c === undefined ? "" : c >= 0 ? " ▲" : " ▼"; + return `${m.symbol.toUpperCase()} ${money(m.current_price, currency)}${arrow}${ + c === null || c === undefined ? "" : ` ${Math.abs(c).toFixed(1)}%` + }`; +} + +/** "18 Mar 2024", or empty. */ +export function shortDate(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +// --------------------------------------------------------------------------- +// Portfolio + +export interface Valued { + holding: Holding; + market?: Market; + value?: number; + /** Change of the holding's value over 24 h, in the currency. */ + change?: number; + share: number; +} + +/** Each holding with its value and share of the total, biggest first. */ +export function valuePortfolio( + holdings: Holding[], + markets: Market[], +): { rows: Valued[]; total: number; change: number } { + const byId = new Map(markets.map((m) => [m.id, m])); + const rows: Valued[] = holdings.map((holding) => { + const market = byId.get(holding.id); + const price = market?.current_price ?? undefined; + const value = price === undefined ? undefined : price * holding.amount; + const pct = market ? change24h(market) : undefined; + const change = value === undefined || pct === null || pct === undefined + ? undefined + : value - value / (1 + pct / 100); + return { holding, market, value, change, share: 0 }; + }); + const total = rows.reduce((sum, r) => sum + (r.value ?? 0), 0); + const change = rows.reduce((sum, r) => sum + (r.change ?? 0), 0); + for (const r of rows) r.share = total > 0 && r.value ? r.value / total : 0; + rows.sort((a, b) => (b.value ?? -1) - (a.value ?? -1)); + return { rows, total, change }; +} + +/** Adds to an existing holding of the same coin, or appends it. */ +export function upsertHolding(holdings: Holding[], h: Holding, mode: "set" | "add" = "set"): Holding[] { + const existing = holdings.find((x) => x.id === h.id); + if (!existing) return [...holdings, h]; + return holdings.map((x) => + x.id === h.id ? { ...x, ...h, amount: mode === "add" ? x.amount + h.amount : h.amount } : x + ); +} + +/** A market for a coin found by search, before its price is known. */ +export function searchToMarket(c: SearchCoin): Market { + return { + id: c.id, + symbol: c.symbol, + name: c.name, + image: c.large ?? c.thumb, + current_price: null, + market_cap: null, + market_cap_rank: c.market_cap_rank, + total_volume: null, + }; +} diff --git a/extensions/crypto/lib_test.ts b/extensions/crypto/lib_test.ts new file mode 100644 index 0000000..387082d --- /dev/null +++ b/extensions/crypto/lib_test.ts @@ -0,0 +1,185 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + amount, + change7d, + changeColor, + compactMoney, + type Holding, + logo, + type Market, + money, + normalizeCurrency, + parseAmount, + percent, + searchToMarket, + signedMoney, + sparkChange, + sparkline, + tickerLine, + upsertHolding, + valuePortfolio, +} from "./lib.ts"; +import { coinItem, coinMetadata } from "./views.ts"; + +// Shaped like `GET /coins/markets?vs_currency=usd&sparkline=true&price_change_percentage=24h,7d`. +const bitcoin: Market = { + id: "bitcoin", + symbol: "btc", + name: "Bitcoin", + image: "https://coin-images.coingecko.com/coins/images/1/large/bitcoin.png?1696501400", + current_price: 67123.45, + market_cap: 1323456789012, + market_cap_rank: 1, + fully_diluted_valuation: 1409876543210, + total_volume: 28123456789, + high_24h: 67890, + low_24h: 65432.1, + price_change_24h: 1512.3, + price_change_percentage_24h: 2.305, + price_change_percentage_24h_in_currency: 2.305, + price_change_percentage_7d_in_currency: -1.42, + circulating_supply: 19765431, + total_supply: 21000000, + max_supply: 21000000, + ath: 73738, + ath_change_percentage: -8.97, + ath_date: "2024-03-14T07:10:36.635Z", + last_updated: "2026-10-28T09:58:12.123Z", + sparkline_in_7d: { price: [68000, 67500, 66000, 65000, 65500, 66500, 67000, 67123] }, +}; + +const ethereum: Market = { + ...bitcoin, + id: "ethereum", + symbol: "eth", + name: "Ethereum", + image: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + current_price: 2500, + market_cap_rank: 2, + price_change_percentage_24h_in_currency: -4, + price_change_percentage_7d_in_currency: null, + max_supply: null, + total_supply: 120000000, + sparkline_in_7d: { price: [2400, 2450, 2500] }, +}; + +Deno.test("currency preference", () => { + assertEquals(normalizeCurrency("EUR"), "eur"); + assertEquals(normalizeCurrency(" usd "), "usd"); + assertEquals(normalizeCurrency("dogecoin"), "usd"); + assertEquals(normalizeCurrency(undefined), "usd"); +}); + +Deno.test("money", () => { + assertEquals(money(67123.45, "usd"), "$67,123.45"); + assertEquals(money(0.61234567, "usd"), "$0.6123"); + assertEquals(money(0.0000123456, "eur"), "€0.00001235"); + assertEquals(money(0.0234, "btc"), "₿0.0234"); + assertEquals(money(1234, "jpy"), "¥1,234.00"); + assertEquals(money(null, "usd"), "—"); + assertEquals(compactMoney(1323456789012, "usd"), "$1.32T"); + assertEquals(compactMoney(45600000000, "eur"), "€45.6B"); + assertEquals(compactMoney(undefined, "usd"), "—"); + assertEquals(signedMoney(209.14, "usd"), "+$209.14"); + assertEquals(signedMoney(-3.2, "eur"), "−€3.20"); +}); + +Deno.test("percent and colors", () => { + assertEquals(percent(2.305), "+2.31 %"); + assertEquals(percent(-0.844), "−0.84 %"); + assertEquals(percent(0), "0.00 %"); + assertEquals(percent(null), "—"); + assertEquals(changeColor(1), "green"); + assertEquals(changeColor(-1), "red"); + assertEquals(changeColor(0), undefined); +}); + +Deno.test("amounts typed by the user", () => { + assertEquals(parseAmount("0.25"), 0.25); + assertEquals(parseAmount("1,5"), 1.5); + assertEquals(parseAmount("1,250"), 1.25); + assertEquals(parseAmount("1,250,000"), 1250000); + assertEquals(parseAmount(" 3 "), 3); + assertEquals(parseAmount("2e-3"), 0.002); + assertEquals(parseAmount("-1"), undefined); + assertEquals(parseAmount("0"), undefined); + assertEquals(parseAmount("abc"), undefined); + assertEquals(amount(0.00012345), "0.00012345"); + assertEquals(amount(1250), "1,250"); +}); + +Deno.test("sparklines", () => { + assertEquals(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), "▁▂▃▄▅▆▇█"); + assertEquals(sparkline([5, 5, 5]), "▄▄▄"); + assertEquals(sparkline([1]), ""); + assertEquals(sparkline(undefined), ""); + const week = Array.from({ length: 168 }, (_, i) => 100 + Math.sin(i / 10) * 5); + assertEquals([...sparkline(week)].length, 28); + assertEquals(sparkChange([100, 110]), 10); + assertEquals(change7d(ethereum)?.toFixed(2), "4.17"); + assertEquals(change7d(bitcoin), -1.42); +}); + +Deno.test("logos in any size", () => { + assertEquals( + logo(bitcoin.image, "small"), + "https://coin-images.coingecko.com/coins/images/1/small/bitcoin.png?1696501400", + ); + assertEquals( + logo("https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png", "large"), + "https://assets.coingecko.com/coins/images/1/large/bitcoin.png", + ); + assertEquals(logo("missing_large.png", "large"), undefined); + assertEquals(logo(null, "large"), undefined); +}); + +Deno.test("ticker line", () => { + assertEquals(tickerLine(bitcoin, "usd"), "BTC $67,123.45 ▲ 2.3%"); + assertEquals(tickerLine(ethereum, "eur"), "ETH €2,500.00 ▼ 4.0%"); +}); + +Deno.test("portfolio values", () => { + const held: Holding[] = [ + { id: "ethereum", symbol: "eth", name: "Ethereum", amount: 2 }, + { id: "bitcoin", symbol: "btc", name: "Bitcoin", amount: 0.1 }, + { id: "gone-coin", symbol: "gone", name: "Gone", amount: 10 }, + ]; + const { rows, total, change } = valuePortfolio(held, [bitcoin, ethereum]); + assertEquals(rows.map((r) => r.holding.id), ["bitcoin", "ethereum", "gone-coin"]); + assertEquals(Math.round(total * 100) / 100, 11712.35); + assertEquals(Math.round(rows[0].share * 1000) / 1000, 0.573); + assertEquals(rows[2].value, undefined); + assertEquals(rows[2].share, 0); + // +2.305 % on 6712.35, −4 % on 5000. + assertEquals(Math.round(change), Math.round(6712.345 - 6712.345 / 1.02305 + 5000 - 5000 / 0.96)); +}); + +Deno.test("adding to a holding", () => { + const h: Holding = { id: "bitcoin", symbol: "btc", name: "Bitcoin", amount: 0.1 }; + assertEquals(upsertHolding([], h), [h]); + assertEquals(upsertHolding([h], { ...h, amount: 0.2 }, "add")[0].amount, 0.30000000000000004); + assertEquals(upsertHolding([h], { ...h, amount: 0.5 })[0].amount, 0.5); +}); + +Deno.test("coin item", () => { + const item = coinItem(bitcoin, { currency: "usd", watched: true, section: "Watchlist" }); + assertEquals(item.title, "Bitcoin"); + assertEquals(item.subtitle, "BTC"); + assertEquals(item.icon, "https://coin-images.coingecko.com/coins/images/1/small/bitcoin.png?1696501400"); + assertEquals(item.detail?.image, bitcoin.image); + assertEquals(item.detail?.stats?.[1], { label: "24 h", value: "+2.31 %", color: "green" }); + assertEquals(item.accessories?.at(-1)?.text, "$67,123.45"); + assertEquals(item.actions?.[1].title, "Remove from Watchlist"); + const lines = coinMetadata(bitcoin, "usd"); + assertEquals(lines[0].label, "7 days"); + assertEquals(lines[0].color, "red"); + assertStringIncludes(lines.find((l) => l.label === "Supply")?.value ?? "", "19.77M of 21M BTC"); + assertStringIncludes(lines.find((l) => l.label === "All-time high")?.value ?? "", "14 Mar 2024"); + + const unpriced = coinItem(searchToMarket({ id: "new-coin", name: "New", symbol: "NEW", market_cap_rank: null }), { + currency: "usd", + watched: false, + }); + assertEquals(unpriced.icon, { source: "bs-coin", tint: "yellow" }); + assertEquals(unpriced.detail?.stats, undefined); +}); diff --git a/extensions/crypto/views.ts b/extensions/crypto/views.ts new file mode 100644 index 0000000..f95fed9 --- /dev/null +++ b/extensions/crypto/views.ts @@ -0,0 +1,261 @@ +// Coins as launcher items, the holding form and the coin picker. + +import { + type Action, + action, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + pop, + refresh, + replace, + toast, +} from "@backslash/api"; +import { holdings, RateLimited, searchCoins, setHoldings, stale, toggleWatch } from "./api.ts"; +import { + amount, + change24h, + change7d, + changeColor, + coinUrl, + compact, + compactMoney, + type Holding, + logo, + type Market, + money, + normalizeCurrency, + parseAmount, + percent, + searchToMarket, + shortDate, + sparkline, + upsertHolding, +} from "./lib.ts"; + +export interface Settings { + currency: string; + apiKey: string; +} + +export const settings = (preferences: Record): Settings => ({ + currency: normalizeCurrency(preferences.currency), + apiKey: String(preferences.apiKey ?? "").trim(), +}); + +export const KEY_URL = "https://www.coingecko.com/en/developers/dashboard"; + +/** Shown instead of results when CoinGecko refuses and nothing is saved. */ +export function rateLimitItem(e: RateLimited): Item { + return { + id: "rate-limit", + title: e.message, + subtitle: "The public API allows a few calls a minute", + icon: { source: Icons.hourglass, tint: "orange" }, + detail: { + markdown: [ + "## CoinGecko is rate limiting", + "", + "The free public API allows only a few requests a minute per IP address. Prices are cached", + "for a minute or two, so this passes quickly.", + "", + "For a higher limit, create a free **Demo API key** (Enter opens the dashboard) and paste it in", + "**Settings → Extensions → Crypto → Configure**.", + ].join("\n"), + }, + actions: [open(KEY_URL, "Get a Free Demo API Key", { icon: Icons.key })], + }; +} + +/** Runs `load`, turning a rate limit without saved data into an explanation row. */ +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof RateLimited) return [rateLimitItem(e)]; + throw e; + } +} + +export function coinIcon(m: Pick) { + return logo(m.image, "small") ?? { source: Icons.coin, tint: "yellow" as const }; +} + +/** The detail panel's lines for a coin. */ +export function coinMetadata(m: Market, currency: string): Metadata[] { + const spark = sparkline(m.sparkline_in_7d?.price); + const lines: Metadata[] = []; + if (spark) lines.push(meta.text("7 days", spark, { color: changeColor(change7d(m)) })); + if (m.low_24h != null && m.high_24h != null) { + lines.push(meta.text("24 h range", `${money(m.low_24h, currency)} – ${money(m.high_24h, currency)}`)); + } + lines.push( + meta.text("Market cap", compactMoney(m.market_cap, currency), { icon: Icons.coin }), + meta.text("Volume 24 h", compactMoney(m.total_volume, currency), { icon: Icons.activity }), + ); + if (m.fully_diluted_valuation) { + lines.push(meta.text("Fully diluted", compactMoney(m.fully_diluted_valuation, currency))); + } + if (m.circulating_supply) { + const max = m.max_supply ?? m.total_supply; + lines.push( + max + ? meta.progress( + "Supply", + m.circulating_supply / max, + `${compact(m.circulating_supply)} of ${compact(max)} ${m.symbol.toUpperCase()}`, + ) + : meta.text("Supply", `${compact(m.circulating_supply)} ${m.symbol.toUpperCase()}`), + ); + } + if (m.ath) { + lines.push( + meta.text( + "All-time high", + `${money(m.ath, currency)} · ${percent(m.ath_change_percentage)}${ + m.ath_date ? ` · ${shortDate(m.ath_date)}` : "" + }`, + { icon: Icons.flag }, + ), + ); + } + lines.push(meta.separator(), meta.link("CoinGecko", `coingecko.com/en/coins/${m.id}`, coinUrl(m.id))); + return lines; +} + +/** A coin in the prices list. */ +export function coinItem( + m: Market, + o: { currency: string; watched: boolean; held?: Holding; section?: string }, +): Item { + const c24 = change24h(m); + const c7 = change7d(m); + const symbol = m.symbol.toUpperCase(); + const priced = m.current_price !== null && m.current_price !== undefined; + const actions: Action[] = [ + open(coinUrl(m.id), "Open on CoinGecko"), + action(o.watched ? "Remove from Watchlist" : "Add to Watchlist", async () => { + const now = await toggleWatch(m.id); + await toast(now ? `${m.name} added to the watchlist` : `${m.name} removed from the watchlist`, { + style: "success", + }); + return refresh(); + }, { icon: o.watched ? Icons.starFilled : Icons.star, shortcut: "s" }), + action(o.held ? "Edit Holding…" : "Add to Portfolio…", () => holdingForm(m, o.held), { + icon: Icons.plus, + shortcut: "p", + }), + ...(priced + ? [copy(money(m.current_price, o.currency), "Copy Price", { shortcut: "c", section: "Copy" })] + : []), + copy(symbol, "Copy Symbol", { section: "Copy" }), + copy(coinUrl(m.id), "Copy CoinGecko Link", { section: "Copy" }), + ]; + return { + id: `${o.section ?? ""}:${m.id}`, + title: m.name, + subtitle: symbol, + icon: coinIcon(m), + section: o.section, + keywords: [m.symbol, m.id], + accessories: [ + ...(o.watched + ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "In your watchlist" }] + : []), + ...(o.held + ? [{ tag: `${amount(o.held.amount)} ${symbol}`, color: "blue" as const, tooltip: "In your portfolio" }] + : []), + ...(priced + ? [ + { + text: percent(c24), + color: changeColor(c24), + icon: { source: (c24 ?? 0) >= 0 ? Icons.chartUp : Icons.chartDown, tint: changeColor(c24) }, + tooltip: "24 h change", + }, + { + text: money(m.current_price, o.currency), + tooltip: m.market_cap_rank ? `#${m.market_cap_rank}` : undefined, + }, + ] + : m.market_cap_rank + ? [{ text: `#${m.market_cap_rank}`, tooltip: "Market cap rank" }] + : []), + ], + detail: { + image: logo(m.image, "large"), + stats: priced + ? [ + { label: "Price", value: money(m.current_price, o.currency) }, + { label: "24 h", value: percent(c24), color: changeColor(c24) }, + { label: "7 d", value: percent(c7), color: changeColor(c7) }, + ...(m.market_cap_rank ? [{ label: "Rank", value: `#${m.market_cap_rank}` }] : []), + ] + : m.market_cap_rank + ? [{ label: "Rank", value: `#${m.market_cap_rank}` }] + : undefined, + markdown: priced ? undefined : "CoinGecko has no current price for this coin.", + metadata: priced ? coinMetadata(m, o.currency) : [meta.link("CoinGecko", m.id, coinUrl(m.id))], + }, + actions, + }; +} + +/** Amount of a coin to hold; saving goes back to the list (which reloads itself). */ +export function holdingForm(m: Pick, held?: Holding): FormSpec { + const symbol = m.symbol.toUpperCase(); + return form({ + title: held ? `${m.name} Holding` : `Add ${m.name} to Portfolio`, + submitTitle: held ? "Save Holding" : "Add Holding", + fields: [ + field.text("amount", `Amount (${symbol})`, { + placeholder: "e.g. 0.25", + required: true, + value: held ? String(held.amount) : "", + }), + ...(held ? [] : [field.checkbox("add", "Add to the amount I already hold", { value: true })]), + ], + async submit(values) { + const n = parseAmount(String(values.amount ?? "")); + if (n === undefined) throw new Error(`“${values.amount}” is not an amount of ${symbol}`); + const h: Holding = { id: m.id, symbol: m.symbol, name: m.name, image: m.image ?? undefined, amount: n }; + await setHoldings(upsertHolding(await holdings(), h, values.add ? "add" : "set")); + await toast(`${amount(n)} ${symbol} ${held ? "saved" : "added to the portfolio"}`, { style: "success" }); + return pop(); + }, + }); +} + +/** Search any coin, then enter the amount (replaces this list, so saving lands back on the portfolio). */ +export function coinPicker(s: Settings): ListSpec { + return list({ + title: "Add Holding", + placeholder: "Search a coin: bitcoin, sol, usdc…", + empty: { title: "Type a coin's name or symbol", icon: Icons.search }, + status: () => stale ? "CoinGecko is rate limiting: showing saved results" : "CoinGecko", + search: (query, { signal }) => + guarded(async () => { + if (!query.trim()) return []; + const coins = await searchCoins(query, s.apiKey, signal); + return coins.slice(0, 25).map((c) => { + const m = searchToMarket(c); + return { + id: c.id, + title: c.name, + subtitle: c.symbol.toUpperCase(), + icon: coinIcon({ image: c.thumb }), + accessories: c.market_cap_rank ? [{ text: `#${c.market_cap_rank}`, tooltip: "Market cap rank" }] : [], + actions: [action("Choose", () => replace(holdingForm(m)), { icon: Icons.check })], + }; + }); + }), + }); +} diff --git a/extensions/movies/api.ts b/extensions/movies/api.ts new file mode 100644 index 0000000..0465791 --- /dev/null +++ b/extensions/movies/api.ts @@ -0,0 +1,175 @@ +// The TMDB API (v3 endpoints, with a v4 read access token or a v3 key), and the watchlist. + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { auth, fromDetails, fromResult, type Kind, type Title, type TmdbDetails, type TmdbResult } from "./lib.ts"; + +const API = "https://api.themoviedb.org/3"; +export const TOKEN_URL = "https://www.themoviedb.org/settings/api"; + +type Json = Parameters[1]; + +export class AuthError extends Error {} + +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(() => {}); +} + +export interface Account { + token: string; + region: string; + language: string; +} + +export function token(preferences: Record): string { + const t = String(preferences.token ?? "").trim(); + if (!t) throw new AuthError("Add your TMDB API Read Access Token"); + return t; +} + +async function tmdb(a: Account, path: string, signal?: AbortSignal): Promise { + const { headers, param } = auth(a.token); + const sep = path.includes("?") ? "&" : "?"; + let res: Response; + try { + res = await fetch(`${API}${path}${param ? `${sep}${param}` : ""}`, { + signal, + headers: { ...headers, Accept: "application/json" }, + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`TMDB is unreachable: ${(e as Error).message}`); + } + if (res.status === 401) { + await res.body?.cancel(); + throw new AuthError("TMDB rejected the token"); + } + if (res.status === 429) { + await res.body?.cancel(); + throw new Error("TMDB is rate limiting, try again in a few seconds"); + } + if (!res.ok) { + const body = await res.json().catch(() => undefined) as { status_message?: string } | undefined; + throw new Error(`TMDB answered ${res.status}${body?.status_message ? `: ${body.status_message}` : ""}`); + } + return await res.json() as T; +} + +const page = (r: { results?: TmdbResult[] }, kind?: Kind) => + (r.results ?? []).filter((x) => !x.adult).map((x) => fromResult(x, kind)).filter((t): t is Title => !!t); + +export async function trending(a: Account, kind: "all" | Kind, signal?: AbortSignal): Promise { + const r = await tmdb<{ results?: TmdbResult[] }>(a, `/trending/${kind}/week?language=${a.language}`, signal); + return page(r, kind === "all" ? undefined : kind); +} + +export async function search(a: Account, query: string, kind: "all" | Kind, signal?: AbortSignal): Promise { + const q = encodeURIComponent(query.trim()); + const path = kind === "all" ? "multi" : kind; + const r = await tmdb<{ results?: TmdbResult[] }>( + a, + `/search/${path}?query=${q}&include_adult=false&language=${a.language}&page=1`, + signal, + ); + return page(r, kind === "all" ? undefined : kind); +} + +const memory = new Map(); +const DAY = 86400_000; + +/** Runtime, cast, trailer and providers; kept a day. */ +export async function details(a: Account, t: Pick, signal?: AbortSignal): Promise { + const key = `details:${t.kind}:${t.id}:${a.language}:${a.region}`; + const hit = memory.get(key) ?? + await load<{ at: number; title: Title }>(key).then((c) => c && Date.now() - c.at < DAY ? c.title : undefined); + if (hit) { + memory.set(key, hit); + return hit; + } + const append = t.kind === "movie" + ? "credits,videos,watch/providers,release_dates" + : "aggregate_credits,videos,watch/providers,content_ratings,external_ids"; + const lang = a.language.slice(0, 2); + const d = await tmdb<TmdbDetails>( + a, + `/${t.kind}/${t.id}?language=${a.language}&append_to_response=${append}&include_video_language=${lang},en,null`, + signal, + ); + const title = fromDetails(d, t.kind, a.region); + memory.set(key, title); + await save(key, { at: Date.now(), title }); + return title; +} + +/** Details for each title, in parallel; a title whose details fail is kept as it was. */ +export function withDetails(a: Account, titles: Title[], signal: AbortSignal, count = 12): Promise<Title[]> { + return Promise.all(titles.map((t, i) => + i < count + ? details(a, t, signal).catch((e) => { + if ((e as Error).name === "AbortError" || e instanceof AuthError) throw e; + return t; + }) + : Promise.resolve(t) + )); +} + +// --------------------------------------------------------------------------- +// Watchlist + +export interface Saved { + title: Title; + added: number; +} + +export const watchlist = async () => await load<Saved[]>("watchlist") ?? []; +export const watchKey = (t: Pick<Title, "kind" | "id">) => `${t.kind}:${t.id}`; + +export async function toggleWatch(t: Title): Promise<boolean> { + const list = await watchlist(); + const there = list.some((s) => watchKey(s.title) === watchKey(t)); + // Kept small: the list view shows what was saved, details are reloaded. + const slim: Title = { ...t, cast: undefined, watch: undefined }; + await save( + "watchlist", + there ? list.filter((s) => watchKey(s.title) !== watchKey(t)) : [{ title: slim, added: Date.now() }, ...list], + ); + return !there; +} + +// --------------------------------------------------------------------------- +// Setup + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Free with a TMDB account", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect TMDB", + "", + "Movies and shows come from **The Movie Database**, free for personal use:", + "", + "1. Sign in or create an account on themoviedb.org.", + "2. Open **Settings → API** (Enter opens it) and request an API key for personal use.", + "3. Copy the **API Read Access Token** (the long one; the shorter API key works too).", + "4. Paste it in **Settings → Extensions → Movies & TV → Configure**, and set your region", + " (`US`, `GB`, `FR`…) for streaming services.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Get a TMDB Token", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} diff --git a/extensions/movies/backslash.toml b/extensions/movies/backslash.toml new file mode 100644 index 0000000..74ccd11 --- /dev/null +++ b/extensions/movies/backslash.toml @@ -0,0 +1,53 @@ +[extension] +id = "movies" +title = "Movies & TV" +description = "Search movies and TV shows on TMDB with posters, ratings, cast, trailers and where to stream them, and keep a watchlist" +version = "1.0.0" +author = "Backslash" +icon = "bs-film" +license = "MIT" + +# The TMDB API, and image.tmdb.org where it serves posters. +[permissions] +net = ["api.themoviedb.org", "image.tmdb.org"] + +[[commands]] +name = "search" +title = "Search Movies & TV" +description = "Trending this week, or type to search; posters, cast, trailers and streaming services" +icon = "bs-film" +keywords = [ + "movie", + "movies", + "film", + "tv", + "show", + "series", + "tmdb", + "imdb", + "trailer", + "watchlist", + "cinéma", + "série", + "séries", + "bande-annonce", + "à voir", +] + +[[preferences]] +name = "token" +title = "TMDB API Read Access Token" +description = "themoviedb.org → Settings → API: the API Read Access Token (or the shorter API key)" +type = "secret" + +[[preferences]] +name = "region" +title = "Region" +description = "Two-letter country code for streaming services and release dates: US, GB, FR, DE, CA…" +default = "US" + +[[preferences]] +name = "language" +title = "Language" +description = "Language of titles and overviews: en-US, fr-FR, de-DE, es-ES…" +default = "en-US" diff --git a/extensions/movies/commands/search.ts b/extensions/movies/commands/search.ts new file mode 100644 index 0000000..b6ead46 --- /dev/null +++ b/extensions/movies/commands/search.ts @@ -0,0 +1,55 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { type Account, guarded, search, token, trending, watchKey, watchlist, withDetails } from "../api.ts"; +import { type Kind, matches, normalizeLanguage, normalizeRegion, type Title } from "../lib.ts"; +import { titleItem } from "../views.ts"; + +type View = "all" | "movie" | "tv" | "watchlist"; + +export default (ctx: Context) => { + const account = (): Account => ({ + token: token(ctx.preferences), + region: normalizeRegion(ctx.preferences.region), + language: normalizeLanguage(ctx.preferences.language), + }); + return list({ + placeholder: "Search movies and TV shows…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "movie", title: "Movies" }, + { value: "tv", title: "TV Shows" }, + { value: "watchlist", title: "Watchlist" }, + ], + }, + empty: { title: "Nothing found", description: "Try another title, or the original title", icon: Icons.film }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + const n = items.length; + return `${n} title${n === 1 ? "" : "s"} · ${normalizeRegion(ctx.preferences.region)} · TMDB`; + }, + search: (query, { signal, filter }) => + guarded(async (): Promise<Item[]> => { + const a = account(); + const view = (filter ?? "all") as View; + const saved = await watchlist(); + const watched = new Set(saved.map((s) => watchKey(s.title))); + const item = (t: Title, section?: string) => + titleItem(t, { watched: watched.has(watchKey(t)), region: a.region, section }); + + if (view === "watchlist") { + const mine = saved.map((s) => s.title).filter((t) => matches(t, query)); + return (await withDetails(a, mine, signal, 30)).map((t) => item(t, "Watchlist")); + } + const kind: "all" | Kind = view === "movie" || view === "tv" ? view : "all"; + if (query.trim()) { + return (await withDetails(a, await search(a, query, kind, signal), signal)).map((t) => item(t)); + } + const found = await withDetails(a, await trending(a, kind, signal), signal, 20); + const mine = saved.map((s) => s.title).filter((t) => kind === "all" || t.kind === kind).slice(0, 5); + return [ + ...(await withDetails(a, mine, signal)).map((t) => item(t, "Watchlist")), + ...found.map((t) => item(t, "Trending This Week")), + ]; + }), + }); +}; diff --git a/extensions/movies/lib.ts b/extensions/movies/lib.ts new file mode 100644 index 0000000..6751742 --- /dev/null +++ b/extensions/movies/lib.ts @@ -0,0 +1,328 @@ +// TMDB answers → one compact `Title` shape, and their formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// TMDB shapes (the fields we read) + +/** A result of `/search/multi` or `/trending/all/week`. */ +export interface TmdbResult { + id: number; + media_type?: "movie" | "tv" | "person" | string; + title?: string; + name?: string; + original_title?: string; + original_name?: string; + release_date?: string; + first_air_date?: string; + poster_path?: string | null; + backdrop_path?: string | null; + vote_average?: number; + vote_count?: number; + overview?: string; + genre_ids?: number[]; + original_language?: string; + popularity?: number; + adult?: boolean; +} + +interface Provider { + provider_id: number; + provider_name: string; + logo_path?: string; + display_priority?: number; +} + +interface RegionProviders { + link?: string; + flatrate?: Provider[]; + free?: Provider[]; + ads?: Provider[]; + rent?: Provider[]; + buy?: Provider[]; +} + +interface Video { + key: string; + name?: string; + site: string; + type: string; + official?: boolean; + published_at?: string; + iso_639_1?: string; +} + +/** `/movie/{id}` or `/tv/{id}` with `append_to_response=credits,videos,watch/providers,…`. */ +export interface TmdbDetails extends TmdbResult { + genres?: { id: number; name: string }[]; + runtime?: number | null; + episode_run_time?: number[]; + number_of_seasons?: number; + number_of_episodes?: number; + tagline?: string; + status?: string; + imdb_id?: string | null; + homepage?: string | null; + created_by?: { name: string }[]; + networks?: { name: string }[]; + last_air_date?: string | null; + next_episode_to_air?: { air_date?: string; season_number?: number; episode_number?: number; name?: string } | null; + credits?: { cast?: { name: string; character?: string; order?: number }[]; crew?: { name: string; job: string }[] }; + aggregate_credits?: { cast?: { name: string; roles?: { character?: string }[]; order?: number }[] }; + videos?: { results?: Video[] }; + "watch/providers"?: { results?: Record<string, RegionProviders> }; + external_ids?: { imdb_id?: string | null }; + release_dates?: { results?: { iso_3166_1: string; release_dates: { certification?: string; type?: number }[] }[] }; + content_ratings?: { results?: { iso_3166_1: string; rating?: string }[] }; +} + +// --------------------------------------------------------------------------- +// Our shape + +export type Kind = "movie" | "tv"; + +export interface Watch { + link?: string; + stream: string[]; + free: string[]; + rent: string[]; + buy: string[]; +} + +export interface Title { + kind: Kind; + id: number; + title: string; + originalTitle?: string; + date?: string; + year?: string; + poster?: string; + rating?: number; + votes?: number; + overview?: string; + genres: string[]; + language?: string; + /** Present once the details were loaded. */ + full?: boolean; + tagline?: string; + status?: string; + runtime?: number; + seasons?: number; + episodes?: number; + certification?: string; + directors?: string[]; + cast?: { name: string; character?: string }[]; + trailer?: { key: string; name?: string }; + watch?: Watch; + imdb?: string; + homepage?: string; + network?: string; + nextEpisode?: string; +} + +export const MOVIE_GENRES: Record<number, string> = { + 28: "Action", + 12: "Adventure", + 16: "Animation", + 35: "Comedy", + 80: "Crime", + 99: "Documentary", + 18: "Drama", + 10751: "Family", + 14: "Fantasy", + 36: "History", + 27: "Horror", + 10402: "Music", + 9648: "Mystery", + 10749: "Romance", + 878: "Science Fiction", + 10770: "TV Movie", + 53: "Thriller", + 10752: "War", + 37: "Western", +}; + +export const TV_GENRES: Record<number, string> = { + 10759: "Action & Adventure", + 16: "Animation", + 35: "Comedy", + 80: "Crime", + 99: "Documentary", + 18: "Drama", + 10751: "Family", + 10762: "Kids", + 9648: "Mystery", + 10763: "News", + 10764: "Reality", + 10765: "Sci-Fi & Fantasy", + 10766: "Soap", + 10767: "Talk", + 10768: "War & Politics", + 37: "Western", +}; + +export const IMAGE = "https://image.tmdb.org/t/p"; +export const poster = (path: string | undefined, size: "w92" | "w185" | "w342" | "w500") => + path ? `${IMAGE}/${size}${path}` : undefined; + +/** A search or trending result; undefined for people and nameless entries. */ +export function fromResult(r: TmdbResult, kind?: Kind): Title | undefined { + const k = (kind ?? r.media_type) as Kind; + if (k !== "movie" && k !== "tv") return undefined; + const title = (k === "movie" ? r.title : r.name) ?? r.title ?? r.name; + if (!title) return undefined; + const original = k === "movie" ? r.original_title : r.original_name; + const date = (k === "movie" ? r.release_date : r.first_air_date) || undefined; + const genres = k === "movie" ? MOVIE_GENRES : TV_GENRES; + return { + kind: k, + id: r.id, + title, + originalTitle: original && original !== title ? original : undefined, + date, + year: date?.slice(0, 4), + poster: r.poster_path ?? undefined, + rating: r.vote_count ? r.vote_average : undefined, + votes: r.vote_count, + overview: r.overview || undefined, + genres: (r.genre_ids ?? []).map((g) => genres[g]).filter(Boolean), + language: r.original_language, + }; +} + +/** Prefers an official YouTube trailer, then a teaser, then any YouTube video. */ +export function pickTrailer(videos: Video[] | undefined): { key: string; name?: string } | undefined { + const yt = (videos ?? []).filter((v) => v.site === "YouTube" && v.key); + const rank = (v: Video) => (v.type === "Trailer" ? 0 : v.type === "Teaser" ? 2 : 4) + (v.official ? 0 : 1); + const best = + [...yt].sort((a, b) => rank(a) - rank(b) || (b.published_at ?? "").localeCompare(a.published_at ?? ""))[0]; + return best ? { key: best.key, name: best.name } : undefined; +} + +const names = (list: Provider[] | undefined) => + [...(list ?? [])].sort((a, b) => (a.display_priority ?? 99) - (b.display_priority ?? 99)).map((p) => p.provider_name); + +export function watchIn(details: TmdbDetails, region: string): Watch | undefined { + const r = details["watch/providers"]?.results?.[region]; + if (!r) return undefined; + return { + link: r.link, + stream: names(r.flatrate), + free: [...new Set([...names(r.free), ...names(r.ads)])], + rent: names(r.rent), + buy: names(r.buy), + }; +} + +/** A title with its details: runtime, cast, trailer, where to watch in `region`. */ +export function fromDetails(d: TmdbDetails, kind: Kind, region: string): Title { + const base = fromResult({ ...d, genre_ids: [] }, kind)!; + const cast = kind === "tv" && d.aggregate_credits?.cast?.length + ? d.aggregate_credits.cast.map((c) => ({ name: c.name, character: c.roles?.[0]?.character, order: c.order })) + : (d.credits?.cast ?? []); + const runtime = kind === "movie" ? d.runtime ?? undefined : d.episode_run_time?.[0]; + const certification = kind === "movie" + ? d.release_dates?.results?.find((r) => r.iso_3166_1 === region)?.release_dates.map((x) => x.certification) + .find(Boolean) + : d.content_ratings?.results?.find((r) => r.iso_3166_1 === region)?.rating; + const next = d.next_episode_to_air; + return { + ...base, + full: true, + genres: (d.genres ?? []).map((g) => g.name), + tagline: d.tagline || undefined, + status: d.status || undefined, + runtime: runtime || undefined, + seasons: d.number_of_seasons, + episodes: d.number_of_episodes, + certification: certification || undefined, + directors: kind === "movie" + ? (d.credits?.crew ?? []).filter((c) => c.job === "Director").map((c) => c.name) + : (d.created_by ?? []).map((c) => c.name), + cast: [...cast].sort((a, b) => (a.order ?? 99) - (b.order ?? 99)).slice(0, 5).map((c) => ({ + name: c.name, + character: c.character || undefined, + })), + trailer: pickTrailer(d.videos?.results), + watch: watchIn(d, region), + imdb: d.imdb_id ?? d.external_ids?.imdb_id ?? undefined, + homepage: d.homepage || undefined, + network: d.networks?.[0]?.name, + nextEpisode: next?.air_date + ? `S${next.season_number}E${next.episode_number}${next.name ? ` “${next.name}”` : ""} · ${ + longDate(next.air_date) + }` + : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** 155 → "2 h 35 min". */ +export function duration(minutes: number | undefined): string | undefined { + if (!minutes) return undefined; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return h ? `${h} h${m ? ` ${String(m).padStart(2, "0")} min` : ""}` : `${m} min`; +} + +export const rating = (r: number | undefined) => (r === undefined ? "—" : r.toFixed(1)); + +export function ratingColor(r: number | undefined): Color | undefined { + if (r === undefined) return undefined; + return r >= 7.5 ? "green" : r >= 6 ? "yellow" : "red"; +} + +/** "2024-02-27" → "27 Feb 2024". */ +export function longDate(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(`${iso}T00:00:00Z`); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); +} + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** "Movie · 2024 · 2 h 46 min" or "TV · 2019 · 4 seasons". */ +export function summaryLine(t: Title): string { + return [ + t.kind === "movie" ? "Movie" : "TV", + t.year, + t.kind === "movie" ? duration(t.runtime) : t.seasons ? plural(t.seasons, "season") : undefined, + ].filter(Boolean).join(" · "); +} + +export const tmdbUrl = (t: Pick<Title, "kind" | "id">) => `https://www.themoviedb.org/${t.kind}/${t.id}`; +export const youtubeUrl = (key: string) => `https://www.youtube.com/watch?v=${encodeURIComponent(key)}`; +export const imdbUrl = (id: string) => `https://www.imdb.com/title/${encodeURIComponent(id)}/`; + +/** v4 read access tokens are JWTs; v3 keys are 32 hex characters. */ +export function auth(token: string): { headers: Record<string, string>; param?: string } { + const t = token.trim(); + if (/^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(t)) return { headers: { Authorization: `Bearer ${t}` } }; + return { headers: {}, param: `api_key=${encodeURIComponent(t)}` }; +} + +/** `"fr"` → `"FR"`; anything else than two letters → `"US"`. */ +export function normalizeRegion(input: unknown): string { + const r = String(input ?? "").trim().toUpperCase(); + return /^[A-Z]{2}$/.test(r) ? r : "US"; +} + +/** `"fr"` → `"fr-FR"`, `"pt-br"` → `"pt-BR"`; default en-US. */ +export function normalizeLanguage(input: unknown): string { + const l = String(input ?? "").trim(); + const m = /^([a-z]{2})(?:[-_]([a-z]{2}))?$/i.exec(l); + if (!m) return "en-US"; + const lang = m[1].toLowerCase(); + return `${lang}-${(m[2] ?? (lang === "en" ? "US" : lang)).toUpperCase()}`; +} + +/** Watchlist entries match the query on titles, original titles and genres. */ +export function matches(t: Title, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [t.title, t.originalTitle ?? "", t.year ?? "", ...t.genres].some((s) => s.toLowerCase().includes(q)); +} diff --git a/extensions/movies/lib_test.ts b/extensions/movies/lib_test.ts new file mode 100644 index 0000000..d09ad4f --- /dev/null +++ b/extensions/movies/lib_test.ts @@ -0,0 +1,239 @@ +import { assertEquals } from "@std/assert"; +import { + auth, + duration, + fromDetails, + fromResult, + longDate, + matches, + normalizeLanguage, + normalizeRegion, + pickTrailer, + poster, + ratingColor, + summaryLine, + type TmdbDetails, + type TmdbResult, + tmdbUrl, +} from "./lib.ts"; +import { titleItem } from "./views.ts"; + +// Shaped like `/trending/all/week` and `/search/multi` results. +const dune: TmdbResult = { + adult: false, + id: 693134, + title: "Dune: Part Two", + original_title: "Dune: Part Two", + overview: "Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen.", + poster_path: "/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg", + media_type: "movie", + original_language: "en", + genre_ids: [878, 12], + release_date: "2024-02-27", + vote_average: 8.153, + vote_count: 6234, +}; +const amelie: TmdbResult = { + id: 194, + title: "Amélie", + original_title: "Le Fabuleux Destin d'Amélie Poulain", + media_type: "movie", + poster_path: null, + release_date: "2001-04-25", + vote_average: 7.9, + vote_count: 11000, + genre_ids: [35, 10749], + original_language: "fr", + overview: "", +}; +const person: TmdbResult = { id: 1190668, name: "Timothée Chalamet", media_type: "person" }; + +// `/movie/693134?append_to_response=credits,videos,watch/providers,release_dates`. +const duneDetails: TmdbDetails = { + ...dune, + genre_ids: undefined, + genres: [{ id: 878, name: "Science Fiction" }, { id: 12, name: "Adventure" }], + runtime: 167, + tagline: "Long live the fighters.", + status: "Released", + imdb_id: "tt15239678", + credits: { + cast: [ + { name: "Zendaya", character: "Chani", order: 1 }, + { name: "Timothée Chalamet", character: "Paul Atreides", order: 0 }, + { name: "Rebecca Ferguson", character: "Lady Jessica", order: 2 }, + { name: "Javier Bardem", character: "Stilgar", order: 3 }, + { name: "Josh Brolin", character: "Gurney Halleck", order: 4 }, + { name: "Austin Butler", character: "Feyd-Rautha", order: 5 }, + ], + crew: [{ name: "Denis Villeneuve", job: "Director" }, { name: "Hans Zimmer", job: "Original Music Composer" }], + }, + videos: { + results: [ + { key: "teaser", site: "YouTube", type: "Teaser", official: true, published_at: "2023-05-03T13:00:00.000Z" }, + { key: "fan", site: "YouTube", type: "Trailer", official: false }, + { key: "Way9Dexny3w", site: "YouTube", type: "Trailer", official: true, published_at: "2023-12-18T17:00:00Z" }, + { key: "vimeo", site: "Vimeo", type: "Trailer", official: true }, + ], + }, + "watch/providers": { + results: { + US: { + link: "https://www.themoviedb.org/movie/693134-dune-part-two/watch?locale=US", + flatrate: [{ provider_id: 1899, provider_name: "Max", display_priority: 2 }], + rent: [ + { provider_id: 2, provider_name: "Apple TV", display_priority: 4 }, + { provider_id: 10, provider_name: "Amazon Video", display_priority: 1 }, + ], + }, + }, + }, + release_dates: { + results: [{ + iso_3166_1: "US", + release_dates: [{ certification: "", type: 1 }, { certification: "PG-13", type: 3 }], + }], + }, +}; + +// `/tv/126308?append_to_response=aggregate_credits,…`. +const shogun: TmdbDetails = { + id: 126308, + name: "Shōgun", + first_air_date: "2024-02-27", + poster_path: "/7O4iVfOMQmdCSxhOg1WnzG1AgYT.jpg", + vote_average: 8.6, + vote_count: 1100, + genres: [{ id: 18, name: "Drama" }], + episode_run_time: [58], + number_of_seasons: 1, + number_of_episodes: 10, + created_by: [{ name: "Rachel Kondo" }, { name: "Justin Marks" }], + networks: [{ name: "FX" }], + next_episode_to_air: { air_date: "2026-11-12", season_number: 2, episode_number: 1, name: "Return" }, + aggregate_credits: { cast: [{ name: "Hiroyuki Sanada", roles: [{ character: "Yoshii Toranaga" }], order: 0 }] }, + content_ratings: { results: [{ iso_3166_1: "US", rating: "TV-MA" }] }, + external_ids: { imdb_id: "tt2788316" }, +}; + +Deno.test("results", () => { + const t = fromResult(dune)!; + assertEquals(t.kind, "movie"); + assertEquals(t.year, "2024"); + assertEquals(t.genres, ["Science Fiction", "Adventure"]); + assertEquals(t.originalTitle, undefined); + assertEquals(fromResult(person), undefined); + const a = fromResult(amelie)!; + assertEquals(a.originalTitle, "Le Fabuleux Destin d'Amélie Poulain"); + assertEquals(a.poster, undefined); + assertEquals(a.overview, undefined); + // `/trending/tv/week` results have no media_type. + assertEquals( + fromResult({ id: 1, name: "Severance", first_air_date: "2022-02-17", genre_ids: [18, 9648] }, "tv")?.genres, + [ + "Drama", + "Mystery", + ], + ); +}); + +Deno.test("movie details", () => { + const t = fromDetails(duneDetails, "movie", "US"); + assertEquals(t.full, true); + assertEquals(t.runtime, 167); + assertEquals(t.certification, "PG-13"); + assertEquals(t.directors, ["Denis Villeneuve"]); + assertEquals(t.cast?.map((c) => c.name), [ + "Timothée Chalamet", + "Zendaya", + "Rebecca Ferguson", + "Javier Bardem", + "Josh Brolin", + ]); + assertEquals(t.trailer?.key, "Way9Dexny3w"); + assertEquals(t.watch?.stream, ["Max"]); + assertEquals(t.watch?.rent, ["Amazon Video", "Apple TV"]); + assertEquals(t.imdb, "tt15239678"); + assertEquals(fromDetails(duneDetails, "movie", "FR").watch, undefined); + assertEquals(summaryLine(t), "Movie · 2024 · 2 h 47 min"); +}); + +Deno.test("show details", () => { + const t = fromDetails(shogun, "tv", "US"); + assertEquals(t.seasons, 1); + assertEquals(t.runtime, 58); + assertEquals(t.certification, "TV-MA"); + assertEquals(t.directors, ["Rachel Kondo", "Justin Marks"]); + assertEquals(t.cast, [{ name: "Hiroyuki Sanada", character: "Yoshii Toranaga" }]); + assertEquals(t.network, "FX"); + assertEquals(t.nextEpisode, "S2E1 “Return” · 12 Nov 2026"); + assertEquals(t.imdb, "tt2788316"); + assertEquals(t.trailer, undefined); + assertEquals(summaryLine(t), "TV · 2024 · 1 season"); +}); + +Deno.test("trailers", () => { + assertEquals(pickTrailer([{ key: "t", site: "YouTube", type: "Teaser" }])?.key, "t"); + assertEquals(pickTrailer([{ key: "v", site: "Vimeo", type: "Trailer" }]), undefined); + assertEquals(pickTrailer(undefined), undefined); +}); + +Deno.test("formatting", () => { + assertEquals(duration(167), "2 h 47 min"); + assertEquals(duration(120), "2 h"); + assertEquals(duration(45), "45 min"); + assertEquals(duration(65), "1 h 05 min"); + assertEquals(duration(undefined), undefined); + assertEquals(longDate("2024-02-27"), "27 Feb 2024"); + assertEquals(ratingColor(8.2), "green"); + assertEquals(ratingColor(6.4), "yellow"); + assertEquals(ratingColor(4), "red"); + assertEquals(poster("/a.jpg", "w342"), "https://image.tmdb.org/t/p/w342/a.jpg"); + assertEquals(poster(undefined, "w92"), undefined); + assertEquals(tmdbUrl({ kind: "tv", id: 1 }), "https://www.themoviedb.org/tv/1"); +}); + +Deno.test("preferences and auth", () => { + assertEquals(auth("eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJ4In0.c2ln"), { + headers: { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJ4In0.c2ln" }, + }); + assertEquals(auth(" 0123456789abcdef0123456789abcdef "), { + headers: {}, + param: "api_key=0123456789abcdef0123456789abcdef", + }); + assertEquals(normalizeRegion("fr"), "FR"); + assertEquals(normalizeRegion("France"), "US"); + assertEquals(normalizeLanguage("fr"), "fr-FR"); + assertEquals(normalizeLanguage("en"), "en-US"); + assertEquals(normalizeLanguage("pt_br"), "pt-BR"); + assertEquals(normalizeLanguage("klingon"), "en-US"); +}); + +Deno.test("watchlist matching", () => { + const a = fromResult(amelie)!; + assertEquals(matches(a, "fabuleux"), true); + assertEquals(matches(a, "romance"), true); + assertEquals(matches(a, "2001"), true); + assertEquals(matches(a, "dune"), false); + assertEquals(matches(a, ""), true); +}); + +Deno.test("title item", () => { + const item = titleItem(fromDetails(duneDetails, "movie", "US"), { watched: true, region: "US" }); + assertEquals(item.icon, "https://image.tmdb.org/t/p/w92/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg"); + assertEquals(item.detail?.image, "https://image.tmdb.org/t/p/w342/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg"); + assertEquals(item.detail?.stats?.map((s) => s.value), ["★ 8.2", "2024", "2 h 47 min"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Open on TMDB", + "Watch Trailer on YouTube", + "Where to Watch in US", + "Remove from Watchlist", + ]); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text), [undefined, "Max", "8.2"]); + const cast = item.detail?.metadata?.filter((m) => m.avatar); + assertEquals(cast?.[1], { label: "Cast", value: "Timothée Chalamet as Paul Atreides", avatar: "Timothée Chalamet" }); + + const bare = titleItem(fromResult(amelie)!, { watched: false, region: "FR" }); + assertEquals(bare.icon, { source: "bs-film", tint: "purple" }); + assertEquals(bare.detail?.markdown, "No overview yet."); +}); diff --git a/extensions/movies/views.ts b/extensions/movies/views.ts new file mode 100644 index 0000000..d46d1a3 --- /dev/null +++ b/extensions/movies/views.ts @@ -0,0 +1,165 @@ +// Movies and shows as launcher items. + +import { + type Accessory, + type Action, + action, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + type Stat, + toast, +} from "@backslash/api"; +import { toggleWatch } from "./api.ts"; +import { + duration, + imdbUrl, + longDate, + plural, + poster, + rating, + ratingColor, + summaryLine, + type Title, + tmdbUrl, + youtubeUrl, +} from "./lib.ts"; + +const LANGUAGES = new Intl.DisplayNames(["en"], { type: "language" }); +const languageName = (code: string | undefined) => { + if (!code) return undefined; + try { + return LANGUAGES.of(code); + } catch { + return code; + } +}; + +export function titleMetadata(t: Title, region: string): Metadata[] { + const lines: Metadata[] = []; + if (t.genres.length) lines.push(meta.tags("Genres", t.genres.map((g) => ({ text: g, color: "purple" as const })))); + if (t.date) { + lines.push(meta.text(t.kind === "movie" ? "Released" : "First aired", longDate(t.date), { icon: Icons.calendar })); + } + if (t.kind === "movie" && t.runtime) lines.push(meta.text("Runtime", duration(t.runtime)!, { icon: Icons.clock })); + if (t.kind === "tv" && t.seasons) { + lines.push( + meta.text( + "Seasons", + `${plural(t.seasons, "season")}${t.episodes ? ` · ${plural(t.episodes, "episode")}` : ""}${ + t.runtime ? ` · ${t.runtime} min` : "" + }`, + { icon: Icons.layers }, + ), + ); + } + if (t.nextEpisode) lines.push(meta.text("Next episode", t.nextEpisode, { icon: Icons.calendar })); + if (t.certification) lines.push(meta.tags(`Rated (${region})`, [t.certification])); + if (t.directors?.length) { + lines.push(meta.person(t.kind === "movie" ? "Director" : "Created by", t.directors.slice(0, 3).join(", "))); + } + if (t.cast?.length) { + lines.push(meta.separator()); + t.cast.forEach((c, i) => + lines.push(meta.person(i ? "" : "Cast", c.character ? `${c.name} as ${c.character}` : c.name, c.name)) + ); + } + if (t.watch) { + lines.push(meta.separator()); + const groups: [string, string[], "green" | "teal" | "blue" | "gray"][] = [ + ["Stream", t.watch.stream, "green"], + ["Free", t.watch.free, "teal"], + ["Rent", t.watch.rent, "blue"], + ["Buy", t.watch.buy, "gray"], + ]; + for (const [label, names, color] of groups) { + if (names.length) lines.push(meta.tags(label, names.slice(0, 5).map((text) => ({ text, color })))); + } + if (!groups.some(([, names]) => names.length)) lines.push(meta.text("Where to watch", `Nowhere in ${region}`)); + } else if (t.full) { + lines.push(meta.text("Where to watch", `No streaming service in ${region}`, { icon: Icons.eyeOff })); + } + lines.push(meta.separator()); + if (t.network) lines.push(meta.text("Network", t.network, { icon: Icons.monitor })); + if (t.status) lines.push(meta.text("Status", t.status)); + if (t.originalTitle) lines.push(meta.text("Original title", t.originalTitle)); + const lang = languageName(t.language); + if (lang) lines.push(meta.text("Language", lang, { icon: Icons.globe })); + lines.push(meta.link("TMDB", `themoviedb.org/${t.kind}/${t.id}`, tmdbUrl(t))); + if (t.imdb) lines.push(meta.link("IMDb", t.imdb, imdbUrl(t.imdb))); + return lines; +} + +export function titleItem(t: Title, o: { watched: boolean; region: string; section?: string }): Item { + const stats: Stat[] = []; + if (t.rating !== undefined) { + stats.push({ label: "Rating", value: `★ ${rating(t.rating)}`, color: ratingColor(t.rating) }); + } + if (t.year) stats.push({ label: "Year", value: t.year }); + if (t.kind === "movie" && t.runtime) stats.push({ label: "Runtime", value: duration(t.runtime)! }); + if (t.kind === "tv" && t.seasons) stats.push({ label: "Seasons", value: t.seasons }); + + const accessories: Accessory[] = []; + if (o.watched && o.section !== "Watchlist") { + accessories.push({ icon: { source: Icons.bookmark, tint: "yellow" }, tooltip: "In your watchlist" }); + } + if (t.watch?.stream.length) { + accessories.push({ tag: t.watch.stream[0], color: "green", tooltip: `Streaming on ${t.watch.stream.join(", ")}` }); + } + if (t.rating !== undefined) { + accessories.push({ + text: rating(t.rating), + icon: { source: Icons.starFilled, tint: ratingColor(t.rating) }, + tooltip: `${rating(t.rating)}/10 from ${t.votes ?? 0} votes`, + }); + } + + const actions: Action[] = [open(tmdbUrl(t), "Open on TMDB")]; + if (t.trailer) { + actions.push( + open(youtubeUrl(t.trailer.key), "Watch Trailer on YouTube", { icon: Icons.play, shortcut: "<Ctrl>t" }), + ); + } + if (t.watch?.link) { + actions.push(open(t.watch.link, `Where to Watch in ${o.region}`, { icon: Icons.monitor, shortcut: "<Ctrl>w" })); + } + actions.push( + action(o.watched ? "Remove from Watchlist" : "Add to Watchlist", async () => { + const now = await toggleWatch(t); + await toast(`${t.title} ${now ? "added to" : "removed from"} the watchlist`, { style: "success" }); + return refresh(); + }, { icon: o.watched ? Icons.bookmark : Icons.plus, shortcut: "<Ctrl>s" }), + ); + if (t.imdb) actions.push(open(imdbUrl(t.imdb), "Open on IMDb", { section: "Open" })); + if (t.homepage) actions.push(open(t.homepage, "Open Official Website", { section: "Open" })); + actions.push( + copy(tmdbUrl(t), "Copy TMDB Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(t.year ? `${t.title} (${t.year})` : t.title, "Copy Title", { section: "Copy" }), + ...(t.trailer ? [copy(youtubeUrl(t.trailer.key), "Copy Trailer Link", { section: "Copy" })] : []), + ); + + const md = [ + ...(t.tagline ? [`*${t.tagline}*`, ""] : []), + t.overview ?? "No overview yet.", + ]; + return { + id: `${o.section ?? ""}:${t.kind}:${t.id}`, + title: t.title, + subtitle: summaryLine(t), + section: o.section, + icon: poster(t.poster, "w92") ?? { source: t.kind === "movie" ? Icons.film : Icons.monitor, tint: "purple" }, + keywords: [...t.genres, t.originalTitle ?? "", t.kind === "movie" ? "movie" : "tv show series"], + accessories, + detail: { + image: poster(t.poster, "w342"), + stats: stats.length ? stats : undefined, + markdown: md.join("\n"), + metadata: titleMetadata(t, o.region), + }, + actions, + }; +} diff --git a/extensions/radio/api.ts b/extensions/radio/api.ts new file mode 100644 index 0000000..3d74bcd --- /dev/null +++ b/extensions/radio/api.ts @@ -0,0 +1,93 @@ +// The radio-browser.info API: a community directory with several mirrors. + +import { storage } from "@backslash/api"; +import type { Station } from "./lib.ts"; + +/** Mirrors in `[permissions] net`, tried in order; the one that answered is used first next time. */ +export const MIRRORS = [ + "de1.api.radio-browser.info", + "fi1.api.radio-browser.info", + "de2.api.radio-browser.info", + "all.api.radio-browser.info", +]; +const HEADERS = { + "User-Agent": "Backslash-Radio/1.0 (+https://github.com/backslash-app/plugins)", + Accept: "application/json", +}; + +type Json = Parameters<typeof storage.set>[1]; + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +let preferred: string | undefined; + +async function get<T>(path: string, signal?: AbortSignal): Promise<T> { + preferred ??= await load<string>("mirror"); + const order = [...new Set([preferred, ...MIRRORS].filter((m): m is string => !!m && MIRRORS.includes(m)))]; + let last = ""; + for (const host of order) { + const timeout = AbortSignal.timeout(8000); + try { + const res = await fetch(`https://${host}${path}`, { + headers: HEADERS, + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + if (!res.ok) { + await res.body?.cancel(); + last = `${host} answered ${res.status}`; + if (res.status >= 500 || res.status === 429) continue; + throw new Error(`radio-browser.info answered ${res.status}`); + } + const data = await res.json() as T; + if (host !== preferred) { + preferred = host; + await save("mirror", host); + } + return data; + } catch (e) { + if (signal?.aborted) throw e; + if ((e as Error).message.startsWith("radio-browser.info answered")) throw e; + last = timeout.aborted ? `${host} timed out` : `${host}: ${(e as Error).message}`; + } + } + throw new Error(`radio-browser.info is unreachable (${last})`); +} + +const base = { hidebroken: "true", order: "votes", reverse: "true" }; + +export function searchStations(params: Record<string, string>, signal?: AbortSignal, limit = 40): Promise<Station[]> { + const q = new URLSearchParams({ ...base, limit: String(limit), ...params }); + return get<Station[]>(`/json/stations/search?${q}`, signal); +} + +export function topStations(signal?: AbortSignal, limit = 50): Promise<Station[]> { + return get<Station[]>(`/json/stations/topvote/${limit}?hidebroken=true`, signal); +} + +/** Counts a play, as radio-browser asks clients to, and returns the stream URL it resolved. */ +export async function click(s: Station): Promise<string> { + try { + const r = await get<{ ok?: boolean; url?: string }>(`/json/url/${encodeURIComponent(s.stationuuid)}`); + return r.ok && r.url ? r.url : s.url_resolved || s.url; + } catch { + return s.url_resolved || s.url; + } +} + +// --------------------------------------------------------------------------- +// Favorites + +export const favorites = async () => await load<Station[]>("favorites") ?? []; + +export async function toggleFavorite(s: Station): Promise<boolean> { + const list = await favorites(); + const there = list.some((f) => f.stationuuid === s.stationuuid); + await save("favorites", there ? list.filter((f) => f.stationuuid !== s.stationuuid) : [...list, s]); + return !there; +} diff --git a/extensions/radio/backslash.toml b/extensions/radio/backslash.toml new file mode 100644 index 0000000..80b23e6 --- /dev/null +++ b/extensions/radio/backslash.toml @@ -0,0 +1,60 @@ +[extension] +id = "radio" +title = "Internet Radio" +description = "Search 50,000 internet radio stations by name, genre or country and play them in the background with mpv, ffplay or VLC" +version = "1.0.0" +author = "Backslash" +icon = "bs-radio" +license = "MIT" + +# The radio-browser.info community API: its mirrors, tried in this order +# (all.api is the round-robin name). Station logos live on each station's own +# website, so they are not shown: that would need access to any host. +# Stations play in a transient systemd user unit (systemd-run) so they keep +# playing once the launcher is hidden; mpv, ffplay and cvlc are run once to +# find which is installed. A small mpv script in ~/.cache/backslash/radio writes +# the song title (ICY metadata) there for the launcher to read; HOME locates it. +[permissions] +net = ["de1.api.radio-browser.info", "de2.api.radio-browser.info", "fi1.api.radio-browser.info", "all.api.radio-browser.info"] +run = ["systemd-run", "systemctl", "mpv", "ffplay", "cvlc"] +read = ["~/.cache/backslash/radio", "/etc/os-release"] +write = ["~/.cache/backslash/radio"] +env = ["HOME"] + +[[commands]] +name = "stations" +title = "Internet Radio" +description = "Top stations, your favorites and what's playing; type to search by name or genre" +icon = "bs-radio" +keywords = [ + "radio", + "stations", + "internet radio", + "stream", + "music", + "fm", + "listen", + "webradio", + "station", + "musique", + "écouter", +] + +[[commands]] +name = "stop" +title = "Stop Radio" +description = "Stops the station playing in the background" +icon = "bs-stop" +mode = "no-view" +keywords = ["radio", "stop", "silence", "arrêter", "couper"] + +[[preferences]] +name = "player" +title = "Player" +description = "auto (mpv, then ffplay, then VLC), mpv, ffplay or vlc" +default = "auto" + +[[preferences]] +name = "country" +title = "Your country" +description = "Two-letter code for the Country view when nothing is typed: FR, US, DE…" diff --git a/extensions/radio/commands/stations.ts b/extensions/radio/commands/stations.ts new file mode 100644 index 0000000..e362ade --- /dev/null +++ b/extensions/radio/commands/stations.ts @@ -0,0 +1,86 @@ +import { type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { favorites, searchStations, topStations } from "../api.ts"; +import { countryParams, mergeResults, playerChoice, type Station } from "../lib.ts"; +import { distro, findPlayer, nowPlaying, song } from "../player.ts"; +import { noPlayerItem, nowPlayingItem, stationItem } from "../views.ts"; + +type View = "stations" | "country" | "favorites"; + +let playingName: string | undefined; + +const matches = (s: Station, q: string) => + !q || `${s.name} ${s.tags ?? ""} ${s.country ?? ""}`.toLowerCase().includes(q.toLowerCase()); + +export default (ctx: Context) => { + const choices = playerChoice(ctx.preferences.player); + const home = String(ctx.preferences.country ?? "").trim(); + const player = findPlayer(choices); + return list({ + placeholder: "Search stations by name or genre: fip, jazz, lofi, bbc…", + filter: { + options: [ + { value: "stations", title: "Stations" }, + { value: "country", title: "By Country" }, + { value: "favorites", title: "Favorites" }, + ], + }, + empty: { title: "No station found", description: "Try a genre (jazz) or another spelling", icon: Icons.radio }, + status: (items) => { + const n = items.filter((i) => !["now-playing", "no-player", "country-hint"].includes(i.id ?? "")).length; + return [playingName && `Playing ${playingName}`, `${n} station${n === 1 ? "" : "s"}`, "radio-browser.info"] + .filter(Boolean).join(" · "); + }, + async search(query, { signal, filter }) { + const view = (filter ?? "stations") as View; + const q = query.trim(); + const [favs, current, found] = await Promise.all([favorites(), nowPlaying(), player]); + playingName = current?.station.name.trim(); + updateCommandMetadata({ subtitle: current ? `Playing ${current.station.name.trim()}` : null }).catch(() => {}); + const fav = new Set(favs.map((f) => f.stationuuid)); + const item = (s: Station, section?: string) => + stationItem(s, { + favorite: fav.has(s.stationuuid), + playing: current?.station.stationuuid === s.stationuuid, + choices, + section, + }); + + const head: Item[] = []; + if (!found) head.push(noPlayerItem(await distro())); + if (current && !q) head.push(nowPlayingItem(current, current.player === "mpv" ? await song() : undefined)); + + if (view === "favorites") return [...head, ...favs.filter((s) => matches(s, q)).map((s) => item(s, "Favorites"))]; + + if (view === "country") { + const where = q || home; + if (!where) { + return [...head, { + id: "country-hint", + title: "Type a country: France, FR, Japan, US…", + subtitle: "or set your country in the extension's preferences", + icon: { source: Icons.globe, tint: "blue" }, + }]; + } + const stations = await searchStations(countryParams(where), signal, 60); + return [...head, ...stations.map((s) => item(s, `Top in ${stations[0]?.country || where}`))]; + } + + if (!q) { + const top = await topStations(signal); + return [ + ...head, + ...favs.map((s) => item(s, "Favorites")), + ...top.filter((s) => !fav.has(s.stationuuid)).map((s) => item(s, "Top Voted")), + ]; + } + const [byName, byTag] = await Promise.all([ + searchStations({ name: q }, signal), + searchStations({ tag: q.toLowerCase() }, signal).catch((e) => { + if (signal.aborted) throw e; + return [] as Station[]; + }), + ]); + return [...head, ...mergeResults(byName, byTag, q).map((s) => item(s))]; + }, + }); +}; diff --git a/extensions/radio/commands/stop.ts b/extensions/radio/commands/stop.ts new file mode 100644 index 0000000..6d1afd6 --- /dev/null +++ b/extensions/radio/commands/stop.ts @@ -0,0 +1,8 @@ +import { run } from "@backslash/api"; +import { nowPlaying, stop } from "../player.ts"; + +export default run(async () => { + const current = await nowPlaying(); + await stop(); + return current ? `${current.station.name.trim()} stopped` : "Nothing was playing"; +}); diff --git a/extensions/radio/lib.ts b/extensions/radio/lib.ts new file mode 100644 index 0000000..7893546 --- /dev/null +++ b/extensions/radio/lib.ts @@ -0,0 +1,199 @@ +// radio-browser.info answers, player command lines and formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +/** A station of `/json/stations/…`. */ +export interface Station { + stationuuid: string; + name: string; + url: string; + url_resolved?: string; + homepage?: string; + favicon?: string; + tags?: string; + country?: string; + countrycode?: string; + state?: string; + language?: string; + votes?: number; + codec?: string; + bitrate?: number; + hls?: number; + lastcheckok?: number; + clickcount?: number; + clicktrend?: number; +} + +export type Player = "mpv" | "ffplay" | "vlc"; +export const PLAYERS: Player[] = ["mpv", "ffplay", "vlc"]; + +/** The `player` preference: one player, or all in order for auto. */ +export function playerChoice(input: unknown): Player[] { + const p = String(input ?? "").trim().toLowerCase().replace(/^cvlc$/, "vlc"); + return (PLAYERS as string[]).includes(p) ? [p as Player] : PLAYERS; +} + +/** The program run for a player. */ +export const program = (p: Player) => (p === "vlc" ? "cvlc" : p); + +/** The command line that plays `url` without a window. */ +export function playerArgs(p: Player, url: string, o: { script?: string; title?: string } = {}): string[] { + switch (p) { + case "mpv": + return [ + "mpv", + "--no-video", + "--no-terminal", + "--idle=no", + "--cache=yes", + "--demuxer-max-bytes=4MiB", + ...(o.script ? [`--script=${o.script}`] : []), + ...(o.title ? [`--title=${o.title}`] : []), + url, + ]; + case "ffplay": + return [ + "ffplay", + "-nodisp", + "-autoexit", + "-loglevel", + "error", + ...(o.title ? ["-window_title", o.title] : []), + url, + ]; + case "vlc": + return [ + "cvlc", + "--intf", + "dummy", + "--no-video", + "--play-and-exit", + ...(o.title ? ["--meta-title", o.title] : []), + url, + ]; + } +} + +/** `systemd-run` arguments for a background unit running `argv`. */ +export function unitArgs(unit: string, description: string, argv: string[]): string[] { + return ["--user", "--quiet", "--collect", `--unit=${unit}`, `--description=${description}`, ...argv]; +} + +/** Station tags: `"jazz,france, public radio"` → `["jazz", "france", "public radio"]`. */ +export function tags(s: Pick<Station, "tags">, limit = 8): string[] { + const seen = new Set<string>(); + return (s.tags ?? "").split(",").map((t) => t.trim().toLowerCase()).filter((t) => { + if (!t || t.length > 24 || seen.has(t)) return false; + seen.add(t); + return true; + }).slice(0, limit); +} + +/** "AAC+ · 128 kbps", "MP3", "HLS". */ +export function quality(s: Pick<Station, "codec" | "bitrate" | "hls">): string { + const codec = s.codec && s.codec !== "UNKNOWN" ? s.codec : s.hls ? "HLS" : ""; + return [codec, s.bitrate ? `${s.bitrate} kbps` : ""].filter(Boolean).join(" · "); +} + +const GENRES: [RegExp, Color][] = [ + [/jazz|blues|soul|funk/, "purple"], + [/classical|classique|opera|baroque/, "teal"], + [/news|talk|info|actualit|sport|public radio/, "blue"], + [/rock|metal|punk|grunge|indie/, "red"], + [/electro|dance|house|techno|trance|edm|ambient|chill|lounge|lofi|lo-fi/, "pink"], + [/pop|hits|top 40|charts|80s|90s|oldies/, "orange"], + [/country|folk|world|latin|reggae/, "green"], + [/hip.?hop|rap|r&b|urban/, "yellow"], +]; + +/** A color for the station's icon, from its genres. */ +export function genreColor(s: Pick<Station, "tags">): Color { + const t = tags(s, 20).join(" "); + return GENRES.find(([re]) => re.test(t))?.[1] ?? "gray"; +} + +/** What the user typed for the Country view → radio-browser parameters. */ +export function countryParams(query: string): Record<string, string> { + const q = query.trim(); + if (/^[a-z]{2}$/i.test(q)) return { countrycode: q.toUpperCase() }; + return { country: q }; +} + +/** Merges name and tag results: exact and name matches first, then by votes, without duplicates. */ +export function mergeResults(byName: Station[], byTag: Station[], query: string, limit = 60): Station[] { + const q = query.trim().toLowerCase(); + const seen = new Set<string>(); + const all = [...byName, ...byTag].filter((s) => { + if (!s.stationuuid || seen.has(s.stationuuid)) return false; + seen.add(s.stationuuid); + return true; + }); + const score = (s: Station) => { + const n = s.name.trim().toLowerCase(); + return n === q ? 0 : n.startsWith(q) ? 1 : n.includes(q) ? 2 : 3; + }; + return all.map((s, i) => ({ s, i })).sort((a, b) => + score(a.s) - score(b.s) || (b.s.votes ?? 0) - (a.s.votes ?? 0) || a.i - b.i + ).slice(0, limit).map((x) => x.s); +} + +/** + * An mpv script writing the song of the stream's ICY metadata to `out` each + * time it changes (the key's case varies by server), and removing it on exit. + */ +export function songScript(out: string): string { + return [ + `local out = ${JSON.stringify(out)}`, + "local function save(_, m)", + ' local title = ""', + ' if m then for k, v in pairs(m) do if string.lower(k) == "icy-title" then title = v end end end', + ' local f = io.open(out, "w")', + " if f then f:write(title) f:close() end", + "end", + 'mp.observe_property("metadata", "native", save)', + 'mp.register_event("shutdown", function() os.remove(out) end)', + "", + ].join("\n"); +} + +/** The song file's text → the song, ignoring placeholders like " - ". */ +export function songTitle(text: string | undefined): string | undefined { + const value = (text ?? "").trim(); + return value && !/^[\s\-–]*$/.test(value) ? value : undefined; +} + +/** "3 min", "1 h 05": how long it has been playing. */ +export function elapsed(ms: number): string { + const min = Math.floor(ms / 60_000); + if (min < 1) return "just started"; + if (min < 60) return `${min} min`; + return `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, "0")}`; +} + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +/** Distro family from /etc/os-release, for install hints. */ +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<Family, string> = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +/** `sudo apt install mpv`, or a generic hint. */ +export function installCommand(family: Family | undefined, pkg = "mpv"): string { + return family ? `${MANAGERS[family]} ${pkg}` : `Install ${pkg} with your package manager`; +} diff --git a/extensions/radio/lib_test.ts b/extensions/radio/lib_test.ts new file mode 100644 index 0000000..66cb2ce --- /dev/null +++ b/extensions/radio/lib_test.ts @@ -0,0 +1,168 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + countryParams, + elapsed, + genreColor, + installCommand, + mergeResults, + osFamily, + playerArgs, + playerChoice, + program, + quality, + songScript, + songTitle, + type Station, + tags, + unitArgs, +} from "./lib.ts"; +import { noPlayerItem, nowPlayingItem, stationItem } from "./views.ts"; + +// Shaped like `/json/stations/search` results. +const fip: Station = { + stationuuid: "f4ae3a6e-0601-11e8-ae97-52543be04c81", + name: "FIP", + url: "http://icecast.radiofrance.fr/fip-hifi.aac", + url_resolved: "http://icecast.radiofrance.fr/fip-hifi.aac", + homepage: "https://www.radiofrance.fr/fip", + favicon: "https://www.radiofrance.fr/favicon.ico", + tags: "eclectic,jazz,france, public radio,Jazz", + country: "France", + countrycode: "FR", + state: "Paris", + language: "french", + votes: 25123, + codec: "AAC", + bitrate: 192, + hls: 0, + lastcheckok: 1, + clickcount: 3123, + clicktrend: 12, +}; +const soma: Station = { + ...fip, + stationuuid: "9608b51d", + name: "SomaFM Groove Salad", + tags: "ambient,chillout,electronic", + votes: 9000, + codec: "MP3", + bitrate: 128, +}; +const fipJazz: Station = { ...fip, stationuuid: "fip-jazz", name: "FIP Jazz", votes: 3000 }; + +Deno.test("players", () => { + assertEquals(playerChoice("auto"), ["mpv", "ffplay", "vlc"]); + assertEquals(playerChoice(" MPV "), ["mpv"]); + assertEquals(playerChoice("cvlc"), ["vlc"]); + assertEquals(playerChoice(undefined), ["mpv", "ffplay", "vlc"]); + assertEquals(program("vlc"), "cvlc"); + assertEquals( + playerArgs("mpv", "http://s/x.aac", { script: "/h/.cache/backslash/radio/now-playing.lua", title: "FIP" }), + [ + "mpv", + "--no-video", + "--no-terminal", + "--idle=no", + "--cache=yes", + "--demuxer-max-bytes=4MiB", + "--script=/h/.cache/backslash/radio/now-playing.lua", + "--title=FIP", + "http://s/x.aac", + ], + ); + assertEquals(playerArgs("ffplay", "http://s"), ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", "http://s"]); + assertEquals(playerArgs("vlc", "http://s").slice(0, 3), ["cvlc", "--intf", "dummy"]); + assertEquals(unitArgs("backslash-radio", "Backslash Radio: FIP", ["mpv", "u"]), [ + "--user", + "--quiet", + "--collect", + "--unit=backslash-radio", + "--description=Backslash Radio: FIP", + "mpv", + "u", + ]); +}); + +Deno.test("song title script", () => { + const lua = songScript('/home/a "b"/.cache/backslash/radio/song.txt'); + assertStringIncludes(lua, 'local out = "/home/a \\"b\\"/.cache/backslash/radio/song.txt"'); + assertStringIncludes(lua, 'mp.observe_property("metadata", "native", save)'); + assertEquals(songTitle("Bonobo - Kerala\n"), "Bonobo - Kerala"); + assertEquals(songTitle(" - "), undefined); + assertEquals(songTitle(""), undefined); + assertEquals(songTitle(undefined), undefined); +}); + +Deno.test("stations", () => { + assertEquals(tags(fip), ["eclectic", "jazz", "france", "public radio"]); + assertEquals(tags({ tags: "" }), []); + assertEquals(quality(fip), "AAC · 192 kbps"); + assertEquals(quality({ codec: "UNKNOWN", bitrate: 0, hls: 1 }), "HLS"); + assertEquals(quality({ codec: "MP3" }), "MP3"); + assertEquals(genreColor(fip), "purple"); + assertEquals(genreColor(soma), "pink"); + assertEquals(genreColor({ tags: "news,talk" }), "blue"); + assertEquals(genreColor({ tags: "" }), "gray"); + assertEquals(countryParams("fr"), { countrycode: "FR" }); + assertEquals(countryParams("Japan"), { country: "Japan" }); +}); + +Deno.test("merging name and tag results", () => { + assertEquals(mergeResults([fipJazz, fip], [soma, fip], "fip").map((s) => s.name), [ + "FIP", + "FIP Jazz", + "SomaFM Groove Salad", + ]); + assertEquals(mergeResults([], [soma, fipJazz], "jazz").map((s) => s.name), ["FIP Jazz", "SomaFM Groove Salad"]); +}); + +Deno.test("install hints", () => { + assertEquals(osFamily('NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n'), "debian"); + assertEquals(osFamily("ID=fedora\n"), "fedora"); + assertEquals(osFamily('ID="opensuse-tumbleweed"\n'), "suse"); + assertEquals(osFamily("ID=endeavouros\nID_LIKE=arch\n"), "arch"); + assertEquals(osFamily("ID=nixos\n"), undefined); + assertEquals(installCommand("arch"), "sudo pacman -S mpv"); + assertEquals(installCommand(undefined), "Install mpv with your package manager"); + assertEquals(noPlayerItem("fedora").subtitle, "sudo dnf install mpv"); +}); + +Deno.test("formatting", () => { + assertEquals(elapsed(20_000), "just started"); + assertEquals(elapsed(25 * 60_000), "25 min"); + assertEquals(elapsed(65 * 60_000), "1 h 05"); +}); + +Deno.test("station items", () => { + const item = stationItem(fip, { favorite: true, playing: false, choices: ["mpv"] }); + assertEquals(item.title, "FIP"); + assertEquals(item.subtitle, "eclectic, jazz, france"); + assertEquals(item.icon, { source: "bs-radio", tint: "purple" }); + assertEquals(item.accessories?.map((a) => a.text ?? a.tag ?? a.tooltip), [ + "In your favorites", + "AAC · 192 kbps", + "FR", + ]); + assertEquals(item.actions?.map((a) => a.title), [ + "Play", + "Remove from Favorites", + "Open Website", + "Copy Stream URL", + "Copy Name", + ]); + const playing = stationItem(fip, { favorite: false, playing: true, choices: ["mpv"], section: "Top Voted" }); + assertEquals(playing.icon, { source: "bs-speaker", tint: "green" }); + assertEquals(playing.actions?.[0].title, "Stop"); + + const now = nowPlayingItem({ station: fip, player: "mpv", started: Date.now() - 3 * 60_000 }, "Bonobo - Kerala"); + assertEquals([now.title, now.subtitle], ["Bonobo - Kerala", "FIP"]); + assertEquals(now.accessories?.[0].text, "3 min"); + assertEquals(now.actions?.map((a) => a.title), [ + "Stop", + "Copy Song Title", + "Search on YouTube", + "Open Station Website", + ]); + const quiet = nowPlayingItem({ station: fip, player: "ffplay", started: Date.now() }, undefined); + assertEquals([quiet.title, quiet.subtitle], ["FIP", "Playing with ffplay"]); +}); diff --git a/extensions/radio/player.ts b/extensions/radio/player.ts new file mode 100644 index 0000000..b27616a --- /dev/null +++ b/extensions/radio/player.ts @@ -0,0 +1,126 @@ +// Playing a station in the background: a transient systemd user unit running +// mpv (or ffplay, or VLC), so it keeps playing once the launcher is hidden. + +import { exec } from "@backslash/api"; +import { click, load, save } from "./api.ts"; +import { + type Family, + osFamily, + type Player, + playerArgs, + program, + songScript, + songTitle, + type Station, + unitArgs, +} from "./lib.ts"; + +export const UNIT = "backslash-radio"; + +export interface Playing { + station: Station; + player: Player; + started: number; +} + +const home = () => Deno.env.get("HOME") ?? ""; +const dir = () => `${home()}/.cache/backslash/radio`; +const scriptPath = () => `${dir()}/now-playing.lua`; +const songPath = () => `${dir()}/song.txt`; + +/** Whether a program can be started: installed and allowed. */ +async function installed(name: string, args: string[]): Promise<boolean> { + try { + await new Deno.Command(name, { + 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 VERSION_ARGS: Record<Player, string[]> = { mpv: ["--version"], ffplay: ["-version"], vlc: ["--version"] }; + +/** The first of `choices` that is installed. */ +export async function findPlayer(choices: Player[]): Promise<Player | undefined> { + for (const p of choices) if (await installed(program(p), VERSION_ARGS[p])) return p; + return undefined; +} + +export async function distro(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +async function unitActive(): Promise<boolean> { + try { + return (await exec("systemctl", ["--user", "is-active", `${UNIT}.service`])) === "active"; + } catch { + return false; // is-active exits 3 when the unit is gone. + } +} + +/** What is playing, if the unit still runs. */ +export async function nowPlaying(): Promise<Playing | undefined> { + const saved = await load<Playing>("playing"); + if (!saved) return undefined; + if (await unitActive()) return saved; + await save("playing", null); + return undefined; +} + +export async function stop(): Promise<boolean> { + const was = await load<Playing>("playing"); + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + await save("playing", null); + return !!was; +} + +export class NoPlayer extends Error {} + +/** Starts the station in the background, replacing what was playing. */ +export async function play(station: Station, choices: Player[]): Promise<Playing> { + const player = await findPlayer(choices); + if (!player) throw new NoPlayer("No player found: install mpv to listen to the radio"); + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + const url = await click(station); + let script: string | undefined; + if (player === "mpv" && home()) { + try { + await Deno.mkdir(dir(), { recursive: true }); + await Deno.remove(songPath()).catch(() => {}); + await Deno.writeTextFile(scriptPath(), songScript(songPath())); + script = scriptPath(); + } catch { + // Plays without the song title. + } + } + const argv = playerArgs(player, url, { script, title: station.name }); + try { + await exec("systemd-run", unitArgs(UNIT, `Backslash Radio: ${station.name}`, argv)); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to play in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: stations play in the background with systemd-run --user"); + } + throw e; + } + // A stream that can't be opened makes the player quit at once. + await new Promise((r) => setTimeout(r, 1200)); + if (!await unitActive()) throw new Error(`${station.name} could not be played: the stream is offline or unsupported`); + const playing = { station, player, started: Date.now() }; + await save("playing", playing); + return playing; +} + +/** The song mpv read from the stream's ICY metadata, if any. */ +export async function song(): Promise<string | undefined> { + if (!home()) return undefined; + return songTitle(await Deno.readTextFile(songPath()).catch(() => undefined)); +} diff --git a/extensions/radio/views.ts b/extensions/radio/views.ts new file mode 100644 index 0000000..371eb84 --- /dev/null +++ b/extensions/radio/views.ts @@ -0,0 +1,198 @@ +// Stations and what's playing as launcher items. + +import { + type Accessory, + type Action, + action, + close, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { toggleFavorite } from "./api.ts"; +import { + compact, + elapsed, + type Family, + genreColor, + installCommand, + type Player, + quality, + type Station, + tags, +} from "./lib.ts"; +import { NoPlayer, play, type Playing, stop } from "./player.ts"; + +const PLAYER_NAMES: Record<Player, string> = { mpv: "mpv", ffplay: "ffplay", vlc: "VLC" }; + +export function playAction(s: Station, choices: Player[], title = "Play"): Action { + return action(title, async () => { + try { + await play(s, choices); + } catch (e) { + if (e instanceof NoPlayer) { + await toast(e.message, { style: "failure" }); + return refresh(); + } + throw e; + } + updateCommandMetadata({ subtitle: `Playing ${s.name}` }).catch(() => {}); + return close(`Playing ${s.name}`); + }, { icon: Icons.play }); +} + +export const stopAction = (name: string) => + action("Stop", async () => { + await stop(); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + await toast(`${name} stopped`, { style: "success" }); + return refresh(); + }, { icon: Icons.stop, shortcut: "<Ctrl>period" }); + +function stationMetadata(s: Station): Metadata[] { + const lines: Metadata[] = []; + const t = tags(s); + if (t.length) lines.push(meta.tags("Genres", t.map((text) => ({ text, color: genreColor({ tags: text }) })))); + const where = [s.state, s.country].filter(Boolean).join(", "); + if (where) lines.push(meta.text("Country", where, { icon: Icons.globe })); + if (s.language) lines.push(meta.text("Language", s.language.split(",").slice(0, 3).join(", "))); + const q = quality(s); + if (q) lines.push(meta.text("Stream", q, { icon: Icons.radio })); + if (s.votes) lines.push(meta.text("Votes", compact(s.votes), { icon: Icons.heart })); + if (s.clickcount) { + lines.push( + meta.text( + "Listeners today", + `${compact(s.clickcount)}${s.clicktrend ? ` (${s.clicktrend > 0 ? "+" : ""}${s.clicktrend})` : ""}`, + { + icon: Icons.activity, + }, + ), + ); + } + if (s.lastcheckok === 0) { + lines.push(meta.text("Status", "Offline at the last check", { color: "red", icon: Icons.alert })); + } + if (s.homepage) { + let host = s.homepage; + try { + host = new URL(s.homepage).hostname.replace(/^www\./, ""); + } catch { + // Shown as is. + } + lines.push(meta.separator(), meta.link("Website", host, s.homepage)); + } + return lines; +} + +export function stationItem( + s: Station, + o: { favorite: boolean; playing: boolean; choices: Player[]; section?: string }, +): Item { + const accessories: Accessory[] = []; + if (o.playing) accessories.push({ tag: "Playing", color: "green" }); + if (o.favorite && o.section !== "Favorites") { + accessories.push({ icon: { source: Icons.heartFilled, tint: "pink" }, tooltip: "In your favorites" }); + } + const q = quality(s); + if (q) accessories.push({ text: q, tooltip: "Codec and bitrate" }); + if (s.countrycode) accessories.push({ tag: s.countrycode, tooltip: s.country }); + const t = tags(s, 4); + return { + id: `${o.section ?? ""}:${s.stationuuid}`, + title: s.name.trim(), + subtitle: t.slice(0, 3).join(", "), + section: o.section, + icon: { source: o.playing ? Icons.speaker : Icons.radio, tint: o.playing ? "green" : genreColor(s) }, + keywords: [...t, s.country ?? "", s.countrycode ?? ""], + accessories, + detail: { + stats: [ + ...(s.bitrate ? [{ label: "Bitrate", value: `${s.bitrate} kbps` }] : []), + ...(s.votes ? [{ label: "Votes", value: compact(s.votes) }] : []), + ...(s.countrycode ? [{ label: "Country", value: s.countrycode }] : []), + ], + markdown: `**${s.name.trim()}**`, + metadata: stationMetadata(s), + }, + actions: [ + o.playing ? stopAction(s.name) : playAction(s, o.choices), + action(o.favorite ? "Remove from Favorites" : "Add to Favorites", async () => { + const now = await toggleFavorite(s); + await toast(`${s.name} ${now ? "added to" : "removed from"} favorites`, { style: "success" }); + return refresh(); + }, { icon: o.favorite ? Icons.heartFilled : Icons.heart, shortcut: "<Ctrl>s" }), + ...(s.homepage ? [open(s.homepage, "Open Website", { shortcut: "<Ctrl>o" })] : []), + copy(s.url_resolved || s.url, "Copy Stream URL", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(s.name.trim(), "Copy Name", { section: "Copy" }), + ], + }; +} + +export function nowPlayingItem(p: Playing, song: string | undefined): Item { + const s = p.station; + const search = song ?? s.name; + return { + id: "now-playing", + title: song ?? s.name.trim(), + subtitle: song ? s.name.trim() : `Playing with ${PLAYER_NAMES[p.player]}`, + section: "Now Playing", + icon: { source: Icons.speaker, tint: "green" }, + accessories: [{ text: elapsed(Date.now() - p.started), icon: Icons.clock, tooltip: "Playing for" }], + detail: { + markdown: song ? `**${song}**\n\non ${s.name.trim()}` : `**${s.name.trim()}**`, + metadata: [ + meta.text("Station", s.name.trim(), { icon: Icons.radio }), + ...(song ? [meta.text("Song", song, { icon: Icons.music })] : []), + meta.text("Player", PLAYER_NAMES[p.player], { icon: Icons.speaker }), + meta.text("Started", new Date(p.started).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }), { + icon: Icons.clock, + }), + ...stationMetadata(s), + ], + }, + actions: [ + stopAction(s.name), + ...(song ? [copy(song, "Copy Song Title", { shortcut: "<Ctrl><Shift>c" })] : []), + open(`https://www.youtube.com/results?search_query=${encodeURIComponent(search)}`, "Search on YouTube", { + icon: Icons.search, + section: "Open", + }), + ...(s.homepage ? [open(s.homepage, "Open Station Website", { section: "Open" })] : []), + ], + }; +} + +export function noPlayerItem(family: Family | undefined): Item { + const cmd = installCommand(family, "mpv"); + return { + id: "no-player", + title: "Install mpv to listen", + subtitle: family ? cmd : "mpv, ffplay (ffmpeg) or VLC", + section: "Setup", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "## No audio player found", + "", + "Stations play in the background with **mpv** (recommended: it also shows the song title),", + "**ffplay** (part of FFmpeg) or **VLC**. Install one:", + "", + "```", + cmd, + "```", + "", + "Then pick it in **Settings → Extensions → Internet Radio → Configure**, or leave `auto`.", + ].join("\n"), + }, + actions: family + ? [copy(cmd, "Copy Install Command")] + : [open("https://mpv.io/installation/", "How to Install mpv")], + }; +} diff --git a/extensions/reddit/api.ts b/extensions/reddit/api.ts new file mode 100644 index 0000000..8c146e2 --- /dev/null +++ b/extensions/reddit/api.ts @@ -0,0 +1,135 @@ +// Reddit's public JSON endpoints (no account). Reddit refuses unauthenticated +// requests from some networks (403) and rate limits them (429): the last +// listing is kept to be shown when that happens. + +import { storage } from "@backslash/api"; +import { + type Comment, + commentsOf, + type Listing, + listingPath, + parseSubreddits, + type Post, + postsOf, + type RedditComment, + type RedditPost, + refusal, + type Sort, +} from "./lib.ts"; + +const BASE = "https://www.reddit.com"; +// Reddit asks for a descriptive User-Agent: <platform>:<app id>:<version> (by /u/<user>). +const HEADERS = { "User-Agent": "linux:app.backslash.reddit:1.0.0 (by /u/backslash-app)", Accept: "application/json" }; + +type Json = Parameters<typeof storage.set>[1]; + +/** Reddit refused: `blocked` when it's about this network or the rate, not the subreddit. */ +export class Refused extends Error { + constructor(message: string, readonly blocked: boolean) { + super(message); + } +} + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +async function get<T>(path: string, signal?: AbortSignal): Promise<T> { + const timeout = AbortSignal.timeout(15_000); + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + headers: HEADERS, + redirect: "manual", + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Refused( + timeout.aborted ? "Reddit is taking too long to answer" : `Reddit is unreachable: ${(e as Error).message}`, + true, + ); + } + // A subreddit that doesn't exist redirects to the search page. + if (res.status >= 300 && res.status < 400) { + await res.body?.cancel(); + throw new Refused("This subreddit doesn't exist", false); + } + const text = await res.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = undefined; + } + if (!res.ok) { + const r = refusal(res.status, body, res.headers.get("retry-after")); + throw new Refused(r.message, r.blocked); + } + if (body === undefined) throw new Refused("Reddit answered with a web page instead of data (blocked?)", true); + return body as T; +} + +export interface Loaded { + posts: Post[]; + /** When the posts are a saved copy because Reddit refused. */ + savedAt?: number; + refused?: Refused; +} + +/** A listing; when Reddit blocks or rate limits, the last copy if there is one. */ +export async function listing(subs: string[], sort: Sort, nsfw: boolean, signal?: AbortSignal): Promise<Loaded> { + const path = listingPath(subs, sort); + const key = `listing:${path}`; + try { + const l = await get<Listing<RedditPost>>(path, signal); + const posts = postsOf(l, nsfw); + await save(key, { at: Date.now(), posts }); + return { posts }; + } catch (e) { + if (!(e instanceof Refused) || !e.blocked) throw e; + const saved = await load<{ at: number; posts: Post[] }>(key); + if (!saved) throw e; + return { posts: saved.posts.filter((p) => nsfw || !p.nsfw), savedAt: saved.at, refused: e }; + } +} + +export async function comments(id: string, signal?: AbortSignal): Promise<Comment[]> { + const answer = await get<[unknown, Listing<RedditComment>]>( + `/comments/${encodeURIComponent(id)}.json?limit=12&depth=1&sort=top&raw_json=1`, + signal, + ); + return commentsOf(answer); +} + +// --------------------------------------------------------------------------- +// Subreddits: the preference's, then those added from the launcher. + +export async function added(): Promise<string[]> { + return await load<string[]>("subreddits") ?? []; +} + +export async function subreddits( + preferences: Record<string, unknown>, +): Promise<{ name: string; fromPrefs: boolean }[]> { + const prefs = parseSubreddits(preferences.subreddits); + const mine = parseSubreddits((await added()).join(" ")); + const seen = new Set(prefs.map((s) => s.toLowerCase())); + return [ + ...prefs.map((name) => ({ name, fromPrefs: true })), + ...mine.filter((s) => !seen.has(s.toLowerCase())).map((name) => ({ name, fromPrefs: false })), + ]; +} + +export async function addSubreddit(name: string): Promise<void> { + const list = await added(); + if (!list.some((s) => s.toLowerCase() === name.toLowerCase())) await save("subreddits", [...list, name]); +} + +export async function removeSubreddit(name: string): Promise<void> { + await save("subreddits", (await added()).filter((s) => s.toLowerCase() !== name.toLowerCase())); +} diff --git a/extensions/reddit/backslash.toml b/extensions/reddit/backslash.toml new file mode 100644 index 0000000..99c8fce --- /dev/null +++ b/extensions/reddit/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "reddit" +title = "Reddit" +description = "Hot, new and top posts from your subreddits with scores, comments, flairs, pictures and the top comments, without an account" +version = "1.0.0" +author = "Backslash" +icon = "bs-comment" +license = "MIT" + +# Reddit's public JSON endpoints on www.reddit.com, and the hosts of post +# pictures: i.redd.it (uploads), preview.redd.it and external-preview.redd.it +# (previews of uploads and links). +[permissions] +net = ["www.reddit.com", "i.redd.it", "preview.redd.it", "external-preview.redd.it"] + +[[commands]] +name = "reddit" +title = "Reddit" +description = "Posts from your subreddits: Hot, New or Top today" +icon = "bs-comment" +keywords = ["reddit", "subreddit", "posts", "news", "front page", "forum", "actualités", "fil", "communauté"] + +[[commands]] +name = "subreddits" +title = "Reddit Subreddits" +description = "Browse one of your subreddits, add or remove subreddits" +icon = "bs-hash" +keywords = ["reddit", "subreddit", "subreddits", "communities", "add subreddit", "communautés", "abonnements"] + +[[preferences]] +name = "subreddits" +title = "Subreddits" +description = "Subreddits to follow, separated by commas or spaces (more can be added from the launcher)" +default = "linux, programming, technology" + +[[preferences]] +name = "nsfw" +title = "Show NSFW posts" +description = "Posts marked NSFW are hidden unless this is on" +type = "boolean" +default = false diff --git a/extensions/reddit/commands/reddit.ts b/extensions/reddit/commands/reddit.ts new file mode 100644 index 0000000..a140e5d --- /dev/null +++ b/extensions/reddit/commands/reddit.ts @@ -0,0 +1,39 @@ +import { type Context, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { listing, subreddits } from "../api.ts"; +import type { Sort } from "../lib.ts"; +import { loadPosts, postItem, SORTS, subredditList, subUrl } from "../views.ts"; + +let note = ""; +let names: string[] = []; + +export default (ctx: Context) => { + const nsfw = ctx.preferences.nsfw === true; + return list({ + placeholder: "Filter posts by title, subreddit, author or flair…", + filter: { options: SORTS }, + empty: { + title: "No subreddits", + description: "Add some in the preferences, or with the Reddit Subreddits command", + icon: Icons.comment, + }, + status: (items) => + note || + `${items.length} posts · ${ + names.length > 3 ? `${names.length} subreddits` : names.map((n) => `r/${n}`).join(", ") + }`, + items: async ({ filter, signal }) => { + const sort = (SORTS.some((s) => s.value === filter) ? filter : "hot") as Sort; + names = (await subreddits(ctx.preferences)).map((s) => s.name); + if (!names.length) return []; + const items = await loadPosts( + () => listing(names, sort, nsfw, signal), + subUrl(names.join("+"), sort), + (p) => postItem(p, { multi: names.length > 1, browse: (sub) => subredditList(sub, nsfw) }), + (t) => (note = t), + ); + const top = items.find((i) => i.id !== "refused"); + if (sort === "hot" && top) updateCommandMetadata({ subtitle: top.title.slice(0, 60) }).catch(() => {}); + return items; + }, + }); +}; diff --git a/extensions/reddit/commands/subreddits.ts b/extensions/reddit/commands/subreddits.ts new file mode 100644 index 0000000..6491371 --- /dev/null +++ b/extensions/reddit/commands/subreddits.ts @@ -0,0 +1,69 @@ +import { action, type Context, copy, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { addSubreddit, removeSubreddit, subreddits } from "../api.ts"; +import { subredditName } from "../lib.ts"; +import { subredditList, subUrl } from "../views.ts"; + +export default (ctx: Context) => { + const nsfw = ctx.preferences.nsfw === true; + return list({ + placeholder: "Filter your subreddits, or type one to add it…", + empty: { title: "No subreddits yet", description: "Type a subreddit's name to add it", icon: Icons.hash }, + status: (items) => { + const n = items.filter((i) => i.id !== "add").length; + return `${n} subreddit${n === 1 ? "" : "s"}`; + }, + async search(query) { + const subs = await subreddits(ctx.preferences); + const q = query.trim().replace(/^\/?r\//i, "").toLowerCase(); + const shown = subs.filter((s) => s.name.toLowerCase().includes(q)); + const items: Item[] = shown.map((s) => ({ + id: s.name, + title: `r/${s.name}`, + subtitle: s.fromPrefs ? "From the preferences" : "Added from the launcher", + icon: { source: Icons.hash, tint: "orange" }, + detail: { + markdown: `**r/${s.name}**\n\nEnter shows its hot, new and top posts.${ + s.fromPrefs ? "\n\nSet in **Settings → Extensions → Reddit → Configure**." : "" + }`, + metadata: [meta.link("Reddit", `reddit.com/r/${s.name}`, `https://www.reddit.com/r/${s.name}/`)], + }, + actions: [ + action("Browse Posts", () => subredditList(s.name, nsfw), { icon: Icons.comment }), + open(subUrl(s.name), "Open in Browser"), + copy(`https://www.reddit.com/r/${s.name}/`, "Copy Link", { section: "Copy" }), + ...(s.fromPrefs ? [] : [ + action("Remove Subreddit", async () => { + await removeSubreddit(s.name); + await toast(`r/${s.name} removed`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Remove r/${s.name}?`, + section: "Danger", + shortcut: "<Ctrl>Delete", + }), + ]), + ], + })); + const name = subredditName(query); + if (name && !subs.some((s) => s.name.toLowerCase() === name.toLowerCase())) { + items.unshift({ + id: "add", + title: `Add r/${name}`, + subtitle: "Follow it in the Reddit command", + icon: { source: Icons.plus, tint: "green" }, + actions: [ + action(`Add r/${name}`, async () => { + await addSubreddit(name); + await toast(`r/${name} added`, { style: "success" }); + return refresh(); + }, { icon: Icons.plus }), + action(`Browse r/${name}`, () => subredditList(name, nsfw), { icon: Icons.comment }), + ], + }); + } + return items; + }, + }); +}; diff --git a/extensions/reddit/lib.ts b/extensions/reddit/lib.ts new file mode 100644 index 0000000..6088c09 --- /dev/null +++ b/extensions/reddit/lib.ts @@ -0,0 +1,305 @@ +// Reddit listings and comments → posts, and their formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +interface Image { + url: string; + width: number; + height: number; +} + +/** A `t3` (link) of a listing, with `raw_json=1` (no HTML escaping). */ +export interface RedditPost { + id: string; + name?: string; + title: string; + subreddit: string; + author: string; + score: number; + num_comments: number; + created_utc: number; + permalink: string; + url?: string; + url_overridden_by_dest?: string; + domain?: string; + is_self?: boolean; + is_video?: boolean; + is_gallery?: boolean; + selftext?: string; + link_flair_text?: string | null; + link_flair_background_color?: string | null; + over_18?: boolean; + spoiler?: boolean; + stickied?: boolean; + locked?: boolean; + post_hint?: string; + upvote_ratio?: number; + thumbnail?: string; + preview?: { images?: { source: Image; resolutions?: Image[] }[] }; +} + +export interface RedditComment { + id: string; + author: string; + body?: string; + score: number; + created_utc: number; + stickied?: boolean; + distinguished?: string | null; + is_submitter?: boolean; +} + +export interface Listing<T> { + kind: "Listing"; + data: { after?: string | null; children: { kind: string; data: T }[] }; +} + +export interface Post { + id: string; + title: string; + subreddit: string; + author: string; + score: number; + comments: number; + created: number; + permalink: string; + /** The link, for link posts. */ + link?: string; + domain?: string; + self: boolean; + text?: string; + flair?: string; + flairColor?: string; + nsfw: boolean; + spoiler: boolean; + pinned: boolean; + locked: boolean; + kind: "text" | "image" | "video" | "gallery" | "link"; + image?: string; + ratio?: number; +} + +export interface Comment { + author: string; + body: string; + score: number; + created: number; + op: boolean; + mod: boolean; +} + +// --------------------------------------------------------------------------- +// Subreddits + +/** `"r/Linux, programming /r/rust"` → `["Linux", "programming", "rust"]`, deduplicated without case. */ +export function parseSubreddits(text: unknown): string[] { + const seen = new Set<string>(); + return String(text ?? "").split(/[\s,;+]+/).map((s) => s.replace(/^\/?r\//i, "").replace(/\/+$/, "").trim()) + .filter((s) => { + if (!/^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/.test(s) || seen.has(s.toLowerCase())) return false; + seen.add(s.toLowerCase()); + return true; + }); +} + +/** A subreddit typed by the user, or undefined when it can't be one. */ +export const subredditName = (text: string) => parseSubreddits(text)[0]; + +export type Sort = "hot" | "new" | "top"; + +export function listingPath(subs: string[], sort: Sort, limit = 50): string { + const q = new URLSearchParams({ limit: String(limit), raw_json: "1" }); + if (sort === "top") q.set("t", "day"); + return `/r/${subs.map(encodeURIComponent).join("+")}/${sort}.json?${q}`; +} + +// --------------------------------------------------------------------------- +// Posts + +const IMAGE_HOSTS = ["i.redd.it", "preview.redd.it", "external-preview.redd.it"]; + +const hostOf = (url: string) => { + try { + return new URL(url).hostname; + } catch { + return ""; + } +}; + +/** A picture the launcher may show: on Reddit's image hosts, not too big. */ +export function pictureOf(p: RedditPost): string | undefined { + const img = p.preview?.images?.[0]; + if (img) { + const fit = [...(img.resolutions ?? [])].filter((r) => r.width <= 960).sort((a, b) => b.width - a.width)[0]; + const url = (fit ?? img.source).url; + if (IMAGE_HOSTS.includes(hostOf(url))) return url; + } + const direct = p.url_overridden_by_dest ?? p.url; + if (direct && hostOf(direct) === "i.redd.it" && /\.(jpe?g|png|gif)$/i.test(direct)) return direct; + return undefined; +} + +export function fromReddit(p: RedditPost): Post { + const self = !!p.is_self; + const link = self ? undefined : p.url_overridden_by_dest ?? p.url; + const kind: Post["kind"] = self + ? "text" + : p.is_gallery + ? "gallery" + : p.is_video || p.post_hint === "hosted:video" || p.post_hint === "rich:video" + ? "video" + : p.post_hint === "image" || (link && hostOf(link) === "i.redd.it") + ? "image" + : "link"; + return { + id: p.id, + title: p.title.trim(), + subreddit: p.subreddit, + author: p.author, + score: p.score, + comments: p.num_comments, + created: p.created_utc * 1000, + permalink: `https://www.reddit.com${p.permalink}`, + link, + domain: self ? undefined : p.domain, + self, + text: p.selftext?.trim() || undefined, + flair: p.link_flair_text?.trim() || undefined, + flairColor: p.link_flair_background_color || undefined, + nsfw: !!p.over_18, + spoiler: !!p.spoiler, + pinned: !!p.stickied, + locked: !!p.locked, + kind, + image: p.over_18 || p.spoiler ? undefined : pictureOf(p), + ratio: p.upvote_ratio, + }; +} + +export function postsOf(listing: Listing<RedditPost> | undefined, nsfw = false): Post[] { + return (listing?.data?.children ?? []).filter((c) => c.kind === "t3").map((c) => fromReddit(c.data)) + .filter((p) => nsfw || !p.nsfw); +} + +/** `/comments/<id>.json` → the top-level comments, best first, without removed ones and bots' pins. */ +export function commentsOf(answer: [unknown, Listing<RedditComment>] | undefined, limit = 8): Comment[] { + const children = answer?.[1]?.data?.children ?? []; + return children.filter((c) => c.kind === "t1").map((c) => c.data) + .filter((c) => c.body && c.body !== "[removed]" && c.body !== "[deleted]" && !(c.stickied && c.distinguished)) + .slice(0, limit) + .map((c) => ({ + author: c.author, + body: c.body!.trim(), + score: c.score, + created: c.created_utc * 1000, + op: !!c.is_submitter, + mod: c.distinguished === "moderator", + })); +} + +// --------------------------------------------------------------------------- +// Formatting + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +/** "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" }); +} + +/** "Wed 28 Oct, 05:37". */ +export const fullDate = (ms: number) => + new Date(ms).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); + +/** A flair's background color → the nearest launcher color. */ +export function flairColor(hex: string | undefined): Color { + const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const n = parseInt(m[1], 16); + const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max - min < 30) return "gray"; + const d = max - min; + let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + h = (h * 60 + 360) % 360; + if (h < 15 || h >= 345) return "red"; + if (h < 40) return "orange"; + if (h < 65) return "yellow"; + if (h < 160) return "green"; + if (h < 195) return "teal"; + if (h < 255) return "blue"; + if (h < 290) return "purple"; + return "pink"; +} + +/** Reddit markdown → the launcher's subset: spoilers hidden, superscripts and tables flattened. */ +export function cleanMarkdown(text: string, max = 1500): string { + let out = text + .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + .replace(/>!(.+?)!</g, "[spoiler]") + .replace(/\^\(([^)]*)\)/g, "$1") + .replace(/(^|\s)\^(\S+)/g, "$1$2") + .replace(/^\|?\s*:?-{3,}.*$/gm, "") + .replace(/​/gi, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (out.length > max) out = out.slice(0, max).replace(/\s+\S*$/, "") + "…"; + return out; +} + +export function commentsMarkdown(p: Post, comments: Comment[]): string { + const head = [`# ${p.title}`, "", `r/${p.subreddit} · u/${p.author} · ${ago(p.created)}`]; + if (p.text) head.push("", cleanMarkdown(p.text, 1200)); + if (!comments.length) return [...head, "", "---", "", "No comments yet."].join("\n"); + const body = comments.map((c) => + [ + `**u/${c.author}**${c.op ? " (OP)" : ""}${c.mod ? " (mod)" : ""} · ${compact(c.score)} points · ${ + ago(c.created) + }`, + "", + cleanMarkdown(c.body, 700), + ].join("\n") + ); + return [...head, "", "---", "", body.join("\n\n---\n\n")].join("\n"); +} + +/** Why Reddit refused, from the status and its JSON body. */ +export function refusal( + status: number, + body: unknown, + retryAfter?: string | null, +): { message: string; blocked: boolean } { + const reason = (body as { reason?: string } | undefined)?.reason; + if (status === 404 || reason === "banned") { + return { message: "This subreddit doesn't exist or was banned", blocked: false }; + } + if (reason === "private") return { message: "This subreddit is private", blocked: false }; + if (reason === "quarantined") { + return { message: "This subreddit is quarantined (only viewable signed in)", blocked: false }; + } + if (status === 429) { + const wait = Number(retryAfter); + return { + message: `Reddit is rate limiting${wait ? `: try again in ${Math.ceil(wait)} s` : ", try again in a minute"}`, + blocked: true, + }; + } + if (status === 403) return { message: "Reddit blocked this request from your network", blocked: true }; + return { message: `Reddit answered ${status}`, blocked: status >= 500 }; +} diff --git a/extensions/reddit/lib_test.ts b/extensions/reddit/lib_test.ts new file mode 100644 index 0000000..31f99ea --- /dev/null +++ b/extensions/reddit/lib_test.ts @@ -0,0 +1,214 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + cleanMarkdown, + commentsMarkdown, + commentsOf, + flairColor, + fromReddit, + type Listing, + listingPath, + parseSubreddits, + pictureOf, + postsOf, + type RedditComment, + type RedditPost, + refusal, + subredditName, +} from "./lib.ts"; +import { postItem, refusedItem } from "./views.ts"; +import { Refused } from "./api.ts"; + +const NOW = Date.parse("2026-10-28T10:00:00Z"); + +// Shaped like `/r/<sub>/hot.json?raw_json=1` children. +const link: RedditPost = { + id: "1abcde", + name: "t3_1abcde", + title: "Linux 7.2 released ", + subreddit: "linux", + author: "linus_fan", + score: 1523, + num_comments: 214, + created_utc: NOW / 1000 - 5 * 3600, + permalink: "/r/linux/comments/1abcde/linux_72_released/", + url: "https://lkml.org/lkml/2026/10/26/1", + url_overridden_by_dest: "https://lkml.org/lkml/2026/10/26/1", + domain: "lkml.org", + is_self: false, + link_flair_text: "Kernel", + link_flair_background_color: "#0079d3", + post_hint: "link", + upvote_ratio: 0.97, + preview: { + images: [{ + source: { url: "https://external-preview.redd.it/abc.jpg?auto=webp&s=1", width: 1200, height: 630 }, + resolutions: [ + { url: "https://external-preview.redd.it/abc.jpg?width=320&s=2", width: 320, height: 168 }, + { url: "https://external-preview.redd.it/abc.jpg?width=640&s=3", width: 640, height: 336 }, + { url: "https://external-preview.redd.it/abc.jpg?width=1080&s=4", width: 1080, height: 567 }, + ], + }], + }, +}; +const image: RedditPost = { + ...link, + id: "2fghij", + title: "My Hyprland setup", + subreddit: "unixporn", + url: "https://i.redd.it/xyz123.png", + url_overridden_by_dest: "https://i.redd.it/xyz123.png", + domain: "i.redd.it", + post_hint: "image", + preview: undefined, +}; +const self: RedditPost = { + ...link, + id: "3klmno", + title: "Weekly questions thread", + is_self: true, + domain: "self.linux", + url: "https://www.reddit.com/r/linux/comments/3klmno/x/", + url_overridden_by_dest: undefined, + selftext: "Ask anything!", + stickied: true, + link_flair_text: null, + preview: undefined, +}; +const nsfw: RedditPost = { ...image, id: "4nsfwx", over_18: true }; + +Deno.test("subreddit names", () => { + assertEquals(parseSubreddits("linux, r/programming /r/rust/ Linux;technology"), [ + "linux", + "programming", + "rust", + "technology", + ]); + assertEquals(parseSubreddits("a, -bad-, ok_one"), ["ok_one"]); + assertEquals(parseSubreddits(undefined), []); + assertEquals(subredditName(" r/Rust "), "Rust"); + assertEquals(subredditName("not a sub!"), "not"); + assertEquals(listingPath(["linux", "rust"], "hot"), "/r/linux+rust/hot.json?limit=50&raw_json=1"); + assertEquals(listingPath(["linux"], "top", 25), "/r/linux/top.json?limit=25&raw_json=1&t=day"); +}); + +Deno.test("posts", () => { + const p = fromReddit(link); + assertEquals(p.title, "Linux 7.2 released"); + assertEquals(p.kind, "link"); + assertEquals(p.link, "https://lkml.org/lkml/2026/10/26/1"); + assertEquals(p.permalink, "https://www.reddit.com/r/linux/comments/1abcde/linux_72_released/"); + assertEquals(p.image, "https://external-preview.redd.it/abc.jpg?width=640&s=3"); + const i = fromReddit(image); + assertEquals(i.kind, "image"); + assertEquals(i.image, "https://i.redd.it/xyz123.png"); + const s = fromReddit(self); + assertEquals([s.kind, s.self, s.link, s.domain, s.pinned, s.flair], [ + "text", + true, + undefined, + undefined, + true, + undefined, + ]); + assertEquals(fromReddit(nsfw).image, undefined); + assertEquals( + pictureOf({ + ...link, + preview: { images: [{ source: { url: "https://evil.example/x.jpg", width: 1, height: 1 } }] }, + }), + undefined, + ); + const listing: Listing<RedditPost> = { + kind: "Listing", + data: { children: [{ kind: "t3", data: link }, { kind: "t3", data: nsfw }, { kind: "t1", data: self }] }, + }; + assertEquals(postsOf(listing).map((p) => p.id), ["1abcde"]); + assertEquals(postsOf(listing, true).map((p) => p.id), ["1abcde", "4nsfwx"]); +}); + +Deno.test("comments", () => { + const listing: Listing<RedditComment> = { + kind: "Listing", + data: { + children: [ + { + kind: "t1", + data: { + id: "0", + author: "AutoModerator", + body: "Rules", + score: 1, + created_utc: 0, + stickied: true, + distinguished: "moderator", + }, + }, + { + kind: "t1", + data: { id: "1", author: "gregkh", body: "Great release ", score: 900, created_utc: NOW / 1000 - 3000 }, + }, + { + kind: "t1", + data: { id: "2", author: "op", body: "Thanks", score: 5, created_utc: NOW / 1000, is_submitter: true }, + }, + { kind: "t1", data: { id: "3", author: "[deleted]", body: "[removed]", score: 1, created_utc: 0 } }, + { kind: "more", data: { id: "x", author: "", score: 0, created_utc: 0 } }, + ], + }, + }; + const c = commentsOf([{}, listing]); + assertEquals(c.map((x) => [x.author, x.body, x.op]), [["gregkh", "Great release", false], ["op", "Thanks", true]]); + const md = commentsMarkdown(fromReddit(self), c); + assertEquals(md.includes("**u/op** (OP) · 5 points"), true); + assertEquals(md.includes("Ask anything!"), true); + assertEquals(commentsMarkdown(fromReddit(link), []).endsWith("No comments yet."), true); +}); + +Deno.test("formatting", () => { + assertEquals(ago(NOW - 5 * 3600_000, NOW), "5 h"); + assertEquals(ago(NOW - 30_000, NOW), "now"); + assertEquals(flairColor("#0079d3"), "blue"); + assertEquals(flairColor("#ff4500"), "orange"); + assertEquals(flairColor("#46d160"), "green"); + assertEquals(flairColor("#dadada"), "gray"); + assertEquals(flairColor(""), "gray"); + assertEquals( + cleanMarkdown("Hi >!spoiler!< ^(small) & more\n\n\n\n|a|\n|---|\n|1|"), + "Hi [spoiler] small & more\n\n|a|\n\n|1|", + ); + assertEquals(cleanMarkdown("word ".repeat(400), 20), "word word word word…"); +}); + +Deno.test("refusals", () => { + assertEquals(refusal(403, { reason: "private", message: "Forbidden", error: 403 }), { + message: "This subreddit is private", + blocked: false, + }); + assertEquals(refusal(403, undefined), { message: "Reddit blocked this request from your network", blocked: true }); + assertEquals(refusal(429, undefined, "42"), { message: "Reddit is rate limiting: try again in 42 s", blocked: true }); + assertEquals(refusal(404, { reason: "banned" }).blocked, false); + assertEquals(refusal(503, undefined), { message: "Reddit answered 503", blocked: true }); + const item = refusedItem( + new Refused("Reddit blocked this request from your network", true), + "https://www.reddit.com/r/linux/hot", + ); + assertEquals(item.actions?.map((a) => a.title), ["Open in Browser", "Try Again"]); +}); + +Deno.test("post item", () => { + const item = postItem(fromReddit(link), { multi: true, browse: () => ({}) as never }); + assertEquals(item.subtitle, "r/linux"); + assertEquals(item.icon, { source: "bs-link", tint: "orange" }); + assertEquals(item.detail?.image, "https://external-preview.redd.it/abc.jpg?width=640&s=3"); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text).slice(0, 3), ["Kernel", "1.5K", "214"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Open Post", + "Open Link", + "Show Top Comments", + "Browse r/linux", + ]); + const one = postItem(fromReddit(self), { multi: false }); + assertEquals(one.subtitle, "u/linus_fan"); + assertEquals(one.actions?.map((a) => a.title).slice(0, 3), ["Open Post", "Show Top Comments", "Open u/linus_fan"]); +}); diff --git a/extensions/reddit/views.ts b/extensions/reddit/views.ts new file mode 100644 index 0000000..c06912e --- /dev/null +++ b/extensions/reddit/views.ts @@ -0,0 +1,190 @@ +// Posts as launcher items, the comments page and a subreddit's list. + +import { + type Accessory, + type Action, + action, + type Color, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, +} from "@backslash/api"; +import { comments, listing, type Loaded, Refused } from "./api.ts"; +import { ago, cleanMarkdown, commentsMarkdown, compact, flairColor, fullDate, type Post, type Sort } from "./lib.ts"; + +export const SORTS: { value: Sort; title: string }[] = [ + { value: "hot", title: "Hot" }, + { value: "new", title: "New" }, + { value: "top", title: "Top Today" }, +]; + +const KIND_ICONS: Record<Post["kind"], string> = { + text: Icons.text, + image: Icons.image, + video: Icons.video, + gallery: Icons.layers, + link: Icons.link, +}; + +export const subUrl = (sub: string, sort: Sort = "hot") => + `https://www.reddit.com/r/${encodeURIComponent(sub)}/${sort === "top" ? "top/?t=day" : sort}`; + +export async function commentsView(p: Post, signal: AbortSignal): Promise<DetailSpec> { + const top = await comments(p.id, signal); + return detail({ + title: p.title, + markdown: commentsMarkdown(p, top), + status: `${compact(p.score)} points · ${compact(p.comments)} comments · r/${p.subreddit}`, + metadata: postMetadata(p), + actions: [ + open(p.permalink, "Open Post on Reddit"), + ...(p.link ? [open(p.link, "Open Link")] : []), + copy(p.permalink, "Copy Post Link", { section: "Copy" }), + ], + }); +} + +function postMetadata(p: Post): Metadata[] { + const lines: Metadata[] = [ + meta.text("Subreddit", `r/${p.subreddit}`, { icon: Icons.hash }), + meta.person("Author", `u/${p.author}`, p.author), + meta.text("Posted", `${ago(p.created)} · ${fullDate(p.created)}`, { icon: Icons.clock }), + ]; + const badges: { text: string; color?: Color }[] = []; + if (p.flair) badges.push({ text: p.flair, color: flairColor(p.flairColor) }); + if (p.nsfw) badges.push({ text: "NSFW", color: "red" }); + if (p.spoiler) badges.push({ text: "Spoiler", color: "orange" }); + if (p.pinned) badges.push({ text: "Pinned", color: "green" }); + if (p.locked) badges.push({ text: "Locked", color: "gray" }); + if (badges.length) lines.push(meta.tags("Flair", badges)); + if (p.ratio !== undefined) lines.push(meta.progress("Upvoted", p.ratio)); + if (p.link && p.domain) lines.push(meta.link("Link", p.domain, p.link)); + lines.push(meta.link("Post", `reddit.com/r/${p.subreddit}/comments/${p.id}`, p.permalink)); + return lines; +} + +export function postItem(p: Post, o: { multi: boolean; browse?: (sub: string) => ListSpec }): Item { + const accessories: Accessory[] = []; + if (p.pinned) accessories.push({ icon: { source: Icons.pin, tint: "green" }, tooltip: "Pinned by the moderators" }); + if (p.flair) accessories.push({ tag: p.flair.slice(0, 20), color: flairColor(p.flairColor) }); + accessories.push( + { + text: compact(p.score), + icon: { source: Icons.arrowUp, tint: p.score >= 1000 ? "orange" : "gray" }, + tooltip: `${p.score} points`, + }, + { text: compact(p.comments), icon: Icons.comment, tooltip: `${p.comments} comments` }, + { text: ago(p.created), tooltip: fullDate(p.created) }, + ); + const actions: Action[] = [ + open(p.permalink, "Open Post"), + ...(p.link ? [open(p.link, "Open Link", { shortcut: "<Ctrl>o" })] : []), + action("Show Top Comments", (ctx) => commentsView(p, ctx.signal), { icon: Icons.comment, shortcut: "<Ctrl>t" }), + ...(o.multi && o.browse + ? [action(`Browse r/${p.subreddit}`, () => o.browse!(p.subreddit), { icon: Icons.hash, shortcut: "<Ctrl>r" })] + : []), + open(`https://www.reddit.com/user/${encodeURIComponent(p.author)}`, `Open u/${p.author}`, { + icon: Icons.person, + section: "Open", + }), + copy(p.link ?? p.permalink, "Copy Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + ...(p.link ? [copy(p.permalink, "Copy Post Link", { section: "Copy" })] : []), + copy(`[${p.title}](${p.permalink})`, "Copy as Markdown Link", { section: "Copy" }), + ]; + const text = p.text && !p.spoiler ? cleanMarkdown(p.text, 900) : undefined; + return { + id: p.id, + title: p.title, + subtitle: o.multi ? `r/${p.subreddit}` : `u/${p.author}`, + icon: { source: KIND_ICONS[p.kind], tint: p.nsfw ? "red" : p.pinned ? "green" : "orange" }, + keywords: [p.subreddit, p.author, p.flair ?? "", p.domain ?? ""], + accessories, + detail: { + image: p.image, + stats: [ + { label: "Points", value: compact(p.score), color: p.score >= 1000 ? "orange" : undefined }, + { label: "Comments", value: compact(p.comments) }, + ...(p.ratio !== undefined ? [{ label: "Upvoted", value: `${Math.round(p.ratio * 100)} %` }] : []), + ], + markdown: [`**${p.title}**`, ...(text ? ["", text] : p.spoiler ? ["", "*Spoiler hidden*"] : [])].join("\n"), + metadata: postMetadata(p), + }, + actions, + }; +} + +/** Shown when Reddit refuses and nothing is saved, or for a subreddit that can't be read. */ +export function refusedItem(e: Refused, webUrl: string): Item { + return { + id: "refused", + title: e.message, + subtitle: e.blocked ? "Reddit limits requests without an account" : undefined, + icon: { source: e.blocked ? Icons.shield : Icons.alert, tint: "orange" }, + detail: { + markdown: e.blocked + ? [ + "## Reddit refused the request", + "", + "Reddit's public pages are rate limited, and refused outright from some networks", + "(VPNs, cloud servers, some ISPs). This usually passes after a minute;", + "otherwise open Reddit in the browser (Enter).", + ].join("\n") + : `## ${e.message}`, + }, + actions: [ + open(webUrl, "Open in Browser"), + action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + ], + }; +} + +/** Posts of a listing, or the refusal; `note` receives a status for saved copies. */ +export async function loadPosts( + load: () => Promise<Loaded>, + webUrl: string, + item: (p: Post) => Item, + note: (text: string) => void, +): Promise<Item[]> { + try { + const { posts, savedAt, refused } = await load(); + const age = savedAt && ago(savedAt); + note(savedAt ? `${refused?.message ?? "Reddit refused"} · posts from ${age === "now" ? "a moment" : age} ago` : ""); + return posts.map(item); + } catch (e) { + if (e instanceof Refused) { + note(e.message); + return [refusedItem(e, webUrl)]; + } + throw e; + } +} + +/** One subreddit, with the same Hot / New / Top views. */ +export function subredditList(sub: string, nsfw: boolean): ListSpec { + let note = ""; + return list({ + title: `r/${sub}`, + placeholder: `Filter r/${sub} posts by title, author or flair…`, + filter: { options: SORTS }, + empty: { title: "No posts", description: `r/${sub} has nothing here`, icon: Icons.comment }, + status: (items) => note || `${items.length} posts · r/${sub}`, + actions: [open(subUrl(sub), `Open r/${sub} in Browser`)], + items: ({ filter, signal }) => { + const sort = (SORTS.some((s) => s.value === filter) ? filter : "hot") as Sort; + return loadPosts( + () => listing([sub], sort, nsfw, signal), + subUrl(sub, sort), + (p) => postItem(p, { multi: false }), + (t) => (note = t), + ); + }, + }); +} diff --git a/extensions/stocks/api.ts b/extensions/stocks/api.ts new file mode 100644 index 0000000..ab81217 --- /dev/null +++ b/extensions/stocks/api.ts @@ -0,0 +1,191 @@ +// The Finnhub REST API (free plan: 60 calls a minute, US quotes), with caches +// so that typing and reopening stay under the limit. + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { + isoDay, + type MarketStatus, + type Metrics, + type News, + type Profile, + type Quote, + type SymbolMatch, +} from "./lib.ts"; + +const API = "https://finnhub.io/api/v1"; +export const REGISTER_URL = "https://finnhub.io/register"; +export const DASHBOARD_URL = "https://finnhub.io/dashboard"; + +type Json = Parameters<typeof storage.set>[1]; + +export class AuthError extends Error {} +export class RateLimited extends Error {} + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +export function apiKey(preferences: Record<string, unknown>): string { + const key = String(preferences.apiKey ?? "").trim(); + if (!key) throw new AuthError("Add your free Finnhub API key"); + return key; +} + +async function request<T>(key: string, path: string, signal?: AbortSignal): Promise<T> { + let res: Response; + try { + res = await fetch(`${API}${path}`, { signal, headers: { "X-Finnhub-Token": key, Accept: "application/json" } }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Finnhub is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (res.status === 401) throw new AuthError("Finnhub rejected the API key"); + if (res.status === 429) throw new RateLimited("Finnhub's limit of 60 calls a minute is reached, try again shortly"); + if (res.status === 403) { + throw new Error("Finnhub's free plan doesn't include this (only US stocks have quotes on the free plan)"); + } + if (!res.ok) throw new Error(`Finnhub answered ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`); + return JSON.parse(text) as T; +} + +interface Cached<T> { + at: number; + data: T; +} + +const memory = new Map<string, Cached<unknown>>(); + +/** Set when the last answer came from the cache because Finnhub refused or was unreachable. */ +export let stale = false; + +/** Starts a new load: `stale` then says whether any of its answers was a saved one. */ +export function newRound(): void { + stale = false; +} + +/** A GET answer, fresh for `ttl` seconds; the saved one when Finnhub refuses (rate limit) or is unreachable. */ +async function cached<T>( + key: string, + path: string, + ttl: number, + signal?: AbortSignal, + persist = true, +): Promise<T> { + const id = `cache:${path}`; + const hit = (memory.get(id) as Cached<T> | undefined) ?? (persist ? await load<Cached<T>>(id) : undefined); + if (hit && Date.now() - hit.at < ttl * 1000) { + memory.set(id, hit); + return hit.data; + } + try { + const data = await request<T>(key, path, signal); + const entry = { at: Date.now(), data }; + memory.set(id, entry); + if (persist) await save(id, entry); + return data; + } catch (e) { + if ((e as Error).name === "AbortError" || e instanceof AuthError || !hit) throw e; + stale = true; + return hit.data; + } +} + +const q = (symbol: string) => encodeURIComponent(symbol); + +export const quote = (key: string, symbol: string, signal?: AbortSignal) => + cached<Quote>(key, `/quote?symbol=${q(symbol)}`, 30, signal); + +/** Company profiles hardly change: kept a week. Empty for funds and foreign listings. */ +export const profile = (key: string, symbol: string, signal?: AbortSignal) => + cached<Profile>(key, `/stock/profile2?symbol=${q(symbol)}`, 7 * 86400, signal); + +export const metrics = (key: string, symbol: string, signal?: AbortSignal) => + cached<Metrics>(key, `/stock/metric?symbol=${q(symbol)}&metric=all`, 86400, signal); + +export const marketStatus = (key: string, signal?: AbortSignal) => + cached<MarketStatus>(key, `/stock/market-status?exchange=US`, 300, signal, false); + +export async function search(key: string, query: string, signal?: AbortSignal): Promise<SymbolMatch[]> { + const res = await cached<{ count: number; result: SymbolMatch[] }>( + key, + `/search?q=${encodeURIComponent(query.trim())}`, + 86400, + signal, + false, + ); + return res.result ?? []; +} + +/** The last two weeks of headlines, newest first. */ +export async function news(key: string, symbol: string, signal?: AbortSignal): Promise<News[]> { + const to = new Date(); + const from = new Date(to.getTime() - 14 * 86400_000); + const list = await cached<News[]>( + key, + `/company-news?symbol=${q(symbol)}&from=${isoDay(from)}&to=${isoDay(to)}`, + 1800, + signal, + false, + ); + return [...(list ?? [])].sort((a, b) => b.datetime - a.datetime); +} + +// --------------------------------------------------------------------------- +// Watchlist + +/** Shown until the user changes the watchlist. */ +export const DEFAULT_WATCHLIST = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]; + +export const watchlist = async () => await load<string[]>("watchlist") ?? DEFAULT_WATCHLIST; +export const setWatchlist = (list: string[]) => save("watchlist", list); + +// --------------------------------------------------------------------------- +// Setup + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Free at finnhub.io: 60 calls a minute, US stocks", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Finnhub", + "", + "Quotes come from **Finnhub**, whose free plan needs an API key:", + "", + "1. Create a free account on **finnhub.io/register** (Enter opens it).", + "2. Copy the **API key** shown on your Finnhub dashboard.", + "3. Paste it in **Settings → Extensions → Stocks → Configure**.", + "", + "The free plan allows 60 calls a minute and has real-time quotes for US stocks.", + ].join("\n"), + }, + actions: [ + open(REGISTER_URL, "Create a Free Finnhub Account", { icon: Icons.key }), + open(DASHBOARD_URL, "Open Finnhub Dashboard", { icon: Icons.external }), + ], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + if (e instanceof RateLimited) { + return [{ + id: "rate-limit", + title: e.message, + icon: { source: Icons.hourglass, tint: "orange" }, + detail: { markdown: "Finnhub's free plan allows 60 calls a minute. Quotes are cached for 30 seconds." }, + }]; + } + throw e; + } +} diff --git a/extensions/stocks/backslash.toml b/extensions/stocks/backslash.toml new file mode 100644 index 0000000..fae8a44 --- /dev/null +++ b/extensions/stocks/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "stocks" +title = "Stocks" +description = "A stock watchlist with live quotes, day ranges, company profiles and news, from Finnhub's free API" +version = "1.0.0" +author = "Backslash" +icon = "bs-chart-up" +license = "MIT" + +# The Finnhub API, and static2.finnhub.io where it serves company logos. +[permissions] +net = ["finnhub.io", "static2.finnhub.io"] + +[[commands]] +name = "stocks" +title = "Stock Watchlist" +description = "Your stocks with price and change; type a symbol or a company to search" +icon = "bs-chart-up" +keywords = [ + "stocks", + "stock", + "shares", + "ticker", + "quote", + "market", + "nasdaq", + "finnhub", + "bourse", + "actions", + "cours", + "marché", +] + +[[preferences]] +name = "apiKey" +title = "Finnhub API key" +description = "Free at finnhub.io/register; the key is on finnhub.io/dashboard" +type = "secret" diff --git a/extensions/stocks/commands/stocks.ts b/extensions/stocks/commands/stocks.ts new file mode 100644 index 0000000..ec606b2 --- /dev/null +++ b/extensions/stocks/commands/stocks.ts @@ -0,0 +1,60 @@ +import { type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { apiKey, guarded, marketStatus, newRound, profile, quote, search, stale, watchlist } from "../api.ts"; +import { marketLine, rankMatches, tickerLine, titleCase } from "../lib.ts"; +import { type Stock, stockItem } from "../views.ts"; + +const SEARCH_QUOTES = 6; +let market: string | undefined; + +/** Quote and profile; a stock still shows if one of them fails. */ +async function enrich(key: string, s: Stock, signal: AbortSignal, withProfile: boolean): Promise<Stock> { + const soft = <T>(p: Promise<T>) => + p.catch((e) => { + if ((e as Error).name === "AbortError") throw e; + return undefined; + }); + const [q, p] = await Promise.all([ + soft(quote(key, s.symbol, signal)), + withProfile ? soft(profile(key, s.symbol, signal)) : Promise.resolve(undefined), + ]); + return { ...s, quote: q, profile: p && Object.keys(p).length ? p : undefined, name: p?.name || s.name }; +} + +export default (ctx: Context) => + list({ + placeholder: "Search a symbol or a company: AAPL, tesla, s&p 500 etf…", + empty: { title: "No symbol found", description: "Try a ticker (MSFT) or a company name", icon: Icons.chartUp }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + const watched = items.filter((i) => i.section === "Watchlist").length; + return [ + watched ? `${watched} in your watchlist` : `${items.length} result${items.length === 1 ? "" : "s"}`, + market, + stale ? "some quotes are saved ones (rate limit)" : "Finnhub", + ].filter(Boolean).join(" · "); + }, + search: (query, { signal }) => + guarded(async (): Promise<Item[]> => { + const key = apiKey(ctx.preferences); + newRound(); + const watched = await watchlist(); + if (!query.trim()) { + const [stocks, status] = await Promise.all([ + Promise.all(watched.map((symbol) => enrich(key, { symbol, name: symbol }, signal, true))), + marketStatus(key, signal).catch(() => undefined), + ]); + market = marketLine(status); + const first = stocks.find((s) => s.quote && (s.quote.c || s.quote.pc)); + updateCommandMetadata({ subtitle: first ? tickerLine(first.symbol, first.quote!) : null }).catch(() => {}); + return stocks.map((s) => stockItem(s, { key, watched: true, section: "Watchlist" })); + } + const matches = rankMatches(await search(key, query, signal), query); + const stocks = await Promise.all( + matches.map((m, i) => { + const s: Stock = { symbol: m.symbol, name: titleCase(m.description), type: m.type || undefined }; + return i < SEARCH_QUOTES ? enrich(key, s, signal, i < 3 || watched.includes(m.symbol)) : Promise.resolve(s); + }), + ); + return stocks.map((s) => stockItem(s, { key, watched: watched.includes(s.symbol), section: "Results" })); + }), + }); diff --git a/extensions/stocks/lib.ts b/extensions/stocks/lib.ts new file mode 100644 index 0000000..54e2187 --- /dev/null +++ b/extensions/stocks/lib.ts @@ -0,0 +1,248 @@ +// Finnhub answers and their formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +/** `GET /quote`: c current, d change, dp change %, h/l day high/low, o open, pc previous close, t time (s). */ +export interface Quote { + c: number; + d: number | null; + dp: number | null; + h: number; + l: number; + o: number; + pc: number; + t: number; +} + +/** `GET /stock/profile2`. `marketCapitalization` is in millions of `currency`. */ +export interface Profile { + country?: string; + currency?: string; + exchange?: string; + finnhubIndustry?: string; + ipo?: string; + logo?: string; + marketCapitalization?: number; + name?: string; + phone?: string; + shareOutstanding?: number; + ticker?: string; + weburl?: string; +} + +/** One result of `GET /search`. */ +export interface SymbolMatch { + description: string; + displaySymbol: string; + symbol: string; + type: string; +} + +/** One article of `GET /company-news`. */ +export interface News { + category?: string; + datetime: number; + headline: string; + id: number; + image?: string; + related?: string; + source?: string; + summary?: string; + url: string; +} + +/** `GET /stock/metric?metric=all`, the few values we show. */ +export interface Metrics { + metric?: { + "52WeekHigh"?: number; + "52WeekLow"?: number; + "52WeekHighDate"?: string; + "52WeekLowDate"?: string; + peBasicExclExtraTTM?: number; + peTTM?: number; + beta?: number; + dividendYieldIndicatedAnnual?: number; + epsTTM?: number; + }; +} + +/** `GET /stock/market-status?exchange=US`. */ +export interface MarketStatus { + exchange: string; + holiday: string | null; + isOpen: boolean; + session: "pre-market" | "regular" | "post-market" | null | string; + timezone?: string; + t?: number; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** Finnhub answers zeros for a symbol it has no quote for. */ +export const hasQuote = (q: Quote | undefined): q is Quote => !!q && (q.c !== 0 || q.pc !== 0); + +/** `189.84`, `0.0123`: prices without a currency sign (Finnhub mixes exchanges). */ +export function price(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const abs = Math.abs(value); + const digits = abs > 0 && abs < 1 + ? { maximumSignificantDigits: 4 } + : { minimumFractionDigits: 2, maximumFractionDigits: 2 }; + return new Intl.NumberFormat("en-US", digits).format(value); +} + +/** `$189.84`, `€12.30`, or a bare price for unknown currencies. */ +export function money(value: number | null | undefined, currency?: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + if (!currency || !/^[A-Z]{3}$/.test(currency)) return price(value); + try { + const abs = Math.abs(value); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + ...(abs > 0 && abs < 1 + ? { maximumSignificantDigits: 4 } + : { minimumFractionDigits: 2, maximumFractionDigits: 2 }), + }).format(value); + } catch { + return price(value); + } +} + +export function signed(value: number | null | undefined, suffix = ""): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const sign = value > 0 ? "+" : value < 0 ? "−" : ""; + return `${sign}${price(Math.abs(value))}${suffix}`; +} + +export function percent(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${value > 0 ? "+" : value < 0 ? "−" : ""}${Math.abs(value).toFixed(2)} %`; +} + +export const changeColor = (value: number | null | undefined): Color | undefined => + !value ? undefined : value > 0 ? "green" : "red"; + +/** Market cap in millions → `$2.9T`, `€412.3B`. */ +export function marketCap(millions: number | undefined, currency = "USD"): string { + if (!millions || !Number.isFinite(millions)) return "—"; + const value = millions * 1e6; + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + notation: "compact", + maximumFractionDigits: 2, + }).format(value); + } catch { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(value); + } +} + +/** Where the price is between the day's low and high, from 0 to 1. */ +export function dayPosition(q: Quote): number | undefined { + if (!(q.h > q.l)) return undefined; + return Math.max(0, Math.min(1, (q.c - q.l) / (q.h - q.l))); +} + +/** `AAPL 189.84 ▲ 1.17%` for the root search subtitle. */ +export function tickerLine(symbol: string, q: Quote): string { + const arrow = q.dp === null ? "" : q.dp >= 0 ? " ▲" : " ▼"; + return `${symbol} ${price(q.c)}${arrow}${q.dp === null ? "" : ` ${Math.abs(q.dp).toFixed(2)}%`}`; +} + +/** "5 min", "3 h", "2 d", then a date, from Unix seconds. */ +export function ago(seconds: number, now = Date.now()): string { + const s = Math.max(0, Math.round(now / 1000 - seconds)); + 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(seconds * 1000).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +/** "Wed 28 Oct, 16:00" from Unix seconds. */ +export function quoteTime(seconds: number): string { + if (!seconds) return ""; + return new Date(seconds * 1000).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** `YYYY-MM-DD` in UTC. */ +export const isoDay = (d: Date) => d.toISOString().slice(0, 10); + +/** "Open", "Pre-market", "After hours", "Closed (Thanksgiving)". */ +export function marketLine(m: MarketStatus | undefined): string | undefined { + if (!m) return undefined; + if (m.holiday) return `US market closed (${m.holiday})`; + if (m.session === "regular" || (m.isOpen && !m.session)) return "US market open"; + if (m.session === "pre-market") return "US pre-market"; + if (m.session === "post-market") return "US after hours"; + return "US market closed"; +} + +// --------------------------------------------------------------------------- +// Symbols + +/** What the user typed → a symbol to look up: ` aapl ` → `AAPL`. */ +export const normalizeSymbol = (s: string) => s.trim().toUpperCase().replace(/\s+/g, ""); + +/** + * Search results worth showing: stocks and funds, the primary listing first + * (`AAPL` before `AAPL.MX`), unless the query names an exchange suffix. + */ +export function rankMatches(matches: SymbolMatch[], query: string, limit = 12): SymbolMatch[] { + const q = normalizeSymbol(query); + const seen = new Set<string>(); + const kept = matches.filter((m) => { + if (!m.symbol || seen.has(m.symbol)) return false; + seen.add(m.symbol); + return q.includes(".") || !m.symbol.includes("."); + }); + const score = (m: SymbolMatch) => (m.symbol === q ? 0 : m.symbol.startsWith(q) ? 1 : 2); + return kept.map((m, i) => ({ m, i })).sort((a, b) => score(a.m) - score(b.m) || a.i - b.i).slice(0, limit).map(( + x, + ) => x.m); +} + +/** "APPLE INC" → "Apple Inc", leaving short tickers-like words (ETF, AG) upper case. */ +export function titleCase(name: string): string { + if (name !== name.toUpperCase()) return name; + return name.toLowerCase().replace( + /\b([a-z])([a-z]*)/g, + (w, first: string, rest: string) => + ["etf", "ag", "se", "sa", "nv", "plc", "spa", "usd", "ii", "iii"].includes(w) + ? w.toUpperCase() + : first.toUpperCase() + rest, + ); +} + +export const domain = (url: string | undefined) => { + if (!url) return undefined; + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +}; + +export const yahooUrl = (symbol: string) => `https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}`; + +/** Moves `symbol` one place up or down. */ +export function move(list: string[], symbol: string, by: -1 | 1): string[] { + const i = list.indexOf(symbol); + const j = i + by; + if (i < 0 || j < 0 || j >= list.length) return list; + const out = [...list]; + [out[i], out[j]] = [out[j], out[i]]; + return out; +} diff --git a/extensions/stocks/lib_test.ts b/extensions/stocks/lib_test.ts new file mode 100644 index 0000000..49fe922 --- /dev/null +++ b/extensions/stocks/lib_test.ts @@ -0,0 +1,134 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + changeColor, + dayPosition, + domain, + hasQuote, + marketCap, + marketLine, + money, + move, + normalizeSymbol, + percent, + type Quote, + rankMatches, + signed, + type SymbolMatch, + tickerLine, + titleCase, + yahooUrl, +} from "./lib.ts"; +import { stockItem } from "./views.ts"; + +// Shaped like `GET /quote?symbol=AAPL`. +const aapl: Quote = { c: 189.84, d: 2.2, dp: 1.1724, h: 190.3, l: 187.1, o: 188, pc: 187.64, t: 1793180000 }; +const unknown: Quote = { c: 0, d: null, dp: null, h: 0, l: 0, o: 0, pc: 0, t: 0 }; + +// Shaped like `GET /search?q=tesla`. +const matches: SymbolMatch[] = [ + { description: "TESLA INC", displaySymbol: "TL0.DE", symbol: "TL0.DE", type: "Common Stock" }, + { description: "DIREXION DAILY TSLA BULL 2X", displaySymbol: "TSLL", symbol: "TSLL", type: "ETP" }, + { description: "TESLA INC", displaySymbol: "TSLA", symbol: "TSLA", type: "Common Stock" }, + { description: "TESLA INC", displaySymbol: "TSLA.MX", symbol: "TSLA.MX", type: "Common Stock" }, + { description: "TESLA INC", displaySymbol: "TSLA", symbol: "TSLA", type: "Common Stock" }, +]; + +Deno.test("quotes", () => { + assertEquals(hasQuote(aapl), true); + assertEquals(hasQuote(unknown), false); + assertEquals(hasQuote(undefined), false); + assertEquals(dayPosition(aapl)?.toFixed(3), "0.856"); + assertEquals(dayPosition({ ...aapl, h: 10, l: 10 }), undefined); + assertEquals(tickerLine("AAPL", aapl), "AAPL 189.84 ▲ 1.17%"); + assertEquals(tickerLine("MSFT", { ...aapl, c: 415.5, dp: -0.7406 }), "MSFT 415.50 ▼ 0.74%"); +}); + +Deno.test("formatting", () => { + assertEquals(money(189.84, "USD"), "$189.84"); + assertEquals(money(12.3, "EUR"), "€12.30"); + assertEquals(money(0.01234, "USD"), "$0.01234"); + assertEquals(money(5, "XX"), "5.00"); + assertEquals(money(5, undefined), "5.00"); + assertEquals(money(null), "—"); + assertEquals(signed(2.2), "+2.20"); + assertEquals(signed(-3.1), "−3.10"); + assertEquals(percent(1.1724), "+1.17 %"); + assertEquals(percent(-0.7406), "−0.74 %"); + assertEquals(percent(null), "—"); + assertEquals(changeColor(-1), "red"); + assertEquals(changeColor(0), undefined); + assertEquals(marketCap(2905123.4, "USD"), "$2.91T"); + assertEquals(marketCap(412300, "EUR"), "€412.3B"); + assertEquals(marketCap(undefined), "—"); +}); + +Deno.test("market status", () => { + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: true, session: "regular" }), "US market open"); + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: false, session: "pre-market" }), "US pre-market"); + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: false, session: "post-market" }), "US after hours"); + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: false, session: null }), "US market closed"); + assertEquals( + marketLine({ exchange: "US", holiday: "Thanksgiving Day", isOpen: false, session: null }), + "US market closed (Thanksgiving Day)", + ); + assertEquals(marketLine(undefined), undefined); +}); + +Deno.test("search results", () => { + assertEquals(rankMatches(matches, "tesla").map((m) => m.symbol), ["TSLL", "TSLA"]); + assertEquals(rankMatches(matches, "tsla").map((m) => m.symbol), ["TSLA", "TSLL"]); + assertEquals(rankMatches(matches, "tsla.mx").map((m) => m.symbol), ["TSLA.MX", "TL0.DE", "TSLL", "TSLA"]); + assertEquals(normalizeSymbol(" brk b "), "BRKB"); + assertEquals(titleCase("APPLE INC"), "Apple Inc"); + assertEquals(titleCase("SPDR S&P 500 ETF TRUST"), "Spdr S&P 500 ETF Trust"); + assertEquals(titleCase("Alphabet Inc"), "Alphabet Inc"); +}); + +Deno.test("helpers", () => { + assertEquals(move(["A", "B", "C"], "B", -1), ["B", "A", "C"]); + assertEquals(move(["A", "B", "C"], "C", 1), ["A", "B", "C"]); + assertEquals(domain("https://www.apple.com/"), "apple.com"); + assertEquals(yahooUrl("BRK.B"), "https://finance.yahoo.com/quote/BRK.B"); + const now = 1793180000 * 1000; + assertEquals(ago(1793180000 - 300, now), "5 min"); + assertEquals(ago(1793180000 - 3 * 86400, now), "3 d"); +}); + +Deno.test("stock item", () => { + const item = stockItem({ + symbol: "AAPL", + name: "Apple Inc", + quote: aapl, + profile: { + currency: "USD", + exchange: "NASDAQ NMS - GLOBAL MARKET", + finnhubIndustry: "Technology", + logo: "https://static2.finnhub.io/file/publicdatany/finnhubimage/stock_logo/AAPL.png", + marketCapitalization: 2905123.4, + name: "Apple Inc", + weburl: "https://www.apple.com/", + }, + }, { key: "k", watched: true, section: "Watchlist" }); + assertEquals(item.icon, "https://static2.finnhub.io/file/publicdatany/finnhubimage/stock_logo/AAPL.png"); + assertEquals(item.accessories?.map((a) => a.text), ["+1.17 %", "$189.84"]); + assertEquals(item.detail?.stats?.[1], { label: "Change", value: "+2.20", color: "green" }); + const range = item.detail?.metadata?.find((m) => m.label === "Day range"); + assertEquals(range?.value, "$187.10 – $190.30"); + assertEquals(range?.progress?.toFixed(2), "0.86"); + assertEquals(item.actions?.map((a) => a.title).slice(0, 5), [ + "Show News", + "Show Company Details", + "Remove from Watchlist", + "Move Up", + "Move Down", + ]); + + const foreign = stockItem({ symbol: "TL0.DE", name: "Tesla Inc", quote: unknown, type: "Common Stock" }, { + key: "k", + watched: false, + }); + assertEquals(foreign.icon, { source: "bs-chart-up", tint: "gray" }); + assertEquals(foreign.accessories, [{ tag: "Common Stock" }]); + assertEquals(foreign.detail?.stats, undefined); +}); diff --git a/extensions/stocks/views.ts b/extensions/stocks/views.ts new file mode 100644 index 0000000..8f66e14 --- /dev/null +++ b/extensions/stocks/views.ts @@ -0,0 +1,262 @@ +// Stocks as launcher items, the company page and the news list. + +import { + type Accessory, + type Action, + action, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + toast, +} from "@backslash/api"; +import { guarded, metrics, news, profile, setWatchlist, watchlist } from "./api.ts"; +import { + ago, + changeColor, + dayPosition, + domain, + hasQuote, + marketCap, + money, + move, + type News, + percent, + type Profile, + type Quote, + quoteTime, + signed, + yahooUrl, +} from "./lib.ts"; + +export interface Stock { + symbol: string; + name: string; + type?: string; + quote?: Quote; + profile?: Profile; +} + +const logoOf = (p: Profile | undefined) => + p?.logo && p.logo.startsWith("https://static2.finnhub.io/") ? p.logo : undefined; + +export function quoteMetadata(s: Stock): Metadata[] { + const q = s.quote; + const p = s.profile; + const cur = p?.currency; + const lines: Metadata[] = []; + if (hasQuote(q)) { + const pos = dayPosition(q); + lines.push( + pos === undefined + ? meta.text("Day range", `${money(q.l, cur)} – ${money(q.h, cur)}`) + : meta.progress("Day range", pos, `${money(q.l, cur)} – ${money(q.h, cur)}`), + meta.text("Open", money(q.o, cur)), + meta.text("Previous close", money(q.pc, cur)), + ); + } + if (p?.marketCapitalization) lines.push(meta.text("Market cap", marketCap(p.marketCapitalization, cur))); + if (p?.exchange) lines.push(meta.text("Exchange", p.exchange, { icon: Icons.globe })); + if (p?.finnhubIndustry) lines.push(meta.tags("Industry", [{ text: p.finnhubIndustry, color: "blue" }])); + if (s.type && !p?.finnhubIndustry) lines.push(meta.tags("Type", [s.type])); + if (p?.country) lines.push(meta.text("Country", p.country, { icon: Icons.flag })); + if (p?.ipo) lines.push(meta.text("IPO", p.ipo, { icon: Icons.calendar })); + const site = domain(p?.weburl); + if (site) lines.push(meta.link("Website", site, p!.weburl!)); + if (hasQuote(q) && q.t) lines.push(meta.text("Updated", quoteTime(q.t), { icon: Icons.clock })); + return lines; +} + +export function stockItem(s: Stock, o: { key: string; watched: boolean; section?: string }): Item { + const q = hasQuote(s.quote) ? s.quote : undefined; + const cur = s.profile?.currency; + const up = (q?.d ?? 0) >= 0; + const logo = logoOf(s.profile); + const accessories: Accessory[] = []; + if (o.watched && o.section !== "Watchlist") { + accessories.push({ icon: { source: Icons.starFilled, tint: "yellow" }, tooltip: "In your watchlist" }); + } + if (q) { + accessories.push( + { + text: percent(q.dp), + color: changeColor(q.dp), + icon: { source: up ? Icons.chartUp : Icons.chartDown, tint: changeColor(q.d) }, + tooltip: `${signed(q.d)} today`, + }, + { text: money(q.c, cur), tooltip: `Previous close ${money(q.pc, cur)}` }, + ); + } else if (s.type) { + accessories.push({ tag: s.type }); + } + + const actions: Action[] = [ + action("Show News", (ctx) => newsList(s, o.key, ctx.signal), { icon: Icons.rss, shortcut: "<Ctrl>n" }), + action("Show Company Details", (ctx) => companyView(s, o.key, ctx.signal), { + icon: Icons.info, + shortcut: "<Ctrl>i", + }), + action(o.watched ? "Remove from Watchlist" : "Add to Watchlist", async () => { + const list = await watchlist(); + await setWatchlist(o.watched ? list.filter((x) => x !== s.symbol) : [...list, s.symbol]); + await toast(`${s.symbol} ${o.watched ? "removed from" : "added to"} the watchlist`, { style: "success" }); + return refresh(); + }, { + icon: o.watched ? Icons.starFilled : Icons.star, + shortcut: "<Ctrl>s", + ...(o.watched ? { section: "Watchlist" } : {}), + }), + ]; + if (o.watched && o.section === "Watchlist") { + actions.push( + action("Move Up", async () => { + await setWatchlist(move(await watchlist(), s.symbol, -1)); + return refresh(); + }, { icon: Icons.arrowUp, shortcut: "<Ctrl><Shift>Up", section: "Watchlist" }), + action("Move Down", async () => { + await setWatchlist(move(await watchlist(), s.symbol, 1)); + return refresh(); + }, { icon: Icons.arrowDown, shortcut: "<Ctrl><Shift>Down", section: "Watchlist" }), + ); + } + actions.push( + open(yahooUrl(s.symbol), "Open on Yahoo Finance", { section: "Open" }), + ...(s.profile?.weburl ? [open(s.profile.weburl, "Open Company Website", { section: "Open" })] : []), + copy(s.symbol, "Copy Symbol", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + ...(q ? [copy(money(q.c, cur), "Copy Price", { section: "Copy" })] : []), + ); + + return { + id: `${o.section ?? ""}:${s.symbol}`, + title: s.symbol, + subtitle: s.name, + section: o.section, + icon: logo ?? { source: up ? Icons.chartUp : Icons.chartDown, tint: q ? changeColor(q.d) ?? "gray" : "gray" }, + keywords: [s.name, ...(s.profile?.finnhubIndustry ? [s.profile.finnhubIndustry] : [])], + accessories, + detail: { + image: logo, + stats: q + ? [ + { label: "Price", value: money(q.c, cur) }, + { label: "Change", value: signed(q.d), color: changeColor(q.d) }, + { label: "Today", value: percent(q.dp), color: changeColor(q.dp) }, + ] + : undefined, + markdown: q + ? `**${s.name}**` + : `**${s.name}**\n\nNo quote for ${s.symbol}: Finnhub's free plan has quotes for US stocks only.`, + metadata: quoteMetadata(s), + }, + actions, + }; +} + +/** The company's profile, key figures and latest headlines. */ +export async function companyView(s: Stock, key: string, signal: AbortSignal): Promise<DetailSpec> { + const [p, m, headlines] = await Promise.all([ + s.profile ? Promise.resolve(s.profile) : profile(key, s.symbol, signal).catch(() => undefined), + metrics(key, s.symbol, signal).catch(() => undefined), + news(key, s.symbol, signal).catch(() => [] as News[]), + ]); + const cur = p?.currency; + const x = m?.metric ?? {}; + const q = hasQuote(s.quote) ? s.quote : undefined; + const md = [ + `# ${p?.name || s.name} (${s.symbol})`, + "", + ...(q ? [`**${money(q.c, cur)}** · ${signed(q.d)} (${percent(q.dp)}) today`, ""] : []), + ...(headlines.length + ? [ + "## Latest news", + "", + ...headlines.slice(0, 8).map((n) => + `- [${n.headline.replace(/[[\]]/g, "")}](${n.url}) · ${n.source ?? ""} · ${ago(n.datetime)}` + ), + ] + : ["No headlines in the last two weeks."]), + ]; + const metadata: Metadata[] = [...quoteMetadata({ ...s, profile: p })]; + if (x["52WeekLow"] && x["52WeekHigh"]) { + metadata.push(meta.text("52 weeks", `${money(x["52WeekLow"], cur)} – ${money(x["52WeekHigh"], cur)}`)); + } + const pe = x.peTTM ?? x.peBasicExclExtraTTM; + if (pe) metadata.push(meta.text("P/E (TTM)", pe.toFixed(1))); + if (x.epsTTM) metadata.push(meta.text("EPS (TTM)", money(x.epsTTM, cur))); + if (x.beta) metadata.push(meta.text("Beta", x.beta.toFixed(2))); + if (x.dividendYieldIndicatedAnnual) { + metadata.push(meta.text("Dividend yield", `${x.dividendYieldIndicatedAnnual.toFixed(2)} %`)); + } + if (p?.shareOutstanding) { + metadata.push( + meta.text( + "Shares", + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format( + p.shareOutstanding * 1e6, + ), + ), + ); + } + return detail({ + title: `${s.symbol} · ${p?.name || s.name}`, + markdown: md.join("\n"), + stats: q + ? [ + { label: "Price", value: money(q.c, cur) }, + { label: "Today", value: percent(q.dp), color: changeColor(q.dp) }, + ...(p?.marketCapitalization ? [{ label: "Market cap", value: marketCap(p.marketCapitalization, cur) }] : []), + ] + : undefined, + metadata, + status: `${headlines.length} headline${headlines.length === 1 ? "" : "s"} in 14 days · Finnhub`, + actions: [ + action("Show News", (ctx) => newsList(s, key, ctx.signal), { icon: Icons.rss }), + open(yahooUrl(s.symbol), "Open on Yahoo Finance"), + ...(p?.weburl ? [open(p.weburl, "Open Company Website")] : []), + copy(s.symbol, "Copy Symbol", { section: "Copy" }), + ], + }); +} + +export function newsItem(n: News, symbol: string): Item { + return { + id: String(n.id || n.url), + title: n.headline, + subtitle: n.source, + icon: { source: Icons.rss, tint: "orange" }, + keywords: [n.source ?? "", n.category ?? ""], + accessories: [{ text: ago(n.datetime), tooltip: new Date(n.datetime * 1000).toLocaleString("en-GB") }], + detail: { + markdown: [`**${n.headline}**`, ...(n.summary ? ["", n.summary.slice(0, 1200)] : [])].join("\n"), + metadata: [ + ...(n.source ? [meta.text("Source", n.source, { icon: Icons.globe })] : []), + meta.text("Published", new Date(n.datetime * 1000).toLocaleString("en-GB"), { icon: Icons.clock }), + ...(n.related ? [meta.tags("Related", n.related.split(",").filter(Boolean).slice(0, 6))] : []), + ...(domain(n.url) ? [meta.link("Link", domain(n.url)!, n.url)] : []), + ], + }, + actions: [ + open(n.url, "Open Article"), + copy(n.url, "Copy Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(`[${n.headline}](${n.url})`, "Copy as Markdown Link", { section: "Copy" }), + open(yahooUrl(symbol), `Open ${symbol} on Yahoo Finance`, { section: "Open" }), + ], + }; +} + +export function newsList(s: Stock, key: string, _signal?: AbortSignal): ListSpec { + return list({ + title: `${s.symbol} News`, + placeholder: `Filter ${s.symbol} headlines…`, + empty: { title: "No headlines", description: "Nothing in the last two weeks", icon: Icons.rss }, + status: (items) => `${items.length} headline${items.length === 1 ? "" : "s"} · last 14 days · Finnhub`, + items: ({ signal }) => guarded(async () => (await news(key, s.symbol, signal)).map((n) => newsItem(n, s.symbol))), + }); +} diff --git a/extensions/youtube/api.ts b/extensions/youtube/api.ts new file mode 100644 index 0000000..7375c2f --- /dev/null +++ b/extensions/youtube/api.ts @@ -0,0 +1,184 @@ +// The YouTube Data API v3 with the user's API key. Searches cost 100 of the +// 10,000 daily quota units, so answers are cached. + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { + apiError, + type ApiVideo, + fromApi, + type PlaylistItem, + searchIds, + type SearchResult, + uploadsPlaylist, + type Video, +} from "./lib.ts"; + +const API = "https://www.googleapis.com/youtube/v3"; +export const KEY_URL = "https://console.cloud.google.com/apis/library/youtube.googleapis.com"; +export const CREDENTIALS_URL = "https://console.cloud.google.com/apis/credentials"; + +type Json = Parameters<typeof storage.set>[1]; + +export class AuthError extends Error {} +export class QuotaError extends Error {} + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +export interface Account { + key: string; + region: string; +} + +export function apiKey(preferences: Record<string, unknown>): string { + const key = String(preferences.apiKey ?? "").trim(); + if (!key) throw new AuthError("Add your YouTube Data API key"); + return key; +} + +async function yt<T>(a: Account, path: string, params: Record<string, string>, signal?: AbortSignal): Promise<T> { + const q = new URLSearchParams({ ...params, key: a.key }); + let res: Response; + try { + res = await fetch(`${API}/${path}?${q}`, { signal, headers: { Accept: "application/json" } }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`YouTube is unreachable: ${(e as Error).message}`); + } + const body = await res.json().catch(() => undefined); + if (!res.ok) { + const { message, auth, quota } = apiError(res.status, body); + throw auth ? new AuthError(message) : quota ? new QuotaError(message) : new Error(message); + } + return body as T; +} + +const memory = new Map<string, { at: number; data: unknown }>(); + +/** An answer kept `ttl` seconds in memory and storage (quota is precious). */ +async function cached<T>(id: string, ttl: number, fetcher: () => Promise<T>): Promise<T> { + const hit = (memory.get(id) as { at: number; data: T } | undefined) ?? await load<{ at: number; data: T }>(id); + if (hit && Date.now() - hit.at < ttl * 1000) return hit.data; + const data = await fetcher(); + const entry = { at: Date.now(), data }; + memory.set(id, entry); + await save(id, entry); + return data; +} + +/** Full details (duration, views…) of videos, in the given order. 1 quota unit. */ +export async function videos(a: Account, ids: string[], signal?: AbortSignal): Promise<Video[]> { + if (!ids.length) return []; + const r = await yt<{ items?: ApiVideo[] }>(a, "videos", { + part: "snippet,contentDetails,statistics", + id: ids.slice(0, 50).join(","), + maxResults: "50", + }, signal); + const byId = new Map((r.items ?? []).map((v) => [v.id, fromApi(v)])); + return ids.map((id) => byId.get(id)).filter((v): v is Video => !!v); +} + +export function search(a: Account, query: string, signal?: AbortSignal): Promise<Video[]> { + const q = query.trim(); + return cached(`search:${a.region}:${q.toLowerCase()}`, 3600, async () => { + const r = await yt<{ items?: SearchResult[] }>(a, "search", { + part: "snippet", + type: "video", + maxResults: "25", + q, + regionCode: a.region, + safeSearch: "moderate", + }, signal); + return videos(a, searchIds(r.items ?? []), signal); + }); +} + +export function trending(a: Account, signal?: AbortSignal): Promise<Video[]> { + return cached(`trending:${a.region}`, 1800, async () => { + const r = await yt<{ items?: ApiVideo[] }>(a, "videos", { + part: "snippet,contentDetails,statistics", + chart: "mostPopular", + regionCode: a.region, + maxResults: "25", + }, signal); + return (r.items ?? []).map(fromApi); + }); +} + +/** A channel's latest uploads. 2 quota units. */ +export function channelVideos(a: Account, channelId: string, signal?: AbortSignal): Promise<Video[]> { + return cached(`channel:${channelId}`, 1800, async () => { + const r = await yt<{ items?: PlaylistItem[] }>(a, "playlistItems", { + part: "contentDetails", + playlistId: uploadsPlaylist(channelId), + maxResults: "30", + }, signal); + const ids = (r.items ?? []).map((i) => i.contentDetails?.videoId).filter((id): id is string => !!id); + return videos(a, ids, signal); + }); +} + +// --------------------------------------------------------------------------- +// Setup + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Free: a Google Cloud API key with the YouTube Data API v3", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect YouTube", + "", + "Searching YouTube reliably needs Google's **YouTube Data API v3** and your own free API key.", + "Keyless front ends (Invidious, Piped) break or get blocked too often to depend on.", + "", + "1. In the Google Cloud console, create a project (any name).", + "2. Open **YouTube Data API v3** (Enter opens it) and click **Enable**.", + "3. In **APIs & Services → Credentials**, click **Create credentials → API key**.", + " Restricting the key to the YouTube Data API v3 is a good idea.", + "4. Paste it in **Settings → Extensions → YouTube → Configure**.", + "", + "The free quota is 10,000 units a day: a search costs 100, trending and channels 1 or 2.", + "Results are cached for an hour to make it last.", + ].join("\n"), + }, + actions: [ + open(KEY_URL, "Enable the YouTube Data API", { icon: Icons.key }), + open(CREDENTIALS_URL, "Create an API Key", { icon: Icons.key }), + ], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + if (e instanceof QuotaError) { + return [{ + id: "quota", + title: e.message, + subtitle: "It resets at midnight Pacific time", + icon: { source: Icons.hourglass, tint: "orange" }, + detail: { + markdown: [ + "## Out of quota for today", + "", + "Each API key gets 10,000 units a day; a search costs 100 of them. Searches made today", + "are still cached for an hour. The quota resets at midnight Pacific time, or you can ask", + "Google for more in the Cloud console (Enter opens it).", + ].join("\n"), + }, + actions: [open("https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas", "Open Quotas")], + }]; + } + throw e; + } +} diff --git a/extensions/youtube/backslash.toml b/extensions/youtube/backslash.toml new file mode 100644 index 0000000..b557f81 --- /dev/null +++ b/extensions/youtube/backslash.toml @@ -0,0 +1,45 @@ +[extension] +id = "youtube" +title = "YouTube" +description = "Search YouTube with thumbnails, views and durations, open videos in the browser or play them in mpv in the background, and browse channels" +version = "1.0.0" +author = "Backslash" +icon = "bs-video" +license = "MIT" + +# The YouTube Data API v3 (www.googleapis.com) with the user's own API key, +# and i.ytimg.com where thumbnails are served. +# Videos play in mpv (with yt-dlp) as a transient systemd user unit, so they +# keep playing once the launcher is hidden; mpv and yt-dlp are also run once to +# check they are installed. The display variables let mpv open its window from +# that unit; /etc/os-release picks the install command to suggest. +[permissions] +net = ["www.googleapis.com", "i.ytimg.com"] +run = ["systemd-run", "systemctl", "mpv", "yt-dlp"] +read = ["/etc/os-release"] +env = ["WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"] + +[[commands]] +name = "search" +title = "Search YouTube" +description = "Trending videos, or type to search; open, play in mpv, copy the link, browse the channel" +icon = "bs-video" +keywords = ["youtube", "yt", "video", "videos", "watch", "music", "channel", "vidéo", "vidéos", "regarder", "chaîne"] + +[[preferences]] +name = "apiKey" +title = "YouTube Data API key" +description = "Google Cloud console → enable YouTube Data API v3 → Credentials → Create API key" +type = "secret" + +[[preferences]] +name = "region" +title = "Region" +description = "Two-letter country code for trending videos and results: US, GB, FR, DE…" +default = "US" + +[[preferences]] +name = "enter" +title = "Enter" +description = "What Enter does on a video: browser (open on youtube.com) or mpv (play in mpv)" +default = "browser" diff --git a/extensions/youtube/commands/search.ts b/extensions/youtube/commands/search.ts new file mode 100644 index 0000000..3498556 --- /dev/null +++ b/extensions/youtube/commands/search.ts @@ -0,0 +1,33 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { type Account, apiKey, guarded, search, trending, videos } from "../api.ts"; +import { normalizeRegion, videoIdFrom } from "../lib.ts"; +import { nowPlaying } from "../player.ts"; +import { nowPlayingItem, type Options, videoItem } from "../views.ts"; + +export default (ctx: Context) => { + const region = normalizeRegion(ctx.preferences.region); + const enter = String(ctx.preferences.enter ?? "").trim().toLowerCase() === "mpv" ? "mpv" : "browser"; + return list({ + placeholder: "Search YouTube, or paste a video link…", + empty: { title: "No video found", description: "Try other words", icon: Icons.video }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + if (items[0]?.id === "quota") return "Quota used up"; + const n = items.filter((i) => i.id !== "now-playing").length; + return `${n} video${n === 1 ? "" : "s"} · ${region} · YouTube Data API`; + }, + search: (query, { signal }) => + guarded(async (): Promise<Item[]> => { + const account: Account = { key: apiKey(ctx.preferences), region }; + const current = await nowPlaying(); + const o: Options = { account, enter, playing: current?.video.id }; + const pasted = videoIdFrom(query); + if (pasted) return (await videos(account, [pasted], signal)).map((v) => videoItem(v, o)); + if (query.trim()) return (await search(account, query, signal)).map((v) => videoItem(v, o)); + return [ + ...(current ? [nowPlayingItem(current)] : []), + ...(await trending(account, signal)).map((v) => videoItem(v, { ...o, section: `Trending in ${region}` })), + ]; + }), + }); +}; diff --git a/extensions/youtube/lib.ts b/extensions/youtube/lib.ts new file mode 100644 index 0000000..b573396 --- /dev/null +++ b/extensions/youtube/lib.ts @@ -0,0 +1,279 @@ +// YouTube Data API v3 answers, mpv command lines and formatting. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Shapes + +interface Thumbs { + default?: { url: string }; + medium?: { url: string }; + high?: { url: string }; + standard?: { url: string }; + maxres?: { url: string }; +} + +/** An item of `videos.list?part=snippet,contentDetails,statistics`. */ +export interface ApiVideo { + id: string; + snippet?: { + publishedAt?: string; + channelId?: string; + title?: string; + description?: string; + thumbnails?: Thumbs; + channelTitle?: string; + tags?: string[]; + liveBroadcastContent?: "none" | "live" | "upcoming" | string; + defaultAudioLanguage?: string; + }; + contentDetails?: { duration?: string; definition?: string; caption?: string }; + statistics?: { viewCount?: string; likeCount?: string; commentCount?: string }; + liveStreamingDetails?: { concurrentViewers?: string; scheduledStartTime?: string }; +} + +/** An item of `search.list?type=video`. */ +export interface SearchResult { + id: { kind?: string; videoId?: string }; + snippet?: ApiVideo["snippet"]; +} + +/** An item of `playlistItems.list` (a channel's uploads). */ +export interface PlaylistItem { + contentDetails?: { videoId?: string; videoPublishedAt?: string }; +} + +export interface Video { + id: string; + title: string; + channel: string; + channelId?: string; + published?: string; + description?: string; + /** Seconds; undefined for live streams and premieres. */ + seconds?: number; + views?: number; + likes?: number; + comments?: number; + live?: "live" | "upcoming"; + hd?: boolean; + captions?: boolean; + tags: string[]; +} + +// --------------------------------------------------------------------------- +// Mapping + +const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; + +/** search.list escapes titles (`Rock & Roll`, `It's`); videos.list doesn't. */ +export function decode(s: string): string { + return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : all; + } + return ENTITIES[e.toLowerCase()] ?? all; + }); +} + +/** ISO 8601 `PT1H2M3S` → 3723 seconds; `P1DT2H` too; `P0D` (live) → undefined. */ +export function parseDuration(iso: string | undefined): number | undefined { + const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso ?? ""); + if (!m) return undefined; + const [d, h, min, s] = m.slice(1).map((x) => Number(x ?? 0)); + const total = d * 86400 + h * 3600 + min * 60 + s; + return total || undefined; +} + +const num = (s: string | undefined) => (s === undefined || s === "" ? undefined : Number(s)); + +export function fromApi(v: ApiVideo): Video { + const sn = v.snippet ?? {}; + const live = sn.liveBroadcastContent === "live" || sn.liveBroadcastContent === "upcoming" + ? sn.liveBroadcastContent + : undefined; + return { + id: v.id, + title: decode(sn.title ?? v.id), + channel: decode(sn.channelTitle ?? ""), + channelId: sn.channelId, + published: sn.publishedAt, + description: sn.description || undefined, + seconds: live ? undefined : parseDuration(v.contentDetails?.duration), + views: num(v.statistics?.viewCount), + likes: num(v.statistics?.likeCount), + comments: num(v.statistics?.commentCount), + live, + hd: v.contentDetails?.definition === "hd", + captions: v.contentDetails?.caption === "true", + tags: sn.tags ?? [], + }; +} + +/** Video ids of search results, in order, without channels or playlists. */ +export const searchIds = ( + items: SearchResult[], +) => [...new Set(items.map((i) => i.id?.videoId).filter((id): id is string => !!id))]; + +/** A channel's uploads playlist: `UCxxxx` → `UUxxxx`. */ +export const uploadsPlaylist = (channelId: string) => channelId.replace(/^UC/, "UU"); + +// --------------------------------------------------------------------------- +// URLs + +export const watchUrl = (id: string) => `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`; +export const shortUrl = (id: string) => `https://youtu.be/${encodeURIComponent(id)}`; +export const channelUrl = (id: string) => `https://www.youtube.com/channel/${encodeURIComponent(id)}`; +export const thumbnail = (id: string, size: "mqdefault" | "hqdefault" | "sddefault") => + `https://i.ytimg.com/vi/${encodeURIComponent(id)}/${size}.jpg`; + +/** A video id from what the user pasted: a watch, youtu.be, shorts or embed URL. */ +export function videoIdFrom(text: string): string | undefined { + const t = text.trim(); + const m = /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([\w-]{11})/.exec(t); + return m?.[1]; +} + +// --------------------------------------------------------------------------- +// mpv + +export function mpvArgs(url: string, o: { audioOnly?: boolean; title?: string } = {}): string[] { + return [ + "mpv", + "--no-terminal", + ...(o.audioOnly + ? ["--no-video", "--ytdl-format=bestaudio/best"] + : ["--force-window=immediate", "--ytdl-format=bestvideo[height<=?1080]+bestaudio/best"]), + ...(o.title ? [`--title=${o.title}`] : []), + url, + ]; +} + +/** `systemd-run` arguments for a background unit, with the display variables mpv needs. */ +export function unitArgs(unit: string, description: string, env: Record<string, string | undefined>, argv: string[]) { + return [ + "--user", + "--quiet", + "--collect", + `--unit=${unit}`, + `--description=${description}`, + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + ...argv, + ]; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** 3723 → "1:02:03"; 75 → "1:15". */ +export function clock(seconds: number | undefined): string | undefined { + if (seconds === undefined) return undefined; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const ss = String(s).padStart(2, "0"); + return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`; +} + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +export function views(n: number | undefined): string | undefined { + if (n === undefined) return undefined; + return `${compact(n)} view${n === 1 ? "" : "s"}`; +} + +/** "3 hours ago", "2 weeks ago", "5 years ago", as YouTube says it. */ +export function ago(iso: string | undefined, now = Date.now()): string | undefined { + if (!iso) return undefined; + const t = Date.parse(iso); + if (Number.isNaN(t)) return undefined; + const s = Math.max(0, (now - t) / 1000); + const units: [number, string][] = [ + [365 * 86400, "year"], + [30 * 86400, "month"], + [7 * 86400, "week"], + [86400, "day"], + [3600, "hour"], + [60, "minute"], + ]; + for (const [size, name] of units) { + const n = Math.floor(s / size); + if (n >= 1) return `${n} ${name}${n === 1 ? "" : "s"} ago`; + } + return "just now"; +} + +export const longDate = (iso: string | undefined) => + iso + ? new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }) + : ""; + +/** The first lines of a description, without its link lists and hashtags walls. */ +export function excerpt(text: string | undefined, max = 600): string | undefined { + if (!text) return undefined; + const kept: string[] = []; + let length = 0; + let cut = false; + for (const line of text.replace(/\r/g, "").split("\n").map((l) => l.trim())) { + if (/^(#\w+\s*)+$/.test(line)) continue; + if (length + line.length > max) { + if (!kept.length) kept.push(line.slice(0, max)); + cut = true; + break; + } + kept.push(line); + length += line.length + 1; + } + const out = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim(); + return out ? out + (cut ? "…" : "") : undefined; +} + +/** `"us"` → `"US"`; anything else than two letters → `"US"`. */ +export function normalizeRegion(input: unknown): string { + const r = String(input ?? "").trim().toUpperCase(); + return /^[A-Z]{2}$/.test(r) ? r : "US"; +} + +/** The Data API's error body → a sentence and whether it is the key's fault. */ +export function apiError( + status: number, + body: { error?: { message?: string; errors?: { reason?: string }[] } } | undefined, +): { message: string; auth: boolean; quota?: boolean } { + const reason = body?.error?.errors?.[0]?.reason ?? ""; + if (reason === "keyInvalid" || (status === 400 && /API key not valid/i.test(body?.error?.message ?? ""))) { + return { message: "Google rejected the API key", auth: true }; + } + if (reason === "accessNotConfigured" || /has not been used|is disabled/i.test(body?.error?.message ?? "")) { + return { message: "Enable the YouTube Data API v3 for this key's Google Cloud project", auth: true }; + } + if (reason === "quotaExceeded" || reason === "dailyLimitExceeded") { + return { message: "Today's YouTube API quota is used up", auth: false, quota: true }; + } + if (reason === "ipRefererBlocked" || reason === "forbidden") { + return { message: "This API key doesn't allow requests from here (check its restrictions)", auth: true }; + } + return { message: `YouTube answered ${status}${body?.error?.message ? `: ${body.error.message}` : ""}`, auth: false }; +} + +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<Family, string> = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +export function installCommand(family: Family | undefined, packages: string[]): string { + return family ? `${MANAGERS[family]} ${packages.join(" ")}` : `Install ${packages.join(" and ")}`; +} diff --git a/extensions/youtube/lib_test.ts b/extensions/youtube/lib_test.ts new file mode 100644 index 0000000..0162ddf --- /dev/null +++ b/extensions/youtube/lib_test.ts @@ -0,0 +1,172 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + apiError, + type ApiVideo, + clock, + decode, + excerpt, + fromApi, + installCommand, + mpvArgs, + normalizeRegion, + parseDuration, + searchIds, + thumbnail, + unitArgs, + uploadsPlaylist, + videoIdFrom, + views, +} from "./lib.ts"; +import { videoItem } from "./views.ts"; + +// Shaped like `videos.list?part=snippet,contentDetails,statistics`. +const rick: ApiVideo = { + id: "dQw4w9WgXcQ", + snippet: { + publishedAt: "2009-10-25T06:57:33Z", + channelId: "UCuAXFkgsw1L7xaCfnd5JJOw", + title: "Rick Astley - Never Gonna Give You Up (Official Music Video)", + description: "The official video for “Never Gonna Give You Up”\n\n#RickAstley #NeverGonnaGiveYouUp", + channelTitle: "Rick Astley", + tags: ["rick astley", "never gonna give you up"], + liveBroadcastContent: "none", + }, + contentDetails: { duration: "PT3M33S", definition: "hd", caption: "true" }, + statistics: { viewCount: "1612345678", likeCount: "18000000", commentCount: "2400000" }, +}; +const live: ApiVideo = { + id: "jfKfPfyJRdk", + snippet: { title: "lofi hip hop radio", channelTitle: "Lofi Girl", liveBroadcastContent: "live" }, + contentDetails: { duration: "P0D" }, + statistics: { viewCount: "90000000" }, +}; + +const account = { key: "k", region: "US" }; + +Deno.test("durations", () => { + assertEquals(parseDuration("PT3M33S"), 213); + assertEquals(parseDuration("PT1H2M3S"), 3723); + assertEquals(parseDuration("PT45S"), 45); + assertEquals(parseDuration("P1DT2H"), 93600); + assertEquals(parseDuration("P0D"), undefined); + assertEquals(parseDuration("garbage"), undefined); + assertEquals(clock(213), "3:33"); + assertEquals(clock(3723), "1:02:03"); + assertEquals(clock(19), "0:19"); + assertEquals(clock(undefined), undefined); +}); + +Deno.test("videos", () => { + const v = fromApi(rick); + assertEquals(v.seconds, 213); + assertEquals(v.views, 1612345678); + assertEquals(v.hd, true); + assertEquals(v.captions, true); + const l = fromApi(live); + assertEquals(l.live, "live"); + assertEquals(l.seconds, undefined); + assertEquals(l.likes, undefined); + assertEquals(decode("Rock & Roll 'n' "Soul""), `Rock & Roll 'n' "Soul"`); + assertEquals( + searchIds([ + { id: { kind: "youtube#video", videoId: "a" } }, + { id: { kind: "youtube#channel" } }, + { id: { videoId: "a" } }, + { id: { videoId: "b" } }, + ]), + ["a", "b"], + ); + assertEquals(uploadsPlaylist("UCuAXFkgsw1L7xaCfnd5JJOw"), "UUuAXFkgsw1L7xaCfnd5JJOw"); +}); + +Deno.test("links", () => { + assertEquals(videoIdFrom("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s"), "dQw4w9WgXcQ"); + assertEquals(videoIdFrom("https://www.youtube.com/watch?list=PL1&v=dQw4w9WgXcQ"), "dQw4w9WgXcQ"); + assertEquals(videoIdFrom("youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ"); + assertEquals(videoIdFrom("https://youtube.com/shorts/abcdefghijk"), "abcdefghijk"); + assertEquals(videoIdFrom("never gonna give you up"), undefined); + assertEquals(thumbnail("dQw4w9WgXcQ", "hqdefault"), "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"); +}); + +Deno.test("formatting", () => { + const now = Date.parse("2026-10-28T10:00:00Z"); + assertEquals(ago("2026-10-28T07:00:00Z", now), "3 hours ago"); + assertEquals(ago("2026-10-27T09:00:00Z", now), "1 day ago"); + assertEquals(ago("2026-10-10T09:00:00Z", now), "2 weeks ago"); + assertEquals(ago("2009-10-25T06:57:33Z", now), "17 years ago"); + assertEquals(ago(undefined, now), undefined); + assertEquals(views(1), "1 view"); + assertEquals(views(1612345678), "1.6B views"); + assertEquals(excerpt("Line one\n#tag #other\n\n\n\nLine two"), "Line one\n\nLine two"); + assertEquals(excerpt("a".repeat(700))?.length, 601); + assertEquals(normalizeRegion("fr"), "FR"); + assertEquals(normalizeRegion("France"), "US"); +}); + +Deno.test("API errors", () => { + assertEquals( + apiError(400, { + error: { message: "API key not valid. Please pass a valid API key.", errors: [{ reason: "badRequest" }] }, + }), + { message: "Google rejected the API key", auth: true }, + ); + assertEquals(apiError(403, { error: { errors: [{ reason: "quotaExceeded" }] } }).quota, true); + assertEquals( + apiError(403, { + error: { + message: "YouTube Data API v3 has not been used in project 1", + errors: [{ reason: "accessNotConfigured" }], + }, + }) + .auth, + true, + ); + assertEquals(apiError(500, undefined), { message: "YouTube answered 500", auth: false }); +}); + +Deno.test("mpv", () => { + assertEquals(mpvArgs("https://www.youtube.com/watch?v=x", { audioOnly: true }), [ + "mpv", + "--no-terminal", + "--no-video", + "--ytdl-format=bestaudio/best", + "https://www.youtube.com/watch?v=x", + ]); + assertEquals(mpvArgs("u", { title: "T" })[2], "--force-window=immediate"); + assertEquals( + unitArgs("backslash-youtube", "Backslash YouTube: T", { WAYLAND_DISPLAY: "wayland-1", DISPLAY: undefined }, [ + "mpv", + "u", + ]), + [ + "--user", + "--quiet", + "--collect", + "--unit=backslash-youtube", + "--description=Backslash YouTube: T", + "--setenv=WAYLAND_DISPLAY=wayland-1", + "mpv", + "u", + ], + ); + assertEquals(installCommand("debian", ["mpv", "yt-dlp"]), "sudo apt install mpv yt-dlp"); + assertEquals(installCommand(undefined, ["yt-dlp"]), "Install yt-dlp"); +}); + +Deno.test("video item", () => { + const item = videoItem(fromApi(rick), { account, enter: "browser" }); + assertEquals(item.icon, "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg"); + assertEquals(item.detail?.image, "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"); + assertEquals(item.accessories?.map((a) => a.text), ["1.6B", "3:33"]); + assertEquals(item.detail?.stats?.map((s) => s.value), ["1.6B", "3:33", "18M"]); + assertEquals(item.actions?.slice(0, 4).map((a) => a.title), [ + "Open in Browser", + "Play in mpv", + "Play Audio Only in mpv", + "Show Rick Astley Videos", + ]); + const mpvFirst = videoItem(fromApi(live), { account, enter: "mpv", playing: "jfKfPfyJRdk" }); + assertEquals(mpvFirst.actions?.[0].title, "Play in mpv"); + assertEquals(mpvFirst.accessories?.map((a) => a.tag ?? a.text), ["Playing", "LIVE", "90M"]); +}); diff --git a/extensions/youtube/player.ts b/extensions/youtube/player.ts new file mode 100644 index 0000000..e559995 --- /dev/null +++ b/extensions/youtube/player.ts @@ -0,0 +1,108 @@ +// Playing a video in mpv (which streams it with yt-dlp) as a transient systemd +// user unit, so it keeps playing once the launcher is hidden. + +import { exec } from "@backslash/api"; +import { load, save } from "./api.ts"; +import { type Family, mpvArgs, osFamily, unitArgs, type Video, watchUrl } from "./lib.ts"; + +export const UNIT = "backslash-youtube"; + +export interface Playing { + video: Video; + audioOnly: boolean; + started: number; +} + +async function installed(name: string): Promise<boolean> { + try { + await new Deno.Command(name, { + args: ["--version"], + 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 Tools { + mpv: boolean; + ytdlp: boolean; +} + +export async function tools(): Promise<Tools> { + const [mpv, ytdlp] = await Promise.all([installed("mpv"), installed("yt-dlp")]); + return { mpv, ytdlp }; +} + +export async function distro(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +async function unitActive(): Promise<boolean> { + try { + return (await exec("systemctl", ["--user", "is-active", `${UNIT}.service`])) === "active"; + } catch { + return false; + } +} + +export async function nowPlaying(): Promise<Playing | undefined> { + const saved = await load<Playing>("playing"); + if (!saved) return undefined; + if (await unitActive()) return saved; + await save("playing", null); + return undefined; +} + +export async function stop(): Promise<void> { + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + await save("playing", null); +} + +const env = (k: string) => { + try { + return Deno.env.get(k); + } catch { + return undefined; + } +}; + +export class MissingTool extends Error {} + +export async function play(video: Video, audioOnly: boolean): Promise<Playing> { + const t = await tools(); + if (!t.mpv || !t.ytdlp) { + const missing = [...(t.mpv ? [] : ["mpv"]), ...(t.ytdlp ? [] : ["yt-dlp"])]; + throw new MissingTool(`Playing needs ${missing.join(" and ")}`); + } + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + const display = Object.fromEntries( + ["WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"].map((k) => [k, env(k)]), + ); + try { + await exec( + "systemd-run", + unitArgs( + UNIT, + `Backslash YouTube: ${video.title}`, + display, + mpvArgs(watchUrl(video.id), { audioOnly, title: video.title }), + ), + ); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to play in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: videos play in the background with systemd-run --user"); + } + throw e; + } + const playing = { video, audioOnly, started: Date.now() }; + await save("playing", playing); + return playing; +} diff --git a/extensions/youtube/views.ts b/extensions/youtube/views.ts new file mode 100644 index 0000000..938b28f --- /dev/null +++ b/extensions/youtube/views.ts @@ -0,0 +1,168 @@ +// Videos as launcher items, a channel's uploads and what's playing. + +import { + type Accessory, + type Action, + action, + close, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + toast, +} from "@backslash/api"; +import { type Account, channelVideos, guarded } from "./api.ts"; +import { + ago, + channelUrl, + clock, + compact, + excerpt, + installCommand, + longDate, + shortUrl, + thumbnail, + type Video, + views, + watchUrl, +} from "./lib.ts"; +import { distro, MissingTool, play, type Playing, stop } from "./player.ts"; + +export interface Options { + account: Account; + enter: "browser" | "mpv"; + section?: string; + playing?: string; +} + +function playAction(v: Video, audioOnly: boolean): Action { + return action(audioOnly ? "Play Audio Only in mpv" : "Play in mpv", async () => { + try { + await play(v, audioOnly); + } catch (e) { + if (e instanceof MissingTool) { + const cmd = installCommand(await distro(), e.message.replace("Playing needs ", "").split(" and ")); + await toast(e.message, { style: "failure", message: cmd }); + return; + } + throw e; + } + return close(audioOnly ? `Playing the audio of ${v.title}` : `Playing ${v.title}`); + }, { icon: audioOnly ? Icons.headphones : Icons.play, ...(audioOnly ? { shortcut: "<Ctrl><Shift>p" } : {}) }); +} + +function videoMetadata(v: Video): Metadata[] { + const lines: Metadata[] = [meta.person("Channel", v.channel)]; + if (v.published) { + lines.push(meta.text("Published", `${longDate(v.published)} · ${ago(v.published)}`, { icon: Icons.calendar })); + } + if (v.seconds) lines.push(meta.text("Duration", clock(v.seconds)!, { icon: Icons.clock })); + if (v.live) { + lines.push(meta.tags("Status", [{ text: v.live === "live" ? "Live now" : "Upcoming", color: "red" }])); + } + if (v.views !== undefined) { + lines.push(meta.text("Views", new Intl.NumberFormat("en-US").format(v.views), { icon: Icons.eye })); + } + if (v.likes !== undefined) lines.push(meta.text("Likes", compact(v.likes), { icon: Icons.heart })); + if (v.comments !== undefined) lines.push(meta.text("Comments", compact(v.comments), { icon: Icons.comment })); + const badges = [...(v.hd ? ["HD"] : []), ...(v.captions ? ["CC"] : [])]; + if (badges.length) lines.push(meta.tags("Quality", badges)); + if (v.tags.length) lines.push(meta.tags("Tags", v.tags.slice(0, 6))); + lines.push(meta.separator(), meta.link("Link", `youtu.be/${v.id}`, watchUrl(v.id))); + return lines; +} + +export function videoItem(v: Video, o: Options): Item { + const openIt = open(watchUrl(v.id), "Open in Browser"); + const playing = o.playing === v.id; + const accessories: Accessory[] = []; + if (playing) accessories.push({ tag: "Playing", color: "green" }); + if (v.live) accessories.push({ tag: v.live === "live" ? "LIVE" : "Upcoming", color: "red" }); + if (v.views !== undefined) accessories.push({ text: compact(v.views), icon: Icons.eye, tooltip: views(v.views) }); + if (v.seconds) accessories.push({ text: clock(v.seconds), tooltip: "Duration" }); + const actions: Action[] = o.enter === "mpv" ? [playAction(v, false), openIt] : [openIt, playAction(v, false)]; + actions.push( + playAction(v, true), + ...(v.channelId + ? [ + action(`Show ${v.channel || "Channel"} Videos`, () => channelList(o.account, v.channelId!, v.channel, o), { + icon: Icons.person, + shortcut: "<Ctrl>u", + }), + open(channelUrl(v.channelId), "Open Channel in Browser", { section: "Open" }), + ] + : []), + copy(watchUrl(v.id), "Copy Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(shortUrl(v.id), "Copy Short Link", { section: "Copy" }), + copy(`[${v.title}](${watchUrl(v.id)})`, "Copy as Markdown Link", { section: "Copy" }), + copy(v.title, "Copy Title", { section: "Copy" }), + ); + return { + id: `${o.section ?? ""}:${v.id}`, + title: v.title, + subtitle: [v.channel, ago(v.published)].filter(Boolean).join(" · "), + section: o.section, + icon: thumbnail(v.id, "mqdefault"), + keywords: [v.channel, ...v.tags.slice(0, 5)], + accessories, + detail: { + image: thumbnail(v.id, "hqdefault"), + stats: [ + ...(v.views !== undefined ? [{ label: "Views", value: compact(v.views) }] : []), + ...(v.seconds ? [{ label: "Duration", value: clock(v.seconds)! }] : []), + ...(v.likes !== undefined ? [{ label: "Likes", value: compact(v.likes) }] : []), + ], + markdown: [`**${v.title}**`, ...(excerpt(v.description) ? ["", excerpt(v.description)!] : [])].join("\n"), + metadata: videoMetadata(v), + }, + actions, + }; +} + +export function channelList(account: Account, channelId: string, name: string, o: Options): ListSpec { + return list({ + title: name || "Channel", + placeholder: `Filter ${name || "the channel"}'s latest videos…`, + empty: { title: "No videos", description: "This channel has no public uploads", icon: Icons.video }, + status: (items) => `${items.length} latest upload${items.length === 1 ? "" : "s"} · ${name}`, + actions: [open(channelUrl(channelId), "Open Channel in Browser")], + items: ({ signal }) => + guarded(async () => + (await channelVideos(account, channelId, signal)).map((v) => videoItem(v, { ...o, section: undefined })) + ), + }); +} + +export function nowPlayingItem(p: Playing): Item { + const v = p.video; + return { + id: "now-playing", + title: v.title, + subtitle: `${p.audioOnly ? "Audio only" : "Playing"} in mpv · ${v.channel}`, + section: "Now Playing", + icon: { source: p.audioOnly ? Icons.headphones : Icons.play, tint: "green" }, + detail: { + image: thumbnail(v.id, "hqdefault"), + metadata: [ + meta.text("Started", new Date(p.started).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }), { + icon: Icons.clock, + }), + ...videoMetadata(v), + ], + }, + actions: [ + action("Stop", async () => { + await stop(); + await toast("Stopped", { style: "success" }); + return refresh(); + }, { icon: Icons.stop, shortcut: "<Ctrl>period" }), + open(watchUrl(v.id), "Open in Browser"), + copy(watchUrl(v.id), "Copy Link", { section: "Copy" }), + ], + }; +} From beb6c18c7d34cda468cc04fd08014c66582c80d8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 28 Oct 2026 10:43:02 +0000 Subject: [PATCH 07/10] Git Repos, Codeberg, Package Registries, HTTP Client, Regex Tester, Cron Helper and Dev Reference Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/codeberg/api.ts | 88 ++ extensions/codeberg/backslash.toml | 47 ++ extensions/codeberg/commands/issues.ts | 29 + extensions/codeberg/commands/notifications.ts | 84 ++ extensions/codeberg/commands/pull-requests.ts | 55 ++ extensions/codeberg/commands/repositories.ts | 42 + extensions/codeberg/lib.ts | 453 +++++++++++ extensions/codeberg/lib_test.ts | 280 +++++++ extensions/cron/backslash.toml | 31 + extensions/cron/commands/cron.ts | 250 ++++++ extensions/cron/lib.ts | 751 ++++++++++++++++++ extensions/cron/lib_test.ts | 271 +++++++ extensions/dev-reference/backslash.toml | 35 + .../dev-reference/commands/dev-reference.ts | 59 ++ extensions/dev-reference/data.ts | 574 +++++++++++++ extensions/dev-reference/lib.ts | 421 ++++++++++ extensions/dev-reference/lib_test.ts | 146 ++++ extensions/git-repos/backslash.toml | 85 ++ extensions/git-repos/commands/repos.ts | 403 ++++++++++ extensions/git-repos/git.ts | 89 +++ extensions/git-repos/lib.ts | 223 ++++++ extensions/git-repos/lib_test.ts | 178 +++++ extensions/git-repos/terminal.ts | 110 +++ extensions/http-client/backslash.toml | 41 + extensions/http-client/commands/request.ts | 103 +++ extensions/http-client/commands/saved.ts | 27 + extensions/http-client/lib.ts | 404 ++++++++++ extensions/http-client/lib_test.ts | 255 ++++++ extensions/http-client/views.ts | 309 +++++++ extensions/package-registries/api.ts | 112 +++ extensions/package-registries/backslash.toml | 51 ++ .../package-registries/commands/search.ts | 149 ++++ extensions/package-registries/lib.ts | 492 ++++++++++++ extensions/package-registries/lib_test.ts | 436 ++++++++++ extensions/regex/backslash.toml | 28 + extensions/regex/commands/regex.ts | 333 ++++++++ extensions/regex/lib.ts | 624 +++++++++++++++ extensions/regex/lib_test.ts | 164 ++++ 38 files changed, 8232 insertions(+) create mode 100644 extensions/codeberg/api.ts create mode 100644 extensions/codeberg/backslash.toml create mode 100644 extensions/codeberg/commands/issues.ts create mode 100644 extensions/codeberg/commands/notifications.ts create mode 100644 extensions/codeberg/commands/pull-requests.ts create mode 100644 extensions/codeberg/commands/repositories.ts create mode 100644 extensions/codeberg/lib.ts create mode 100644 extensions/codeberg/lib_test.ts create mode 100644 extensions/cron/backslash.toml create mode 100644 extensions/cron/commands/cron.ts create mode 100644 extensions/cron/lib.ts create mode 100644 extensions/cron/lib_test.ts create mode 100644 extensions/dev-reference/backslash.toml create mode 100644 extensions/dev-reference/commands/dev-reference.ts create mode 100644 extensions/dev-reference/data.ts create mode 100644 extensions/dev-reference/lib.ts create mode 100644 extensions/dev-reference/lib_test.ts create mode 100644 extensions/git-repos/backslash.toml create mode 100644 extensions/git-repos/commands/repos.ts create mode 100644 extensions/git-repos/git.ts create mode 100644 extensions/git-repos/lib.ts create mode 100644 extensions/git-repos/lib_test.ts create mode 100644 extensions/git-repos/terminal.ts create mode 100644 extensions/http-client/backslash.toml create mode 100644 extensions/http-client/commands/request.ts create mode 100644 extensions/http-client/commands/saved.ts create mode 100644 extensions/http-client/lib.ts create mode 100644 extensions/http-client/lib_test.ts create mode 100644 extensions/http-client/views.ts create mode 100644 extensions/package-registries/api.ts create mode 100644 extensions/package-registries/backslash.toml create mode 100644 extensions/package-registries/commands/search.ts create mode 100644 extensions/package-registries/lib.ts create mode 100644 extensions/package-registries/lib_test.ts create mode 100644 extensions/regex/backslash.toml create mode 100644 extensions/regex/commands/regex.ts create mode 100644 extensions/regex/lib.ts create mode 100644 extensions/regex/lib_test.ts diff --git a/extensions/codeberg/api.ts b/extensions/codeberg/api.ts new file mode 100644 index 0000000..508cc25 --- /dev/null +++ b/extensions/codeberg/api.ts @@ -0,0 +1,88 @@ +// The Forgejo API of codeberg.org (`/api/v1`) with a personal access token. + +import { Icons, type Item, open } from "@backslash/api"; + +export const HOST = "https://codeberg.org"; +export const TOKEN_URL = `${HOST}/user/settings/applications`; + +export class AuthError extends Error {} + +export function token(preferences: Record<string, unknown>): string { + const t = String(preferences.token ?? "").trim(); + if (!t) throw new AuthError("Add a Codeberg access token"); + return t; +} + +export async function forgejo<T>( + t: string, + path: string, + options: { method?: string; signal?: AbortSignal } = {}, +): Promise<T> { + const res = await fetch(`${HOST}/api/v1${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { Authorization: `token ${t}`, Accept: "application/json" }, + }); + if (res.status === 401) throw new AuthError("Codeberg rejected the access token"); + if (res.status === 403) { + const body = await res.json().catch(() => ({})) as { message?: string }; + throw new AuthError( + /scope/i.test(body.message ?? "") + ? `The token lacks a permission: ${body.message}` + : `Codeberg refused the request${body.message ? `: ${body.message}` : ""}`, + ); + } + if (res.status === 429) throw new Error("Codeberg is rate limiting: try again in a minute"); + if (!res.ok) throw new Error(`Codeberg answered ${res.status}`); + if (res.status === 204 || res.status === 205) return undefined as T; + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +/** The row shown instead of results until a token works. */ +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Codeberg: personal access token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Codeberg", + "", + "1. Open **Settings → Applications** on codeberg.org (Enter opens it).", + "2. Under **Generate new token**, give it a name and these permissions:", + " - **issue**: Read (pull requests and issues)", + " - **repository**: Read", + " - **user**: Read", + " - **notification**: Read and write (to mark notifications as read)", + "3. Paste the token in **Settings → Extensions → Codeberg → Configure**.", + "", + "Only codeberg.org is supported: a self-hosted Forgejo or Gitea would need its own host in the extension's network permission.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Access Token", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} + +/** The same, for lists that load in two steps. */ +export async function* guardedSteps(load: () => AsyncGenerator<Item[]>): AsyncGenerator<Item[]> { + try { + yield* load(); + } catch (e) { + if (e instanceof AuthError) yield setupItems(e); + else throw e; + } +} + +export const countStatus = (items: Item[], one: string, many = `${one}s`) => + items[0]?.id === "setup" ? "Not connected" : `${items.length} ${items.length === 1 ? one : many}`; diff --git a/extensions/codeberg/backslash.toml b/extensions/codeberg/backslash.toml new file mode 100644 index 0000000..c3d4723 --- /dev/null +++ b/extensions/codeberg/backslash.toml @@ -0,0 +1,47 @@ +[extension] +id = "codeberg" +title = "Codeberg" +description = "Your Codeberg pull requests with checks, issues, repositories and notifications" +version = "1.0.0" +author = "Backslash" +icon = "bs-repo" +license = "MIT" + +[permissions] +# codeberg.org only: the API and the avatars it serves. A self-hosted Forgejo or +# Gitea would need its own host here, which a preference can't add. +net = ["codeberg.org"] + +[[commands]] +name = "pull-requests" +title = "My Codeberg Pull Requests" +description = "Review requested, created by you, assigned or mentioning you: with branches, diff and checks" +icon = "bs-pr-open" +keywords = ["codeberg", "forgejo", "gitea", "pr", "pull request", "review", "merge request", "relecture"] + +[[commands]] +name = "issues" +title = "My Codeberg Issues" +description = "Open issues assigned to you, created by you or mentioning you" +icon = "bs-issue-open" +keywords = ["codeberg", "forgejo", "gitea", "issues", "bugs", "tickets", "problème"] + +[[commands]] +name = "repositories" +title = "Search Codeberg Repositories" +description = "Your repositories, or search all of Codeberg as you type" +icon = "bs-repo" +keywords = ["codeberg", "forgejo", "gitea", "repo", "git", "code", "dépôt"] + +[[commands]] +name = "notifications" +title = "Codeberg Notifications" +description = "Unread and pinned notifications; mark them read or pin them" +icon = "bs-bell" +keywords = ["codeberg", "forgejo", "gitea", "notifications", "inbox", "alerts"] + +[[preferences]] +name = "token" +title = "Access token" +description = "Create one in Codeberg → Settings → Applications with read access to issues, repositories and user, and read/write to notifications" +type = "secret" diff --git a/extensions/codeberg/commands/issues.ts b/extensions/codeberg/commands/issues.ts new file mode 100644 index 0000000..15e3276 --- /dev/null +++ b/extensions/codeberg/commands/issues.ts @@ -0,0 +1,29 @@ +import { list } from "@backslash/api"; +import { countStatus, forgejo, guarded, token } from "../api.ts"; +import { groupByRepo, type Issue, issueItem } from "../lib.ts"; + +const FILTERS: Record<string, string> = { assigned: "assigned", created: "created", mentioned: "mentioned" }; + +export default list({ + placeholder: "Filter issues by title, repository, label, author…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "created", title: "Created by me" }, + { value: "mentioned", title: "Mentioning me" }, + ], + }, + empty: { title: "No open issue", description: "Nothing matches this view.", icon: "bs-issue-open" }, + status: (items) => countStatus(items, "issue"), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const t = token(preferences); + const key = filter && FILTERS[filter] ? filter : "assigned"; + const issues = await forgejo<Issue[]>( + t, + `/repos/issues/search?type=issues&state=open&${FILTERS[key]}=true&limit=50`, + { signal }, + ); + return groupByRepo(issues).map((i) => issueItem(i, i.repository.full_name)); + }), +}); diff --git a/extensions/codeberg/commands/notifications.ts b/extensions/codeberg/commands/notifications.ts new file mode 100644 index 0000000..ea867fe --- /dev/null +++ b/extensions/codeberg/commands/notifications.ts @@ -0,0 +1,84 @@ +import { + action, + close, + copy, + Icons, + type Item, + list, + open, + openExternal, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { countStatus, forgejo, guarded, token } from "../api.ts"; +import { ago, type Notification, notificationIcon, notificationMeta, notificationUrl, subjectNumber } from "../lib.ts"; + +function notificationItem(n: Notification, t: string): Item { + const number = subjectNumber(n); + const markRead = action("Mark as Read", async () => { + await forgejo(t, `/notifications/threads/${n.id}?to-status=read`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.check, shortcut: "<Ctrl>r" }); + const markUnread = action("Mark as Unread", async () => { + await forgejo(t, `/notifications/threads/${n.id}?to-status=unread`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.dot, shortcut: "<Ctrl>u" }); + const pin = action(n.pinned ? "Unpin" : "Pin", async () => { + await forgejo(t, `/notifications/threads/${n.id}?to-status=${n.pinned ? "read" : "pinned"}`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.pin, shortcut: "<Ctrl>p" }); + return { + id: String(n.id), + title: n.subject.title, + subtitle: `${n.repository.full_name}${number ? ` #${number}` : ""}`, + icon: notificationIcon(n), + section: n.pinned ? "Pinned" : n.unread ? "Unread" : "Read", + keywords: [n.repository.full_name, n.subject.type], + accessories: [ + ...(n.unread ? [{ icon: { source: Icons.dot, tint: "blue" as const }, tooltip: "Unread" }] : []), + { text: ago(n.updated_at), tooltip: "Updated" }, + ], + detail: { metadata: notificationMeta(n) }, + actions: [ + // Opening a notification on the web marks it read there too. + action("Open in Browser", async () => { + await openExternal(notificationUrl(n)); + if (n.unread) { + await forgejo(t, `/notifications/threads/${n.id}?to-status=read`, { method: "PATCH" }).catch(() => {}); + } + return close(); + }, { icon: Icons.external }), + n.unread ? markRead : markUnread, + pin, + open(`https://codeberg.org/${n.repository.full_name}`, "Open Repository", { icon: Icons.repository }), + copy(notificationUrl(n), "Copy URL", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + action("Mark All as Read", async () => { + await forgejo(t, "/notifications?to-status=read&status-types=unread", { method: "PUT" }); + await toast("All notifications marked as read", { style: "success" }); + return refresh(); + }, { icon: Icons.check, confirm: "Mark every notification as read?", section: "All" }), + ], + }; +} + +export default list({ + placeholder: "Filter notifications by title or repository…", + filter: { options: [{ value: "unread", title: "Unread" }, { value: "all", title: "All" }] }, + empty: { title: "You're all caught up", description: "No notification here.", icon: "bs-inbox" }, + status: (items) => countStatus(items, "notification"), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const t = token(preferences); + const all = filter === "all"; + const types = all + ? "status-types=unread&status-types=pinned&status-types=read" + : "status-types=unread&status-types=pinned"; + const list = await forgejo<Notification[]>(t, `/notifications?${types}&limit=50`, { signal }); + const unread = list.filter((n) => n.unread).length; + updateCommandMetadata({ subtitle: unread ? `${unread} unread` : null }).catch(() => {}); + const order = (n: Notification) => (n.pinned ? 0 : n.unread ? 1 : 2); + return [...list].sort((a, b) => order(a) - order(b) || b.updated_at.localeCompare(a.updated_at)) + .map((n) => notificationItem(n, t)); + }), +}); diff --git a/extensions/codeberg/commands/pull-requests.ts b/extensions/codeberg/commands/pull-requests.ts new file mode 100644 index 0000000..31bd902 --- /dev/null +++ b/extensions/codeberg/commands/pull-requests.ts @@ -0,0 +1,55 @@ +import { list, updateCommandMetadata } from "@backslash/api"; +import { countStatus, forgejo, guardedSteps, token } from "../api.ts"; +import { type CombinedStatus, groupByRepo, type Issue, issueItem, type Pull, type PullDetail } from "../lib.ts"; + +const FILTERS: Record<string, string> = { + review: "review_requested", + created: "created", + assigned: "assigned", + mentioned: "mentioned", +}; + +/** Branches, diff size and CI come from one call per pull request: only the first ones. */ +const ENRICH = 20; + +async function enrich(t: string, pr: Issue, signal: AbortSignal): Promise<Pull> { + const base = `/repos/${pr.repository.full_name}`; + const detail = await forgejo<PullDetail>(t, `${base}/pulls/${pr.number}`, { signal }).catch(() => undefined); + const ci = detail?.head.sha + ? await forgejo<CombinedStatus>(t, `${base}/commits/${detail.head.sha}/status`, { signal }).catch(() => undefined) + : undefined; + return { ...pr, detail, ci }; +} + +export default list({ + placeholder: "Filter pull requests by title, repository, author, branch…", + filter: { + options: [ + { value: "review", title: "Review requested" }, + { value: "created", title: "Created by me" }, + { value: "assigned", title: "Assigned" }, + { value: "mentioned", title: "Mentioning me" }, + ], + }, + empty: { title: "Nothing here", description: "No open pull request matches.", icon: "bs-pr-open" }, + status: (items) => countStatus(items, "pull request"), + items: ({ preferences, signal, filter }) => + guardedSteps(async function* () { + const t = token(preferences); + const key = filter && FILTERS[filter] ? filter : "review"; + const found = await forgejo<Issue[]>( + t, + `/repos/issues/search?type=pulls&state=open&${FILTERS[key]}=true&limit=50`, + { signal }, + ); + if (key === "review") { + updateCommandMetadata({ subtitle: found.length ? `${found.length} to review` : null }).catch(() => {}); + } + const prs = groupByRepo(found); + yield prs.map((pr) => issueItem(pr, pr.repository.full_name)); + const enriched = await Promise.all( + prs.map((pr, i) => (i < ENRICH ? enrich(t, pr, signal) : Promise.resolve(pr as Pull))), + ); + yield enriched.map((pr) => issueItem(pr, pr.repository.full_name)); + }), +}); diff --git a/extensions/codeberg/commands/repositories.ts b/extensions/codeberg/commands/repositories.ts new file mode 100644 index 0000000..844f43e --- /dev/null +++ b/extensions/codeberg/commands/repositories.ts @@ -0,0 +1,42 @@ +import { type Item, list, storage } from "@backslash/api"; +import { forgejo, guarded, token } from "../api.ts"; +import { type Repo, repoItem } from "../lib.ts"; + +let mine = 0; + +export default list({ + placeholder: "Search Codeberg repositories, or browse yours…", + empty: { title: "No repository found", icon: "bs-repo" }, + status: (items: Item[]) => + items[0]?.id === "setup" + ? "Not connected" + : items.every((i) => i.section === "Your Repositories") + ? `${mine} of your repositories` + : `${items.length} repositories`, + search: (query, { preferences, signal }) => + guarded(async () => { + const t = token(preferences); + const q = query.trim(); + if (!q) { + const repos = await forgejo<Repo[]>(t, "/user/repos?limit=50", { signal }); + mine = repos.length; + await storage.set("mine", repos.map((r) => r.id)).catch(() => {}); + return [...repos].sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + .map((r) => repoItem(r, "Your Repositories")); + } + const [found, ids] = await Promise.all([ + forgejo<{ ok: boolean; data: Repo[] }>( + t, + `/repos/search?q=${encodeURIComponent(q)}&limit=30&sort=updated&order=desc`, + { + signal, + }, + ), + storage.get<number[]>("mine").catch(() => undefined), + ]); + const own = new Set(ids ?? []); + // Yours first, then everyone's. + return [...found.data].sort((a, b) => Number(own.has(b.id)) - Number(own.has(a.id))) + .map((r) => repoItem(r, own.has(r.id) ? "Your Repositories" : "Codeberg")); + }), +}); diff --git a/extensions/codeberg/lib.ts b/extensions/codeberg/lib.ts new file mode 100644 index 0000000..ff1cffa --- /dev/null +++ b/extensions/codeberg/lib.ts @@ -0,0 +1,453 @@ +// Forgejo objects and how they look in lists and panels. Tested in lib_test.ts. + +import { + type Action, + action, + type Color, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, +} from "@backslash/api"; + +export interface User { + login: string; + full_name?: string; + avatar_url?: string; +} + +export interface Label { + name: string; + color: string; +} + +/** An issue or pull request from `/repos/issues/search`. */ +export interface Issue { + id: number; + number: number; + title: string; + html_url: string; + state: "open" | "closed"; + body?: string | null; + user: User; + labels?: Label[]; + milestone?: { title: string } | null; + assignees?: User[] | null; + comments: number; + created_at: string; + updated_at: string; + repository: { full_name: string; name: string; owner: string }; + pull_request?: { merged: boolean; draft?: boolean; html_url?: string } | null; +} + +/** `/repos/{owner}/{repo}/pulls/{index}`: what the search doesn't say. */ +export interface PullDetail { + head: { ref: string; sha: string }; + base: { ref: string }; + mergeable?: boolean; + merged?: boolean; + draft?: boolean; + additions?: number; + deletions?: number; + changed_files?: number; + requested_reviewers?: User[] | null; +} + +/** `/repos/{owner}/{repo}/commits/{sha}/status`. */ +export interface CombinedStatus { + state: "" | "pending" | "success" | "error" | "failure" | "warning"; + total_count: number; +} + +export interface Repo { + id: number; + full_name: string; + name: string; + owner: User; + description: string; + html_url: string; + clone_url: string; + ssh_url: string; + website?: string; + stars_count: number; + forks_count: number; + watchers_count?: number; + open_issues_count: number; + open_pr_counter?: number; + release_counter?: number; + language?: string; + default_branch: string; + private: boolean; + fork: boolean; + archived: boolean; + mirror?: boolean; + size?: number; + updated_at: string; + topics?: string[] | null; +} + +export interface Notification { + id: number; + unread: boolean; + pinned: boolean; + updated_at: string; + repository: { full_name: string }; + subject: { + title: string; + type: "Issue" | "Pull" | "Commit" | "Repository"; + state?: "open" | "closed" | "merged"; + html_url?: string; + latest_comment_html_url?: string; + }; +} + +// --- Formatting ------------------------------------------------------------------- + +/** "today", "yesterday", "3d", "25 Oct". */ +export function ago(iso: string, now = new Date()): string { + 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 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 label color (`e11d21` or `#e11d21`). */ +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"; +} + +function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").replace(/<!--[\s\S]*?-->/g, "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +// --- Issues and pull requests ----------------------------------------------------- + +export interface Pull extends Issue { + detail?: PullDetail; + ci?: CombinedStatus; +} + +export const isPull = (i: Issue) => !!i.pull_request; + +export function stateIcon(i: Pull): { source: string; tint: Color } { + if (isPull(i)) { + if (i.pull_request?.merged || i.detail?.merged) return { source: Icons.merged, tint: "purple" }; + if (i.state === "closed") return { source: Icons.pullRequestClosed, tint: "red" }; + if (i.pull_request?.draft || i.detail?.draft || /^(WIP|Draft)\b:?/i.test(i.title)) { + return { source: Icons.pullRequestDraft, tint: "gray" }; + } + return { source: Icons.pullRequest, tint: "green" }; + } + return i.state === "closed" ? { source: Icons.issueClosed, tint: "purple" } : { source: Icons.issue, tint: "green" }; +} + +function stateText(i: Pull): string { + const s = stateIcon(i).source; + if (s === Icons.merged) return "Merged"; + if (s === Icons.pullRequestDraft) return "Draft"; + return i.state === "closed" ? "Closed" : "Open"; +} + +/** CI of the head commit. */ +export function checks(i: Pull): { icon: string; color: Color; text: string } | undefined { + if (!i.ci || !i.ci.total_count) return undefined; + switch (i.ci.state) { + case "success": + return { icon: Icons.check, color: "green", text: "Checks passed" }; + case "failure": + case "error": + return { icon: Icons.cross, color: "red", text: "Checks failed" }; + case "warning": + return { icon: Icons.alert, color: "orange", text: "Checks passed with warnings" }; + case "pending": + return { icon: Icons.dot, color: "yellow", text: "Checks running" }; + default: + return undefined; + } +} + +export function issueMetadata(i: Pull): Metadata[] { + const s = stateIcon(i); + const ci = checks(i); + const lines: Metadata[] = [ + meta.text("Status", stateText(i), { icon: s.source, color: s.tint }), + meta.person("Author", i.user.full_name || i.user.login, i.user.avatar_url), + ]; + if (i.detail) lines.push(meta.text("Branch", `${i.detail.head.ref} → ${i.detail.base.ref}`, { icon: Icons.branch })); + if (ci) lines.push(meta.text("Checks", ci.text, { icon: ci.icon, color: ci.color })); + if (i.detail?.mergeable === false && i.state === "open" && !i.detail.merged) { + lines.push(meta.text("Merge", "Conflicts with the base branch", { icon: Icons.alert, color: "red" })); + } + const reviewers = (i.detail?.requested_reviewers ?? []).map((u) => u.login); + if (reviewers.length) lines.push(meta.tags("Reviewers", reviewers)); + const assignees = (i.assignees ?? []).map((u) => u.login); + if (assignees.length) lines.push(meta.tags("Assignees", assignees)); + if (i.labels?.length) { + lines.push(meta.tags("Labels", i.labels.map((l) => ({ text: l.name, color: colorOf(l.color) })))); + } + if (i.milestone) lines.push(meta.text("Milestone", i.milestone.title, { icon: Icons.flag })); + lines.push(meta.separator()); + lines.push(meta.text("Repository", i.repository.full_name, { icon: Icons.repository })); + lines.push(meta.text("Comments", String(i.comments), { icon: Icons.comment })); + lines.push(meta.text("Opened", ago(i.created_at))); + lines.push(meta.text("Updated", ago(i.updated_at))); + return lines; +} + +const stats = (i: Pull) => + i.detail?.additions !== undefined + ? [ + { label: "files", value: i.detail.changed_files ?? 0 }, + { label: "added", value: `+${i.detail.additions}`, color: "green" as Color }, + { label: "removed", value: `−${i.detail.deletions ?? 0}`, color: "red" as Color }, + ] + : []; + +export function issueActions(i: Pull, page = false): Action[] { + const ref = `${i.repository.full_name}#${i.number}`; + return [ + open(i.html_url, "Open in Browser"), + ...(page ? [] : [action("Show Details", () => issuePage(i), { icon: Icons.info })]), + ...(isPull(i) + ? [open(`${i.html_url}/files`, "Open Files Changed", { icon: Icons.code, shortcut: "<Ctrl>f" })] + : []), + copy(i.html_url, "Copy URL", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(ref, "Copy Reference", { icon: Icons.hash, section: "Copy" }), + copy(`[${ref}: ${i.title}](${i.html_url})`, "Copy as Markdown Link", { section: "Copy" }), + ...(i.detail + ? [ + copy(i.detail.head.ref, "Copy Branch Name", { icon: Icons.branch, section: "Copy" }), + copy( + `git fetch https://codeberg.org/${i.repository.full_name}.git pull/${i.number}/head:pr-${i.number} && git switch pr-${i.number}`, + "Copy Checkout Command", + { icon: Icons.terminal, section: "Copy" }, + ), + ] + : []), + ]; +} + +export function issuePage(i: Pull): DetailSpec { + return detail({ + title: `#${i.number}`, + markdown: `# ${i.title}\n\n${i.body?.replace(/<!--[\s\S]*?-->/g, "").trim() || "_No description._"}`, + stats: stats(i), + metadata: issueMetadata(i), + status: `${i.repository.full_name} #${i.number}`, + actions: issueActions(i, true), + }); +} + +export function issueItem(i: Pull, section?: string): Item { + const ci = checks(i); + const labels = (i.labels ?? []).slice(0, 2); + return { + id: String(i.id), + title: i.title, + subtitle: section ? `#${i.number}` : `${i.repository.name} #${i.number}`, + icon: stateIcon(i), + section, + keywords: [ + i.repository.full_name, + String(i.number), + i.user.login, + ...(i.labels ?? []).map((l) => l.name), + i.detail?.head.ref ?? "", + ], + accessories: [ + ...(ci ? [{ icon: { source: ci.icon, tint: ci.color }, tooltip: ci.text }] : []), + ...labels.map((l) => ({ tag: l.name, color: colorOf(l.color) })), + ...(i.detail?.additions !== undefined + ? [{ + text: `+${i.detail.additions} −${i.detail.deletions ?? 0}`, + tooltip: `${i.detail.changed_files ?? 0} files`, + }] + : []), + ...(i.comments ? [{ icon: Icons.comment, text: String(i.comments), tooltip: "Comments" }] : []), + { avatar: i.user.avatar_url ?? i.user.login, tooltip: i.user.login }, + { text: ago(i.updated_at), tooltip: "Updated" }, + ], + detail: { stats: stats(i), markdown: excerpt(i.body), metadata: issueMetadata(i) }, + actions: issueActions(i), + }; +} + +/** Repositories first by most recent activity, as Codeberg orders them. */ +export function groupByRepo(issues: Pull[]): Pull[] { + const order = new Map<string, number>(); + issues.forEach((i, n) => order.has(i.repository.full_name) || order.set(i.repository.full_name, n)); + return [...issues].sort((a, b) => order.get(a.repository.full_name)! - order.get(b.repository.full_name)!); +} + +// --- Repositories ----------------------------------------------------------------- + +const LANGUAGE_COLORS: Record<string, Color> = { + Rust: "orange", + Go: "teal", + Python: "blue", + TypeScript: "blue", + JavaScript: "yellow", + C: "gray", + "C++": "pink", + Java: "orange", + Kotlin: "purple", + Ruby: "red", + PHP: "purple", + Shell: "green", + Lua: "blue", + Zig: "orange", + Nix: "blue", + HTML: "red", + CSS: "purple", + Elixir: "purple", + Haskell: "purple", + Dart: "teal", + Vue: "green", +}; + +export const languageColor = (l?: string): Color => LANGUAGE_COLORS[l ?? ""] ?? "gray"; + +export function repoIcon(r: Repo): { source: string; tint: Color } { + if (r.archived) return { source: Icons.archive, tint: "gray" }; + if (r.private) return { source: Icons.lock, tint: "orange" }; + if (r.fork) return { source: Icons.branch, tint: languageColor(r.language) }; + return { source: Icons.repository, tint: languageColor(r.language) }; +} + +export function repoItem(r: Repo, section?: string): Item { + const flags = [ + ...(r.private ? [{ text: "Private", color: "orange" as Color }] : []), + ...(r.fork ? [{ text: "Fork", color: "blue" as Color }] : []), + ...(r.mirror ? [{ text: "Mirror", color: "teal" as Color }] : []), + ...(r.archived ? [{ text: "Archived", color: "gray" as Color }] : []), + ]; + return { + id: String(r.id), + title: r.full_name, + subtitle: r.description || undefined, + icon: repoIcon(r), + section, + keywords: [r.language ?? "", ...(r.topics ?? [])], + accessories: [ + ...(r.language ? [{ icon: { source: Icons.dot, tint: languageColor(r.language) }, text: r.language }] : []), + ...(r.stars_count ? [{ icon: Icons.star, text: compact(r.stars_count), tooltip: "Stars" }] : []), + { text: ago(r.updated_at), tooltip: "Updated" }, + ], + detail: { + stats: [ + { label: "stars", value: compact(r.stars_count) }, + { label: "forks", value: compact(r.forks_count) }, + { label: "issues", value: r.open_issues_count }, + ...(r.open_pr_counter !== undefined ? [{ label: "pull requests", value: r.open_pr_counter }] : []), + ], + markdown: r.description || undefined, + metadata: [ + meta.person("Owner", r.owner.full_name || r.owner.login, r.owner.avatar_url), + ...(r.language + ? [meta.text("Language", r.language, { icon: Icons.dot, color: languageColor(r.language) })] + : []), + meta.text("Default branch", r.default_branch, { icon: Icons.branch }), + ...(flags.length ? [meta.tags("Visibility", flags)] : []), + ...(r.topics?.length ? [meta.tags("Topics", r.topics)] : []), + ...(r.release_counter ? [meta.text("Releases", String(r.release_counter), { icon: Icons.tag })] : []), + ...(r.website ? [meta.link("Website", r.website.replace(/^https?:\/\//, ""), r.website)] : []), + meta.separator(), + meta.text("Updated", ago(r.updated_at), { icon: Icons.clock }), + ], + }, + actions: [ + open(r.html_url, "Open in Browser"), + open(`${r.html_url}/issues`, "Open Issues", { icon: Icons.issue, shortcut: "<Ctrl>i" }), + open(`${r.html_url}/pulls`, "Open Pull Requests", { icon: Icons.pullRequest, shortcut: "<Ctrl>p" }), + ...(r.release_counter ? [open(`${r.html_url}/releases`, "Open Releases", { icon: Icons.tag })] : []), + copy(`git clone ${r.ssh_url}`, "Copy git clone (SSH)", { + icon: Icons.terminal, + shortcut: "<Ctrl><Shift>c", + section: "Copy", + }), + copy(`git clone ${r.clone_url}`, "Copy git clone (HTTPS)", { icon: Icons.terminal, section: "Copy" }), + copy(r.html_url, "Copy URL", { section: "Copy" }), + copy(r.full_name, "Copy Name", { section: "Copy" }), + ], + }; +} + +// --- Notifications ---------------------------------------------------------------- + +export function notificationIcon(n: Notification): { source: string; tint: Color } { + const { type, state } = n.subject; + if (type === "Pull") { + if (state === "merged") return { source: Icons.merged, tint: "purple" }; + if (state === "closed") return { source: Icons.pullRequestClosed, tint: "red" }; + return { source: Icons.pullRequest, tint: "green" }; + } + if (type === "Issue") { + return state === "closed" ? { source: Icons.issueClosed, tint: "purple" } : { source: Icons.issue, tint: "green" }; + } + if (type === "Commit") return { source: Icons.commit, tint: "gray" }; + return { source: Icons.repository, tint: "gray" }; +} + +/** The web page of a notification: the latest comment, else the subject. */ +export function notificationUrl(n: Notification): string { + return n.subject.latest_comment_html_url || n.subject.html_url || `https://codeberg.org/${n.repository.full_name}`; +} + +const TYPE_NAMES: Record<string, string> = { + Pull: "Pull request", + Issue: "Issue", + Commit: "Commit", + Repository: "Repository", +}; + +export function notificationMeta(n: Notification): Metadata[] { + const icon = notificationIcon(n); + return [ + meta.text("Type", TYPE_NAMES[n.subject.type] ?? n.subject.type, { icon: icon.source, color: icon.tint }), + ...(n.subject.state ? [meta.text("State", n.subject.state[0].toUpperCase() + n.subject.state.slice(1))] : []), + meta.text("Repository", n.repository.full_name, { icon: Icons.repository }), + meta.text("Updated", ago(n.updated_at), { icon: Icons.clock }), + ...(n.pinned ? [meta.text("Pinned", "Yes", { icon: Icons.pin })] : []), + meta.text("Status", n.unread ? "Unread" : "Read", { + icon: n.unread ? Icons.dot : Icons.check, + color: n.unread ? "blue" : "gray", + }), + ]; +} + +/** `#12` from a subject URL, if any. */ +export function subjectNumber(n: Notification): string | undefined { + return /\/(?:issues|pulls)\/(\d+)/.exec(n.subject.html_url ?? "")?.[1]; +} diff --git a/extensions/codeberg/lib_test.ts b/extensions/codeberg/lib_test.ts new file mode 100644 index 0000000..d1f6b43 --- /dev/null +++ b/extensions/codeberg/lib_test.ts @@ -0,0 +1,280 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + checks, + colorOf, + compact, + groupByRepo, + type Issue, + issueItem, + issueMetadata, + type Notification, + notificationIcon, + notificationUrl, + type Pull, + type Repo, + repoIcon, + repoItem, + stateIcon, + subjectNumber, +} from "./lib.ts"; +import { AuthError, setupItems, token } from "./api.ts"; + +// Realistic Forgejo 13 responses (shapes from the codeberg.org API docs). +const user = { id: 42, login: "ada", full_name: "Ada Lovelace", avatar_url: "https://codeberg.org/avatars/1f2e" }; +const repo = { id: 7, name: "backslash", owner: "backslash-app", full_name: "backslash-app/backslash" }; +const issues = [ + { + id: 901, + number: 57, + title: "Detect terminals before pasting", + html_url: "https://codeberg.org/backslash-app/backslash/pulls/57", + state: "open", + body: "Fixes pasting into Kitty.\n\n<!-- template -->", + user, + labels: [{ id: 1, name: "bug", color: "ee0701" }], + milestone: { title: "1.2" }, + assignees: [user], + comments: 3, + created_at: "2026-10-26T08:00:00Z", + updated_at: "2026-10-28T09:00:00Z", + repository: repo, + pull_request: { + merged: false, + merged_at: null, + draft: false, + html_url: "https://codeberg.org/backslash-app/backslash/pulls/57", + }, + }, + { + id: 902, + number: 12, + title: "WIP: Forgejo Actions cache", + html_url: "https://codeberg.org/ada/notes/pulls/12", + state: "open", + body: "", + user, + labels: [], + milestone: null, + assignees: null, + comments: 0, + created_at: "2026-10-20T08:00:00Z", + updated_at: "2026-10-27T09:00:00Z", + repository: { id: 8, name: "notes", owner: "ada", full_name: "ada/notes" }, + pull_request: { merged: false, draft: true }, + }, +]; +const pull57 = { + id: 5001, + number: 57, + head: { ref: "fix/kitty-paste", sha: "3f2a9c1d" }, + base: { ref: "main" }, + mergeable: true, + merged: false, + draft: false, + additions: 86, + deletions: 12, + changed_files: 4, + requested_reviewers: [{ login: "grace" }], +}; +const status = { state: "failure", total_count: 2, statuses: [] }; +const repos = [ + { + id: 7, + name: "backslash", + full_name: "backslash-app/backslash", + owner: user, + description: "A launcher for Linux", + html_url: "https://codeberg.org/backslash-app/backslash", + clone_url: "https://codeberg.org/backslash-app/backslash.git", + ssh_url: "git@codeberg.org:backslash-app/backslash.git", + website: "https://backslash.dev", + stars_count: 1234, + forks_count: 56, + open_issues_count: 12, + open_pr_counter: 3, + release_counter: 5, + language: "Rust", + default_branch: "main", + private: false, + fork: false, + archived: false, + mirror: false, + updated_at: "2026-10-28T08:00:00Z", + topics: ["launcher", "gtk"], + }, + { + id: 8, + name: "notes", + full_name: "ada/notes", + owner: user, + description: "", + html_url: "https://codeberg.org/ada/notes", + clone_url: "https://codeberg.org/ada/notes.git", + ssh_url: "git@codeberg.org:ada/notes.git", + stars_count: 0, + forks_count: 0, + open_issues_count: 0, + language: "", + default_branch: "main", + private: true, + fork: false, + archived: false, + updated_at: "2026-10-20T08:00:00Z", + }, +]; +const notifications = [ + { + id: 300, + unread: true, + pinned: false, + updated_at: "2026-10-28T07:00:00Z", + repository: repo, + subject: { + title: "Detect terminals before pasting", + url: "https://codeberg.org/api/v1/repos/backslash-app/backslash/pulls/57", + html_url: "https://codeberg.org/backslash-app/backslash/pulls/57", + latest_comment_html_url: "https://codeberg.org/backslash-app/backslash/pulls/57#issuecomment-99", + type: "Pull", + state: "open", + }, + url: "https://codeberg.org/api/v1/notifications/threads/300", + }, + { + id: 301, + unread: false, + pinned: true, + updated_at: "2026-10-25T07:00:00Z", + repository: repo, + subject: { + title: "Crash on Wayland", + html_url: "https://codeberg.org/backslash-app/backslash/issues/40", + type: "Issue", + state: "closed", + }, + }, +]; + +const pr = issues[0] as unknown as Issue; +const draft = issues[1] as unknown as Issue; + +Deno.test("pull request state, checks and metadata", () => { + assertEquals(stateIcon(pr), { source: "bs-pr-open", tint: "green" }); + assertEquals(stateIcon(draft), { source: "bs-pr-draft", tint: "gray" }); + assertEquals(stateIcon({ ...pr, pull_request: { merged: true } }), { source: "bs-pr-merged", tint: "purple" }); + assertEquals(stateIcon({ ...pr, state: "closed" }), { source: "bs-pr-closed", tint: "red" }); + assertEquals(stateIcon({ ...pr, pull_request: null }), { source: "bs-issue-open", tint: "green" }); + const full: Pull = { ...pr, detail: pull57, ci: status as Pull["ci"] }; + assertEquals(checks(full), { icon: "bs-x", color: "red", text: "Checks failed" }); + assertEquals(checks({ ...full, ci: { state: "success", total_count: 1 } })?.text, "Checks passed"); + assertEquals(checks({ ...full, ci: { state: "pending", total_count: 0 } }), undefined); + const lines = issueMetadata(full).map((m) => [m.label, m.value ?? m.tags?.map((t) => t.text).join(",")]); + assertEquals(lines.slice(0, 8), [ + ["Status", "Open"], + ["Author", "Ada Lovelace"], + ["Branch", "fix/kitty-paste → main"], + ["Checks", "Checks failed"], + ["Reviewers", "grace"], + ["Assignees", "ada"], + ["Labels", "bug"], + ["Milestone", "1.2"], + ]); + const conflict = issueMetadata({ ...full, detail: { ...pull57, mergeable: false } }); + assertEquals(conflict.some((m) => m.value === "Conflicts with the base branch"), true); +}); + +Deno.test("pull request items", () => { + const item = issueItem({ ...pr, detail: pull57, ci: status as Pull["ci"] }, "backslash-app/backslash"); + assertEquals([item.title, item.subtitle, item.section], [ + "Detect terminals before pasting", + "#57", + "backslash-app/backslash", + ]); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text ?? a.tooltip), [ + "Checks failed", + "bug", + "+86 −12", + "3", + "ada", + item.accessories![5].text, + ]); + assertEquals(item.detail?.stats?.map((s) => s.value), [4, "+86", "−12"]); + assertEquals(item.detail?.markdown, "Fixes pasting into Kitty."); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), [ + "Open in Browser", + "Show Details", + "Open Files Changed", + ]); + const checkout = item.actions?.find((a) => a.title === "Copy Checkout Command") as { text: string }; + assertEquals( + checkout.text, + "git fetch https://codeberg.org/backslash-app/backslash.git pull/57/head:pr-57 && git switch pr-57", + ); + // Before the details load: no branch, no diff. + assertEquals(issueItem(pr).subtitle, "backslash #57"); + assertEquals(issueItem(pr).actions?.some((a) => a.title === "Copy Branch Name"), false); +}); + +Deno.test("grouped by repository, most recent first", () => { + const a = { ...pr, id: 1, repository: { ...pr.repository, full_name: "x/a" } }; + const b = { ...pr, id: 2, repository: { ...pr.repository, full_name: "x/b" } }; + const a2 = { ...a, id: 3 }; + assertEquals(groupByRepo([a, b, a2]).map((i) => i.id), [1, 3, 2]); +}); + +Deno.test("repositories", () => { + const [main, notes] = repos as unknown as Repo[]; + assertEquals(repoIcon(main), { source: "bs-repo", tint: "orange" }); + assertEquals(repoIcon(notes), { source: "bs-lock", tint: "orange" }); + assertEquals(repoIcon({ ...main, archived: true }).source, "bs-archive"); + const item = repoItem(main, "Your Repositories"); + assertEquals(item.detail?.stats?.map((s) => s.value), ["1.2k", "56", 12, 3]); + assertEquals(item.accessories?.[0].text, "Rust"); + assertEquals( + (item.actions?.find((a) => a.title === "Copy git clone (SSH)") as { text: string }).text, + "git clone git@codeberg.org:backslash-app/backslash.git", + ); + assertEquals(repoItem(notes).detail?.metadata?.find((m) => m.label === "Visibility")?.tags, [{ + text: "Private", + color: "orange", + }]); +}); + +Deno.test("notifications", () => { + const [unread, pinned] = notifications as unknown as Notification[]; + assertEquals(notificationIcon(unread), { source: "bs-pr-open", tint: "green" }); + assertEquals(notificationIcon(pinned), { source: "bs-issue-closed", tint: "purple" }); + assertEquals(notificationIcon({ ...unread, subject: { ...unread.subject, state: "merged" } }).source, "bs-pr-merged"); + assertEquals(notificationUrl(unread), "https://codeberg.org/backslash-app/backslash/pulls/57#issuecomment-99"); + assertEquals(notificationUrl(pinned), "https://codeberg.org/backslash-app/backslash/issues/40"); + assertEquals([subjectNumber(unread), subjectNumber(pinned)], ["57", "40"]); +}); + +Deno.test("token and setup row", () => { + assertEquals(token({ token: " abc " }), "abc"); + let error: unknown; + try { + token({}); + } catch (e) { + error = e; + } + assertEquals(error instanceof AuthError, true); + const [row] = setupItems(new AuthError("Add a Codeberg access token")); + assertEquals([row.id, row.actions?.[0].title], ["setup", "Create Access Token"]); +}); + +Deno.test("formatting", () => { + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals([ago("2026-10-28T09:00:00Z", now), ago("2026-10-27T09:00:00Z", now), ago("2026-10-24T09:00:00Z", now)], [ + "today", + "yesterday", + "4d", + ]); + assertEquals([compact(950), compact(1234), compact(45_600)], ["950", "1.2k", "46k"]); + assertEquals([colorOf("ee0701"), colorOf("#0e8a16"), colorOf("cccccc"), colorOf(undefined)], [ + "red", + "green", + "gray", + "gray", + ]); +}); diff --git a/extensions/cron/backslash.toml b/extensions/cron/backslash.toml new file mode 100644 index 0000000..52b5f85 --- /dev/null +++ b/extensions/cron/backslash.toml @@ -0,0 +1,31 @@ +[extension] +id = "cron" +title = "Cron Helper" +description = "Explain cron and systemd calendar expressions, see their next runs and convert between them" +version = "1.0.0" +author = "Backslash" +icon = "bs-clock" +license = "MIT" + +# No permission: everything is computed locally. + +[[commands]] +name = "cron" +title = "Cron Helper" +description = "Type a cron expression or an OnCalendar value: English, next 10 runs, systemd and cron equivalents" +icon = "bs-clock" +keywords = [ + "cron", + "crontab", + "schedule", + "systemd", + "timer", + "oncalendar", + "quartz", + "next run", + "expression", + "planifier", + "planification", + "tâche planifiée", + "minuteur", +] diff --git a/extensions/cron/commands/cron.ts b/extensions/cron/commands/cron.ts new file mode 100644 index 0000000..03848e4 --- /dev/null +++ b/extensions/cron/commands/cron.ts @@ -0,0 +1,250 @@ +import { type Action, copy, Icons, type Item, list, meta, type Metadata } from "@backslash/api"; +import { + explain, + fieldLines, + formatRun, + nextRuns, + parseAny, + parseCron, + PRESETS, + type Schedule, + timerUnit, + toCron, + toSystemd, + until, +} from "../lib.ts"; + +const attempt = <T>(fn: () => T): T | Error => { + try { + return fn(); + } catch (e) { + return e instanceof Error ? e : new Error(String(e)); + } +}; + +const runsMarkdown = (s: Schedule, now: Date, count = 5) => { + if (s.reboot) return "Runs once at boot. Cron daemons that support it (cronie, Vixie cron) start it when they start."; + const runs = nextRuns(s, now, count); + if (!runs.length) return "_Never runs again._"; + return ["**Next runs** (local time)", "", ...runs.map((d) => `- ${formatRun(d, s.hasSeconds)} · ${until(d, now)}`)] + .join("\n"); +}; + +function conversions(s: Schedule): { systemd: string[] | Error; cron: string | Error } { + return { systemd: attempt(() => toSystemd(s)), cron: attempt(() => toCron(s)) }; +} + +function scheduleMetadata(s: Schedule): Metadata[] { + const { systemd, cron } = conversions(s); + return [ + ...fieldLines(s).map(([label, value]) => meta.text(label, value)), + meta.separator(), + ...(s.kind === "systemd" && typeof cron === "string" ? [meta.text("cron", cron, { icon: Icons.terminal })] : []), + ...(s.kind === "cron" && Array.isArray(systemd) + ? systemd.map((line, i) => meta.text(i ? "" : "OnCalendar", line, { icon: i ? undefined : Icons.calendar })) + : []), + ...(s.timezone ? [meta.text("Time zone", `${s.timezone} (runs shown in local time)`, { icon: Icons.globe })] : []), + ]; +} + +function copyActions(s: Schedule, description: string): Action[] { + const { systemd, cron } = conversions(s); + return [ + copy(s.source, "Copy Expression"), + copy(description, "Copy Explanation", { icon: Icons.text, shortcut: "<Ctrl><Shift>e" }), + ...(s.kind === "cron" && Array.isArray(systemd) + ? [ + copy(systemd.join("\n"), "Copy as systemd OnCalendar", { icon: Icons.calendar, shortcut: "<Ctrl><Shift>s" }), + copy(timerUnit(systemd, description), "Copy systemd Timer Unit", { icon: Icons.file, section: "systemd" }), + ] + : []), + ...(s.kind === "systemd" && typeof cron === "string" + ? [copy(cron, "Copy as Cron Expression", { icon: Icons.terminal, shortcut: "<Ctrl><Shift>s" })] + : []), + ]; +} + +function presetItem(cron: string, title: string, now: Date): Item { + const s = parseCron(cron); + const next = s.reboot ? undefined : nextRuns(s, now, 1)[0]; + const description = explain(s); + return { + id: `preset:${cron}`, + title: cron, + subtitle: title, + icon: { source: s.reboot ? Icons.power : Icons.clock, tint: "blue" }, + section: "Presets", + keywords: [title, description], + accessories: next ? [{ text: until(next, now), tooltip: `Next run: ${formatRun(next)}` }] : [], + detail: { + markdown: `## ${description}\n\n${runsMarkdown(s, now)}`, + metadata: scheduleMetadata(s), + }, + actions: copyActions(s, description), + }; +} + +function scheduleItems(s: Schedule, now: Date): Item[] { + const description = explain(s); + const runs = s.reboot ? [] : nextRuns(s, now, 10); + const { systemd, cron } = conversions(s); + const kind = s.kind === "cron" + ? `Cron · ${s.macro ?? `${s.hasSeconds ? s.source.split(/\s+/).length : 5} fields`}` + : `systemd calendar event${s.macro ? ` · ${s.macro}` : ""}`; + const items: Item[] = [{ + id: "explain", + title: description, + subtitle: kind, + icon: { source: Icons.calendar, tint: "blue" }, + section: "Schedule", + accessories: runs[0] ? [{ text: until(runs[0], now), tooltip: "Next run" }] : [], + detail: { + stats: runs[0] + ? [{ label: "Next run", value: until(runs[0], now).replace(/^in /, "") }, { + label: "Runs per day", + value: perDay(runs), + }] + : [], + markdown: `## ${description}\n\n\`${s.source}\`\n\n${runsMarkdown(s, now)}`, + metadata: scheduleMetadata(s), + }, + actions: copyActions(s, description), + }]; + if (s.timezone) { + items.push({ + id: "tz", + title: `Times are computed in local time, not ${s.timezone}`, + icon: { source: Icons.info, tint: "orange" }, + section: "Schedule", + }); + } + runs.forEach((d, i) => { + const iso = toLocalIso(d); + items.push({ + id: `run:${i}`, + title: formatRun(d, s.hasSeconds), + subtitle: until(d, now), + icon: { source: Icons.clock, tint: i === 0 ? "green" : "gray" }, + section: "Next Runs", + accessories: i === 0 ? [{ tag: "Next", color: "green" }] : [], + actions: [copy(iso, "Copy Date and Time"), copy(s.source, "Copy Expression")], + }); + }); + if (s.kind === "cron") { + if (Array.isArray(systemd)) { + systemd.forEach((line, i) => { + items.push({ + id: `systemd:${i}`, + title: line, + subtitle: systemd.length > 1 ? `OnCalendar= (line ${i + 1} of ${systemd.length})` : "OnCalendar=", + icon: { source: Icons.calendar, tint: "purple" }, + section: "systemd Timer", + detail: { + markdown: [ + systemd.length > 1 + ? "Cron runs when the day of month **or** the day of week matches; systemd needs one `OnCalendar=` line for each." + : "Same schedule as a systemd timer.", + "", + "```ini", + timerUnit(systemd, description), + "```", + "", + "Check it with `systemd-analyze calendar`.", + ].join("\n"), + }, + actions: [ + copy(line, "Copy OnCalendar Value"), + copy(timerUnit(systemd, description), "Copy Timer Unit", { icon: Icons.file }), + copy(`systemd-analyze calendar --iterations=5 '${line}'`, "Copy systemd-analyze Command", { + icon: Icons.terminal, + }), + ], + }); + }); + } else items.push(unsupported(systemd, "systemd Timer")); + } else if (typeof cron === "string") { + items.push({ + id: "cron", + title: cron, + subtitle: s.hasSeconds && cron.split(" ").length === 6 ? "Cron with seconds (Quartz, Spring, node-cron)" : "Cron", + icon: { source: Icons.terminal, tint: "purple" }, + section: "Cron", + detail: { markdown: `\`\`\`\n# crontab -e\n${cron} /path/to/command\n\`\`\`` }, + actions: [copy(cron, "Copy Cron Expression"), copy(`${cron} `, "Copy as crontab Line Start")], + }); + } else items.push(unsupported(cron, "Cron")); + return items; +} + +const unsupported = (e: Error, section: string): Item => ({ + id: `unsupported:${section}`, + title: e.message, + subtitle: "No exact equivalent", + icon: { source: Icons.alert, tint: "orange" }, + section, +}); + +function perDay(runs: Date[]): string { + if (runs.length < 2) return "–"; + const span = (runs[runs.length - 1].getTime() - runs[0].getTime()) / (runs.length - 1); + const n = 86_400_000 / span; + return n >= 1 ? String(Math.round(n)) : `1 per ${Math.round(1 / n)} days`; +} + +const pad = (n: number) => String(n).padStart(2, "0"); +const toLocalIso = (d: Date) => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${ + pad(d.getSeconds()) + }`; + +export default list({ + placeholder: "Type a cron expression (*/5 * * * *) or a systemd calendar (Mon..Fri 09:00)…", + empty: { title: "Nothing to show", icon: Icons.clock }, + status: (items) => { + const first = items[0]; + if (!first) return ""; + if (first.id === "error") return "Invalid expression"; + if (first.id?.startsWith("preset:")) return `${items.length} presets · times in local time`; + return String(first.subtitle ?? ""); + }, + search(query) { + const now = new Date(); + const text = query.trim(); + const presets = (filter: string) => + PRESETS.filter((p) => !filter || `${p.cron} ${p.title}`.toLowerCase().includes(filter.toLowerCase())) + .map((p) => presetItem(p.cron, p.title, now)); + if (!text) return presets(""); + const s = attempt(() => parseAny(text)); + if (!(s instanceof Error)) return scheduleItems(s, now); + // Words that aren't an expression search the presets ("weekday", "month"). + const matching = presets(text); + if (/^[a-z ]+$/i.test(text) && matching.length) return matching; + return [{ + id: "error", + title: s.message, + subtitle: "Not a valid expression", + icon: { source: Icons.alert, tint: "red" }, + section: "Expression", + detail: { + markdown: [ + "## Cron", + "", + "`minute hour day-of-month month day-of-week`, or 6 fields with seconds first.", + "", + "- `*` any · `5` a value · `1-5` a range · `*/15` a step · `1,15` a list", + "- Names: `JAN`–`DEC`, `SUN`–`SAT`", + "- `L` last day of the month · `5L` last Friday · `1#2` second Monday", + "- `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`, `@reboot`", + "", + "## systemd", + "", + "`[weekday] [year-month-day] [hour:minute[:second]]`", + "", + "- `Mon..Fri 09:00` · `*-*-01 00:00` · `*:0/15` · `*-*~01` (last day)", + "- `daily`, `weekly`, `monthly`, `quarterly`, `yearly`", + ].join("\n"), + }, + actions: [copy(text, "Copy Input")], + }, ...presets("")]; + }, +}); diff --git a/extensions/cron/lib.ts b/extensions/cron/lib.ts new file mode 100644 index 0000000..e489bca --- /dev/null +++ b/extensions/cron/lib.ts @@ -0,0 +1,751 @@ +// Cron and systemd calendar expressions: parsing, English, next runs, conversion. Pure, tested in lib_test.ts. + +export interface Part { + kind: "any" | "value" | "range" | "step"; + from: number; + to: number; + step: number; +} + +export interface Field { + parts: Part[]; + values: number[]; + /** `*` (or `?`): no restriction. */ + star: boolean; +} + +export interface Schedule { + kind: "cron" | "systemd"; + /** The expression as typed. */ + source: string; + /** `@daily`, `weekly`… when the expression was a shorthand. */ + macro?: string; + /** `@reboot`: no calendar time at all. */ + reboot?: boolean; + hasSeconds: boolean; + second: Field; + minute: Field; + hour: Field; + day: Field; + month: Field; + /** 0 = Sunday … 6 = Saturday. */ + weekday: Field; + year?: Field; + /** Days counted from the end of the month: 0 = the last day (cron `L`, systemd `~01`). */ + lastDays: number[]; + /** Cron `5#3`: the third Friday. */ + nth: { weekday: number; n: number }[]; + /** Cron `5L`: the last Friday. */ + lastWeekday: number[]; + /** systemd: a time zone after the time (runs are still shown in local time). */ + timezone?: string; +} + +interface Range { + name: string; + min: number; + max: number; + names?: string[]; +} + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; +const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; +export const MONTH_NAMES = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +export const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +const R = { + second: { name: "second", min: 0, max: 59 }, + minute: { name: "minute", min: 0, max: 59 }, + hour: { name: "hour", min: 0, max: 23 }, + day: { name: "day of month", min: 1, max: 31 }, + month: { name: "month", min: 1, max: 12, names: MONTHS }, + weekday: { name: "day of week", min: 0, max: 7, names: DAYS }, + year: { name: "year", min: 1970, max: 2199 }, +} satisfies Record<string, Range>; + +export const MACROS: Record<string, string> = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", +}; + +const any = (r: Range): Field => ({ + parts: [{ kind: "any", from: r.min, to: r.max, step: 1 }], + values: span(r.min, r.max, 1), + star: true, +}); +const single = (n: number): Field => ({ + parts: [{ kind: "value", from: n, to: n, step: 1 }], + values: [n], + star: false, +}); + +function span(from: number, to: number, step: number): number[] { + const out: number[] = []; + for (let n = from; n <= to; n += step) out.push(n); + return out; +} + +function number(text: string, r: Range): number { + const lower = text.toLowerCase(); + const named = r.names?.indexOf(lower.slice(0, 3)) ?? -1; + if (named >= 0 && /^[a-z]+$/.test(lower)) return named + (r.min === 1 ? 1 : 0); + if (!/^\d+$/.test(text)) throw new Error(`“${text}” is not a valid ${r.name}`); + const n = Number(text); + if (n < r.min || n > r.max) { + throw new Error(`${r.name[0].toUpperCase()}${r.name.slice(1)} ${n} is out of range (${r.min}–${r.max})`); + } + return n; +} + +function finish(parts: Part[], r: Range, star: boolean): Field { + const set = new Set<number>(); + for (const p of parts) for (const n of span(p.from, p.to, p.step)) set.add(r === R.weekday && n === 7 ? 0 : n); + return { parts, values: [...set].sort((a, b) => a - b), star }; +} + +/** One cron field: `*`, `5`, `1-5`, `*\/15`, `10-50/10`, `MON-FRI`, lists of those. */ +export function parseCronField(text: string, r: Range): Field { + if (text === "*" || text === "?") return any(r); + const parts = text.split(",").map((piece): Part => { + if (!piece) throw new Error(`Empty value in the ${r.name} field`); + const [base, stepText, extra] = piece.split("/"); + if (extra !== undefined) throw new Error(`“${piece}” has two steps`); + let step = 1; + if (stepText !== undefined) { + if (!/^\d+$/.test(stepText) || Number(stepText) === 0) throw new Error(`“/${stepText}” is not a valid step`); + step = Number(stepText); + } + let from: number, to: number; + if (base === "*" || base === "?") [from, to] = [r.min, r === R.weekday ? 6 : r.max]; + else if (base.includes("-")) { + const [a, b] = base.split("-"); + [from, to] = [number(a, r), number(b, r)]; + if (from > to) { + if (r === R.weekday && to === 0) to = 7; // FRI-SUN + else throw new Error(`Range ${base} goes backwards`); + } + } else { + from = number(base, r); + // `5/15` (Quartz, Vixie): from 5 to the end. + to = stepText === undefined ? from : r === R.weekday ? 6 : r.max; + } + const kind = stepText !== undefined ? "step" : base === "*" ? "any" : from === to ? "value" : "range"; + return { kind, from, to, step }; + }); + return finish(parts, r, false); +} + +const blank = (): Omit<Schedule, "kind" | "source" | "hasSeconds"> => ({ + second: single(0), + minute: any(R.minute), + hour: any(R.hour), + day: any(R.day), + month: any(R.month), + weekday: any(R.weekday), + lastDays: [], + nth: [], + lastWeekday: [], +}); + +/** A cron expression: 5 fields, 6 with seconds first, 7 with a year last, or a `@macro`. */ +export function parseCron(input: string): Schedule { + const source = input.trim(); + if (!source) throw new Error("Type a cron expression, like */5 * * * *"); + const lower = source.toLowerCase(); + if (lower === "@reboot") { + return { ...blank(), kind: "cron", source, macro: "@reboot", reboot: true, hasSeconds: false }; + } + if (lower.startsWith("@")) { + const expanded = MACROS[lower]; + if (!expanded) throw new Error(`Unknown shorthand ${source}: use @yearly, @monthly, @weekly, @daily or @hourly`); + return { ...parseCron(expanded), source, macro: lower }; + } + const fields = source.split(/\s+/); + if (fields.length < 5) throw new Error(`A cron expression has 5 fields, this one has ${fields.length}`); + if (fields.length > 7) throw new Error(`A cron expression has 5 to 7 fields, this one has ${fields.length}`); + const hasSeconds = fields.length >= 6; + const [sec, min, hour, dom, mon, dow, year] = hasSeconds ? fields : ["0", ...fields]; + const s: Schedule = { + ...blank(), + kind: "cron", + source, + hasSeconds, + second: parseCronField(sec, R.second), + minute: parseCronField(min, R.minute), + hour: parseCronField(hour, R.hour), + month: parseCronField(mon, R.month), + }; + // Day of month: `L` (last day), `L-3` (three days before the end). + const lastDom = dom.toUpperCase().match(/^L(?:-(\d+))?$/); + if (lastDom) { + s.day = { parts: [], values: [], star: false }; + s.lastDays = [Number(lastDom[1] ?? 0)]; + } else s.day = parseCronField(dom, R.day); + // Day of week: `5#3` (third Friday), `5L` / `FRIL` (last Friday). + const nth = dow.match(/^(\w+)#([1-5])$/); + const lastDow = dow.match(/^(\w+?)L$/i); + if (nth) { + s.weekday = { parts: [], values: [], star: false }; + s.nth = [{ weekday: number(nth[1], R.weekday) % 7, n: Number(nth[2]) }]; + } else if (lastDow && dow.toUpperCase() !== "L") { + s.weekday = { parts: [], values: [], star: false }; + s.lastWeekday = [number(lastDow[1], R.weekday) % 7]; + } else s.weekday = parseCronField(dow, R.weekday); + if (year !== undefined && year !== "*") s.year = parseCronField(year, R.year); + return s; +} + +// --------------------------------------------------------------------------- +// systemd calendar events (OnCalendar=) + +export const SYSTEMD_SHORTHANDS: Record<string, string> = { + minutely: "*-*-* *:*:00", + hourly: "*-*-* *:00:00", + daily: "*-*-* 00:00:00", + monthly: "*-*-01 00:00:00", + weekly: "Mon *-*-* 00:00:00", + yearly: "*-01-01 00:00:00", + annually: "*-01-01 00:00:00", + quarterly: "*-01,04,07,10-01 00:00:00", + semiannually: "*-01,07-01 00:00:00", +}; + +const SYSTEMD_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function weekdayName(text: string): number { + const i = DAYS.indexOf(text.toLowerCase().slice(0, 3)); + if (i < 0 || !/^[a-z]+$/i.test(text)) throw new Error(`“${text}” is not a day of the week`); + return i; +} + +/** `Mon..Fri,Sun` → weekdays. */ +function systemdWeekdays(text: string): Field { + const parts = text.split(",").map((piece): Part => { + const [a, b] = piece.split(".."); + const from = weekdayName(a); + if (b === undefined) return { kind: "value", from, to: from, step: 1 }; + let to = weekdayName(b); + if (to < from) to += 7; // Sat..Mon + return { kind: "range", from, to, step: 1 }; + }); + const values = [...new Set(parts.flatMap((p) => span(p.from, p.to, 1).map((n) => n % 7)))].sort((a, b) => a - b); + return { parts, values, star: false }; +} + +/** One component of a systemd date or time: `*`, `5`, `1..5`, `0/15`, `1..20/5`, lists. */ +export function parseSystemdComponent(text: string, r: Range): Field { + if (text === "*") return any(r); + const parts = text.split(",").map((piece): Part => { + const [base, stepText] = piece.split("/"); + const step = stepText === undefined ? 1 : Number(stepText); + if (!Number.isInteger(step) || step < 1) throw new Error(`“/${stepText}” is not a valid repetition`); + let from: number, to: number; + if (base === "*") [from, to] = [r.min, r.max]; + else if (base.includes("..")) { + const [a, b] = base.split(".."); + [from, to] = [number(a, r), number(b, r)]; + if (from > to) throw new Error(`Range ${base} goes backwards`); + } else { + from = number(base, r); + to = stepText === undefined ? from : r.max; + } + const kind = stepText !== undefined ? "step" : base === "*" ? "any" : from === to ? "value" : "range"; + return { kind, from, to, step }; + }); + return finish(parts, r, false); +} + +/** `~07/1` → the 7th last day and every day after it: offsets 6…0 from the end. */ +function lastDays(text: string): number[] { + const r = { name: "day from the end", min: 1, max: 31 }; + const out = new Set<number>(); + for (const piece of text.split(",")) { + const [base, stepText] = piece.split("/"); + const step = stepText === undefined ? 1 : Number(stepText); + if (!Number.isInteger(step) || step < 1) throw new Error(`“/${stepText}” is not a valid repetition`); + const [a, b] = base.split(".."); + const from = number(a, r); + // Towards the end of the month: ~07/2 is the 7th, 5th, 3rd and last day. + const to = b !== undefined ? number(b, r) : stepText !== undefined ? 1 : from; + const [hi, lo] = from >= to ? [from, to] : [to, from]; + for (let n = hi; n >= lo; n -= step) out.add(n - 1); + } + return [...out].sort((x, y) => x - y); +} + +const looksLikeZone = (t: string) => /^(UTC|GMT|[A-Z][A-Za-z_]+\/[A-Za-z_/+-]+|[A-Z]{3,4})$/.test(t); + +/** A systemd `OnCalendar=` value: `Mon..Fri *-*-* 09:00:00`, `daily`, `*-*~01 12:00`… */ +export function parseSystemd(input: string): Schedule { + const source = input.trim(); + if (!source) throw new Error("Type a calendar event, like Mon..Fri 09:00"); + const tokens = source.split(/\s+/); + let timezone: string | undefined; + if ( + tokens.length > 1 && looksLikeZone(tokens[tokens.length - 1]) && + !/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(tokens[tokens.length - 1]) + ) { + timezone = tokens.pop(); + } + const shorthand = SYSTEMD_SHORTHANDS[tokens.join(" ").toLowerCase()]; + if (shorthand) return { ...parseSystemd(shorthand), source, macro: tokens.join(" ").toLowerCase(), timezone }; + const s: Schedule = { ...blank(), kind: "systemd", source, hasSeconds: true, timezone }; + let i = 0; + if (/^[a-z]/i.test(tokens[0])) s.weekday = systemdWeekdays(tokens[i++]); + let date: string | undefined, time: string | undefined; + for (const t of tokens.slice(i)) { + if (t.includes(":")) { + if (time) throw new Error(`Two times in “${source}”`); + time = t; + } else if (/[-~]/.test(t)) { + if (date || time) throw new Error(`Unexpected “${t}”: the date comes before the time`); + date = t; + } else throw new Error(`“${t}” is neither a date (*-*-01) nor a time (09:00)`); + } + if (date) { + const m = date.match(/^(?:([^-~]+)-)?([^-~]+)([-~])([^-~]+)$/); + if (!m) throw new Error(`“${date}” is not a date like *-*-01 or 2026-10-28`); + const [, y, mon, sep, d] = m; + if (y !== undefined && y !== "*") s.year = parseSystemdComponent(y, R.year); + s.month = parseSystemdComponent(mon, R.month); + if (sep === "~") { + // `~01` is the last day, `~07/1` the last seven days. + s.day = { parts: [], values: [], star: false }; + s.lastDays = lastDays(d); + } else s.day = parseSystemdComponent(d, R.day); + } + if (time) { + const pieces = time.split(":"); + if (pieces.length < 2 || pieces.length > 3) throw new Error(`“${time}” is not a time like 09:00 or 09:00:30`); + s.hour = parseSystemdComponent(pieces[0], R.hour); + s.minute = parseSystemdComponent(pieces[1], R.minute); + // Fractions of seconds can't be scheduled more finely than seconds here. + s.second = pieces[2] === undefined ? single(0) : parseSystemdComponent(pieces[2].replace(/\.\d+$/, ""), R.second); + } else { + s.hour = single(0); + s.minute = single(0); + s.second = single(0); + } + return s; +} + +/** Cron when it looks like cron, else systemd. */ +export function parseAny(input: string): Schedule { + const text = input.trim(); + const cronLike = text.startsWith("@") || /^[\d*?,\/\-a-z#LW]+(\s+[\d*?,\/\-a-z#LW]+){4,6}$/i.test(text); + if (cronLike && !/\.\.|:|~/.test(text)) { + try { + return parseCron(text); + } catch (e) { + if (text.startsWith("@") || !/[a-z]{4,}/i.test(text)) throw e; + } + } + try { + return parseSystemd(text); + } catch (e) { + // Only cron characters and no date or time: the cron error is the useful one. + if (/^[\d*?,\/\s]+$/.test(text) || cronLike) return parseCron(text); + throw e; + } +} + +// --------------------------------------------------------------------------- +// Next runs + +const daysIn = (year: number, month0: number) => new Date(year, month0 + 1, 0).getDate(); + +export function dayMatches(s: Schedule, date: Date): boolean { + const d = date.getDate(), wd = date.getDay(); + const dim = daysIn(date.getFullYear(), date.getMonth()); + const domRestricted = !s.day.star || s.lastDays.length > 0; + const dowRestricted = !s.weekday.star || s.nth.length > 0 || s.lastWeekday.length > 0; + const dom = s.day.values.includes(d) || s.lastDays.includes(dim - d); + const dow = s.weekday.values.includes(wd) || + s.nth.some((x) => x.weekday === wd && Math.ceil(d / 7) === x.n) || + s.lastWeekday.some((w) => w === wd && d + 7 > dim); + if (domRestricted && dowRestricted) return s.kind === "cron" ? dom || dow : dom && dow; + if (domRestricted) return dom; + if (dowRestricted) return dow; + return true; +} + +/** The first time strictly after `after` matching the schedule, in local time. */ +export function nextRun(s: Schedule, after: Date): Date | undefined { + if (s.reboot) return undefined; + let t = new Date(after.getTime()); + t.setMilliseconds(0); + t = new Date(t.getTime() + 1000); + const limit = after.getFullYear() + 30; + for (let i = 0; i < 200_000; i++) { + const y = t.getFullYear(), mo = t.getMonth(), d = t.getDate(), h = t.getHours(), mi = t.getMinutes(); + const prev = t.getTime(); + if (y > limit || (s.year && y > Math.max(...s.year.values))) return undefined; + if (s.year && !s.year.values.includes(y)) t = new Date(y + 1, 0, 1); + else if (!s.month.values.includes(mo + 1)) t = new Date(y, mo + 1, 1); + else if (!dayMatches(s, t)) t = new Date(y, mo, d + 1); + else if (!s.hour.values.includes(h)) t = new Date(y, mo, d, h + 1); + else if (!s.minute.values.includes(mi)) t = new Date(y, mo, d, h, mi + 1); + else if (!s.second.values.includes(t.getSeconds())) t = new Date(y, mo, d, h, mi, t.getSeconds() + 1); + else return t; + // A repeated hour when clocks go back: never move backwards. + if (t.getTime() <= prev) t = new Date(prev + 1000); + } + return undefined; +} + +export function nextRuns(s: Schedule, from: Date, count = 10): Date[] { + const out: Date[] = []; + let t = from; + while (out.length < count) { + const n = nextRun(s, t); + if (!n) break; + out.push(n); + t = n; + } + return out; +} + +// --------------------------------------------------------------------------- +// English + +const pad = (n: number) => String(n).padStart(2, "0"); +const clock = (h: number, m: number, sec?: number) => `${pad(h)}:${pad(m)}${sec ? `:${pad(sec)}` : ""}`; + +export function joinWords(words: string[], last = "and"): string { + if (words.length <= 1) return words[0] ?? ""; + return `${words.slice(0, -1).join(", ")} ${last} ${words[words.length - 1]}`; +} + +export function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th"; + return `${n}${s}`; +} + +const isValues = (f: Field) => !f.star && f.parts.every((p) => p.kind === "value"); +const isSingle = (f: Field) => isValues(f) && f.values.length === 1; +const onlyStep = (f: Field, r: Range) => + f.parts.length === 1 && f.parts[0].kind === "step" && f.parts[0].from === r.min && + f.parts[0].to >= (r === R.weekday ? 6 : r.max); + +/** `1-5` → "1 through 5"; lists joined. `name` turns numbers into words. */ +function describeParts(f: Field, name: (n: number) => string, unit: string): string { + return joinWords(f.parts.map((p) => { + if (p.kind === "value") return name(p.from); + if (p.kind === "range") return `${name(p.from)} through ${name(p.to)}`; + if (p.kind === "step") { + const every = `every ${ordinal(p.step)} ${unit}`; + return `${every} from ${name(p.from)} through ${name(p.to)}`; + } + return `every ${unit}`; + })); +} + +function describeTime(s: Schedule): string { + const { second: sec, minute: min, hour } = s; + const secs = s.hasSeconds && !(isSingle(sec) && sec.values[0] === 0); + // At 09:30, at 09:00 and 17:00. + if (isValues(min) && isValues(hour) && (!secs || isSingle(sec)) && min.values.length * hour.values.length <= 6) { + const times = hour.values.flatMap((h) => min.values.map((m) => clock(h, m, secs ? sec.values[0] : undefined))); + return `At ${joinWords(times)}`; + } + let secPart = ""; + if (secs) { + if (sec.star) secPart = "Every second"; + else if (onlyStep(sec, R.second)) secPart = `Every ${sec.parts[0].step} seconds`; + else secPart = `At second ${describeParts(sec, String, "second")}`; + } + let minPart: string; + if (min.star) minPart = secs ? "" : "every minute"; + else if (onlyStep(min, R.minute)) minPart = `every ${min.parts[0].step} minutes`; + else if (isValues(min)) minPart = `at minute${min.values.length > 1 ? "s" : ""} ${joinWords(min.values.map(String))}`; + else minPart = `at minutes ${describeParts(min, String, "minute")}`; + let hourPart: string; + if (hour.star) hourPart = min.star || onlyStep(min, R.minute) ? "" : "past every hour"; + else if (hour.parts.length === 1 && hour.parts[0].kind === "step") { + const p = hour.parts[0]; + const window = onlyStep(hour, R.hour) ? "" : ` from ${clock(p.from, 0)} through ${clock(p.to, 59)}`; + hourPart = min.star || onlyStep(min, R.minute) + ? `every ${p.step} hours${window}` + : `past every ${ordinal(p.step)} hour${window}`; + } else if (hour.parts.length === 1 && hour.parts[0].kind === "range") { + hourPart = `between ${clock(hour.parts[0].from, 0)} and ${clock(hour.parts[0].to, 59)}`; + } else if (isSingle(hour)) hourPart = `between ${clock(hour.values[0], 0)} and ${clock(hour.values[0], 59)}`; + else if (isValues(hour)) hourPart = `during hours ${joinWords(hour.values.map(pad))}`; + else hourPart = `during hours ${describeParts(hour, pad, "hour")}`; + const words = [secPart, minPart, hourPart].filter(Boolean).join(minPart && secPart ? ", " : " "); + return words[0].toUpperCase() + words.slice(1); +} + +function describeDays(s: Schedule): string { + const dom: string[] = []; + if (s.lastDays.length) { + const sorted = [...s.lastDays].sort((a, b) => a - b); + if (sorted.length === 1 && sorted[0] === 0) dom.push("on the last day of the month"); + else if (sorted.length === 1) { + dom.push(`${sorted[0]} day${sorted[0] > 1 ? "s" : ""} before the last day of the month`); + } else if (sorted.every((n, i) => n === i)) dom.push(`on the last ${sorted.length} days of the month`); + else dom.push(`on days ${joinWords(sorted.map((n) => n === 0 ? "last" : `last − ${n}`))} of the month`); + } + if (!s.day.star && s.day.parts.length) { + const f = s.day; + if (onlyStep(f, R.day)) dom.push(`every ${f.parts[0].step} days`); + else if (isValues(f)) { + dom.push(`on day${f.values.length > 1 ? "s" : ""} ${joinWords(f.values.map(String))} of the month`); + } else dom.push(`on days ${describeParts(f, String, "day")} of the month`); + } + const dow: string[] = []; + for (const x of s.nth) { + dow.push(`on the ${["", "first", "second", "third", "fourth", "fifth"][x.n]} ${DAY_NAMES[x.weekday]} of the month`); + } + for (const w of s.lastWeekday) dow.push(`on the last ${DAY_NAMES[w]} of the month`); + if (!s.weekday.star && s.weekday.parts.length) { + const f = s.weekday; + const name = (n: number) => DAY_NAMES[n % 7]; + if (f.values.join() === "1,2,3,4,5") dow.push("on weekdays"); + else if (f.values.join() === "0,6") dow.push("on weekends"); + else if (isValues(f)) dow.push(`on ${joinWords(f.values.map(name))}`); + else dow.push(`on ${describeParts(f, name, "day of the week")}`); + } + if (dom.length && dow.length) { + if (s.kind === "cron") return `${joinWords(dom)}, or ${joinWords(dow, "or")}`; + // systemd: both must match, e.g. the last seven days that are a Friday. + if (s.lastDays.length === 7 && s.lastDays.every((n) => n < 7) && isSingle(s.weekday)) { + return `on the last ${DAY_NAMES[s.weekday.values[0]]} of the month`; + } + const nth = ["01..07", "08..14", "15..21", "22..28"].indexOf(systemdComponent(s.day, R.day)); + if (nth >= 0 && !s.lastDays.length && isSingle(s.weekday)) { + return `on the ${["first", "second", "third", "fourth"][nth]} ${DAY_NAMES[s.weekday.values[0]]} of the month`; + } + return `${joinWords(dom)}, if it is ${joinWords(dow.map((d) => d.replace(/^on /, "a ")), "or")}`; + } + return joinWords([...dom, ...dow], dom.length ? "and" : "or"); +} + +function describeMonths(s: Schedule): string { + const f = s.month; + if (f.star) return ""; + if (onlyStep(f, R.month)) return `every ${f.parts[0].step} months`; + return `in ${describeParts(f, (n) => MONTH_NAMES[n - 1], "month")}`; +} + +/** "At 09:00, on weekdays". */ +export function explain(s: Schedule): string { + if (s.reboot) return "Once, when the system starts"; + const parts = [describeTime(s), describeDays(s), describeMonths(s)]; + if (s.year) parts.push(`in ${describeParts(s.year, String, "year")}`); + return parts.filter(Boolean).join(", "); +} + +/** One line per field, for the side panel. */ +export function fieldLines(s: Schedule): [string, string][] { + const text = (f: Field, r: Range, name: (n: number) => string = String) => + f.star ? "every" : f.parts.length ? describeParts(f, name, r.name) : ""; + const lines: [string, string][] = []; + if (s.hasSeconds) lines.push(["Second", text(s.second, R.second)]); + lines.push(["Minute", text(s.minute, R.minute)], ["Hour", text(s.hour, R.hour)]); + const days = describeDays({ ...s, weekday: any(R.weekday), nth: [], lastWeekday: [] }); + lines.push(["Day of month", days ? days.replace(/^on /, "") : "every"]); + lines.push(["Month", text(s.month, R.month, (n) => MONTH_NAMES[n - 1])]); + const dows = describeDays({ ...s, day: any(R.day), lastDays: [] }); + lines.push(["Day of week", dows ? dows.replace(/^on /, "") : "every"]); + if (s.year) lines.push(["Year", text(s.year, R.year)]); + return lines; +} + +// --------------------------------------------------------------------------- +// Conversion + +function systemdComponent(f: Field, r: Range): string { + if (f.star) return "*"; + return f.parts.map((p) => { + const v = (n: number) => r === R.year ? String(n) : pad(n); + if (p.kind === "value") return v(p.from); + if (p.kind === "range") return `${v(p.from)}..${v(p.to)}`; + if (p.kind === "any") return "*"; + return p.to >= r.max ? `${v(p.from)}/${p.step}` : `${v(p.from)}..${v(p.to)}/${p.step}`; + }).join(","); +} + +function systemdWeekdayList(values: number[]): string { + // Monday first; runs of 3+ days become ranges. + const order = [1, 2, 3, 4, 5, 6, 0].filter((d) => values.includes(d)); + const out: string[] = []; + let i = 0; + while (i < order.length) { + let j = i; + while (j + 1 < order.length && (order[j + 1] - order[j] + 7) % 7 === 1) j++; + if (j - i >= 2) out.push(`${SYSTEMD_DAYS[order[i]]}..${SYSTEMD_DAYS[order[j]]}`); + else for (let k = i; k <= j; k++) out.push(SYSTEMD_DAYS[order[k]]); + i = j + 1; + } + return out.join(","); +} + +/** Offsets from the end → `~01`, `~07/1` (the last seven days) or `~01,03`. */ +function lastDaysComponent(offsets: number[]): string { + const sorted = [...offsets].sort((a, b) => a - b); + if (sorted.length > 1 && sorted.every((n, i) => n === i)) return `~${pad(sorted.length)}/1`; + return `~${sorted.map((n) => pad(n + 1)).join(",")}`; +} + +const NTH_DAYS = ["", "01..07", "08..14", "15..21", "22..28", "29..31"]; + +/** The `OnCalendar=` lines matching a cron schedule (several when cron's day-of-month OR day-of-week can't be one line). */ +export function toSystemd(s: Schedule): string[] { + if (s.reboot) throw new Error("@reboot has no calendar equivalent: use OnBootSec= in the timer instead"); + const time = `${systemdComponent(s.hour, R.hour)}:${systemdComponent(s.minute, R.minute)}:${ + systemdComponent(s.second, R.second) + }`; + const year = s.year ? systemdComponent(s.year, R.year) : "*"; + const month = systemdComponent(s.month, R.month); + const date = (day: string) => `${year}-${month}${day.startsWith("~") ? day : `-${day}`}`; + const lines: string[] = []; + const domRestricted = !s.day.star || s.lastDays.length > 0; + const plainDow = !s.weekday.star && s.weekday.values.length > 0; + const dowRestricted = plainDow || s.nth.length > 0 || s.lastWeekday.length > 0; + const dayFields: string[] = []; + if (!s.day.star && s.day.parts.length) dayFields.push(systemdComponent(s.day, R.day)); + if (s.lastDays.length) dayFields.push(lastDaysComponent(s.lastDays)); + if (s.kind === "systemd" || !(domRestricted && dowRestricted)) { + const dow = plainDow ? `${systemdWeekdayList(s.weekday.values)} ` : ""; + if (!dowRestricted || plainDow) { + if (!dayFields.length) lines.push(`${dow}${date("*")} ${time}`); + for (const d of dayFields) lines.push(`${dow}${date(d)} ${time}`); + } + } else { + for (const d of dayFields) lines.push(`${date(d)} ${time}`); + if (plainDow) lines.push(`${systemdWeekdayList(s.weekday.values)} ${date("*")} ${time}`); + } + for (const x of s.nth) lines.push(`${SYSTEMD_DAYS[x.weekday]} ${date(NTH_DAYS[x.n])} ${time}`); + for (const w of s.lastWeekday) lines.push(`${SYSTEMD_DAYS[w]} ${year}-${month}~07/1 ${time}`); + // `*-*-* 00:00:00` reads better as the shorthand systemd prints. + return lines.map((l) => l === "*-*-* 00:00:00" ? "daily" : l === "*-*-* *:00:00" ? "hourly" : l); +} + +function cronComponent(f: Field, r: Range): string { + if (f.star) return "*"; + const max = r === R.weekday ? 6 : r.max; + return f.parts.map((p) => { + if (p.kind === "value") return String(p.from); + if (p.kind === "range") return `${p.from}-${p.to}`; + if (p.kind === "any") return "*"; + if (p.from === r.min && p.to >= max) return `*/${p.step}`; + return `${p.from}-${Math.min(p.to, max)}/${p.step}`; + }).join(","); +} + +/** The cron line for a systemd schedule; throws when cron can't say it. */ +export function toCron(s: Schedule): string { + if (s.kind === "cron") return s.source; + if (s.year) throw new Error("Cron has no year field: this calendar event names specific years"); + const sec = cronComponent(s.second, R.second); + let dom = s.day.star ? "*" : cronComponent(s.day, R.day); + let dow = s.weekday.star ? "*" : s.weekday.values.join(","); + const range = s.weekday.parts.length === 1 && s.weekday.parts[0].kind === "range" ? s.weekday.parts[0] : undefined; + if (range && range.to <= 7) dow = `${range.from}-${range.to}`; + const last = [...s.lastDays].sort((a, b) => a - b); + if (last.length) { + if (!s.day.star && s.day.parts.length) throw new Error("Cron can't combine days from the end with other days"); + if (last.length === 7 && last[6] === 6 && isSingle(s.weekday)) { + [dom, dow] = ["*", `${s.weekday.values[0]}L`]; + } else if (last.length === 1) dom = last[0] ? `L-${last[0]}` : "L"; + else throw new Error("Cron can only say the last day of the month (L)"); + } else if (!s.day.star && !s.weekday.star) { + // Cron reads both as OR; only "the Nth weekday" can be said. + const n = NTH_DAYS.indexOf(systemdComponent(s.day, R.day)); + if (n > 0 && isSingle(s.weekday)) [dom, dow] = ["*", `${s.weekday.values[0]}#${n}`]; + else throw new Error("Cron runs when the day of month OR the weekday matches, systemd when both do"); + } + const fields = [ + cronComponent(s.minute, R.minute), + cronComponent(s.hour, R.hour), + dom, + cronComponent(s.month, R.month), + dow, + ]; + return sec === "0" ? fields.join(" ") : [sec, ...fields].join(" "); +} + +// --------------------------------------------------------------------------- +// Presentation + +export interface Preset { + cron: string; + title: string; +} + +export const PRESETS: Preset[] = [ + { cron: "* * * * *", title: "Every minute" }, + { cron: "*/5 * * * *", title: "Every 5 minutes" }, + { cron: "*/15 * * * *", title: "Every 15 minutes" }, + { cron: "0 * * * *", title: "Every hour" }, + { cron: "0 */6 * * *", title: "Every 6 hours" }, + { cron: "0 0 * * *", title: "Every day at midnight" }, + { cron: "0 9 * * *", title: "Every day at 09:00" }, + { cron: "0 9 * * 1-5", title: "Weekdays at 09:00" }, + { cron: "*/30 9-17 * * 1-5", title: "Every 30 minutes during office hours" }, + { cron: "0 0 * * 0", title: "Every Sunday at midnight" }, + { cron: "0 3 * * 6", title: "Every Saturday at 03:00" }, + { cron: "0 0 1 * *", title: "First day of the month" }, + { cron: "0 0 L * *", title: "Last day of the month" }, + { cron: "0 10 * * 1#1", title: "First Monday of the month at 10:00" }, + { cron: "0 0 1 */3 *", title: "Every quarter" }, + { cron: "0 0 1 1 *", title: "Every year on January 1st" }, + { cron: "@reboot", title: "At startup" }, +]; + +/** "in 5 min", "in 3 h", "in 2 days". */ +export function until(date: Date, now: Date): string { + const s = Math.round((date.getTime() - now.getTime()) / 1000); + if (s < 60) return `in ${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `in ${m} min`; + const h = Math.round(m / 60); + if (h < 36) return `in ${h} h`; + const d = Math.round(h / 24); + if (d < 60) return `in ${d} days`; + const mo = Math.round(d / 30); + if (mo < 24) return `in ${mo} months`; + return `in ${Math.round(d / 365)} years`; +} + +/** "Wed 28 Oct 2026, 09:00" in local time. */ +export function formatRun(d: Date, seconds = false): string { + const day = `${DAY_NAMES[d.getDay()].slice(0, 3)} ${d.getDate()} ${ + MONTH_NAMES[d.getMonth()].slice(0, 3) + } ${d.getFullYear()}`; + return `${day}, ${clock(d.getHours(), d.getMinutes(), seconds ? d.getSeconds() : undefined)}`; +} + +/** A timer unit to paste into `~/.config/systemd/user/<name>.timer`. */ +export function timerUnit(lines: string[], description: string): string { + return [ + "[Unit]", + `Description=${description}`, + "", + "[Timer]", + ...lines.map((l) => `OnCalendar=${l}`), + "Persistent=true", + "", + "[Install]", + "WantedBy=timers.target", + ].join("\n"); +} diff --git a/extensions/cron/lib_test.ts b/extensions/cron/lib_test.ts new file mode 100644 index 0000000..5f904c5 --- /dev/null +++ b/extensions/cron/lib_test.ts @@ -0,0 +1,271 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + dayMatches, + explain, + fieldLines, + formatRun, + joinWords, + nextRun, + nextRuns, + ordinal, + parseAny, + parseCron, + parseCronField, + parseSystemd, + timerUnit, + toCron, + toSystemd, + until, +} from "./lib.ts"; + +// Local time throughout: June and January, away from daylight saving changes. +const at = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s); +const runs = (expr: string, from: Date, n = 3) => + nextRuns(parseAny(expr), from, n).map((d) => formatRun(d, parseAny(expr).hasSeconds)); + +Deno.test("cron fields: values, ranges, steps, lists and names", () => { + const r = { name: "minute", min: 0, max: 59 }; + assertEquals(parseCronField("*/15", r).values, [0, 15, 30, 45]); + assertEquals(parseCronField("10-20/5", r).values, [10, 15, 20]); + assertEquals(parseCronField("5/20", r).values, [5, 25, 45]); + assertEquals(parseCronField("1,2,3-5", r).values, [1, 2, 3, 4, 5]); + const s = parseCron("0 9 * JAN-MAR MON-FRI"); + assertEquals(s.month.values, [1, 2, 3]); + assertEquals(s.weekday.values, [1, 2, 3, 4, 5]); + assertEquals(parseCron("0 0 * * 7").weekday.values, [0]); + assertEquals(parseCron("0 0 * * FRI-SUN").weekday.values, [0, 5, 6]); + assertEquals(parseCron("0 0 ? * *").day.star, true); +}); + +Deno.test("cron shapes: 5, 6 and 7 fields, macros", () => { + assertEquals(parseCron("*/5 * * * *").hasSeconds, false); + const six = parseCron("*/10 * * * * *"); + assertEquals([six.hasSeconds, six.second.values.length], [true, 6]); + assertEquals(parseCron("0 0 12 1 1 * 2027").year?.values, [2027]); + assertEquals(parseCron("@daily").minute.values, [0]); + assertEquals(parseCron("@weekly").weekday.values, [0]); + assertEquals(parseCron("@reboot").reboot, true); + assertEquals(parseCron("0 0 L * *").lastDays, [0]); + assertEquals(parseCron("0 0 L-2 * *").lastDays, [2]); + assertEquals(parseCron("0 10 * * 1#2").nth, [{ weekday: 1, n: 2 }]); + assertEquals(parseCron("0 18 * * FRIL").lastWeekday, [5]); +}); + +Deno.test("cron errors say what is wrong", () => { + assertThrows(() => parseCron("* * *"), Error, "5 fields, this one has 3"); + assertThrows(() => parseCron("61 * * * *"), Error, "Minute 61 is out of range (0–59)"); + assertThrows(() => parseCron("* 24 * * *"), Error, "Hour 24 is out of range"); + assertThrows(() => parseCron("5-2 * * * *"), Error, "goes backwards"); + assertThrows(() => parseCron("*/0 * * * *"), Error, "not a valid step"); + assertThrows(() => parseCron("* * * * MON-FOO"), Error, "“FOO” is not a valid day of week"); + assertThrows(() => parseCron("@sometimes"), Error, "Unknown shorthand"); + assertThrows(() => parseCron("1,,2 * * * *"), Error, "Empty value"); + assertThrows(() => parseAny("* * *"), Error, "5 fields"); +}); + +Deno.test("English explanations", () => { + const cases: [string, string][] = [ + ["* * * * *", "Every minute"], + ["*/5 * * * *", "Every 5 minutes"], + ["0 * * * *", "At minute 0 past every hour"], + ["0,30 * * * *", "At minutes 0 and 30 past every hour"], + ["0 */6 * * *", "At minute 0 past every 6th hour"], + ["23 0-20/2 * * *", "At minute 23 past every 2nd hour from 00:00 through 20:59"], + ["0 9 * * 1-5", "At 09:00, on weekdays"], + ["0 0 * * SAT,SUN", "At 00:00, on weekends"], + ["0 0,12 * * *", "At 00:00 and 12:00"], + ["*/30 9-17 * * 1-5", "Every 30 minutes between 09:00 and 17:59, on weekdays"], + ["* 9 * * *", "Every minute between 09:00 and 09:59"], + ["15 14 1 * *", "At 14:15, on day 1 of the month"], + ["0 0 1 */3 *", "At 00:00, on day 1 of the month, every 3 months"], + ["5 0 * 8 *", "At 00:05, in August"], + ["0 4 8-14 * *", "At 04:00, on days 8 through 14 of the month"], + ["0 0 1,15 * 5", "At 00:00, on days 1 and 15 of the month, or on Friday"], + ["0 0 L * *", "At 00:00, on the last day of the month"], + ["0 10 * * 1#1", "At 10:00, on the first Monday of the month"], + ["0 18 * * 5L", "At 18:00, on the last Friday of the month"], + ["0 9 * * 1,3,5", "At 09:00, on Monday, Wednesday and Friday"], + ["*/10 * * * * *", "Every 10 seconds"], + ["30 0 9 * * *", "At 09:00:30"], + ["@weekly", "At 00:00, on Sunday"], + ["@reboot", "Once, when the system starts"], + ]; + for (const [expr, english] of cases) assertEquals(explain(parseCron(expr)), english, expr); +}); + +Deno.test("field lines for the side panel", () => { + assertEquals(fieldLines(parseCron("0 9 * * 1-5")), [ + ["Minute", "0"], + ["Hour", "9"], + ["Day of month", "every"], + ["Month", "every"], + ["Day of week", "weekdays"], + ]); +}); + +Deno.test("next runs in local time", () => { + const wed = at(2026, 6, 10, 10, 0); // Wednesday 10 June 2026, 10:00 + assertEquals(runs("*/5 * * * *", wed), [ + "Wed 10 Jun 2026, 10:05", + "Wed 10 Jun 2026, 10:10", + "Wed 10 Jun 2026, 10:15", + ]); + assertEquals(runs("0 9 * * 1-5", wed), [ + "Thu 11 Jun 2026, 09:00", + "Fri 12 Jun 2026, 09:00", + "Mon 15 Jun 2026, 09:00", + ]); + assertEquals(runs("0 0 L * *", wed), ["Tue 30 Jun 2026, 00:00", "Fri 31 Jul 2026, 00:00", "Mon 31 Aug 2026, 00:00"]); + assertEquals(runs("0 0 L-1 2 *", wed, 2), ["Sat 27 Feb 2027, 00:00", "Mon 28 Feb 2028, 00:00"]); + assertEquals(runs("0 10 * * 1#1", wed), ["Mon 6 Jul 2026, 10:00", "Mon 3 Aug 2026, 10:00", "Mon 7 Sep 2026, 10:00"]); + assertEquals(runs("0 18 * * 5L", wed), [ + "Fri 26 Jun 2026, 18:00", + "Fri 31 Jul 2026, 18:00", + "Fri 28 Aug 2026, 18:00", + ]); + // Day of month OR day of week, as cron does. + assertEquals(runs("0 0 1,15 * 5", wed), [ + "Fri 12 Jun 2026, 00:00", + "Mon 15 Jun 2026, 00:00", + "Fri 19 Jun 2026, 00:00", + ]); + assertEquals(runs("*/20 * * * * *", at(2026, 6, 10, 10, 0, 30)), [ + "Wed 10 Jun 2026, 10:00:40", + "Wed 10 Jun 2026, 10:01", + "Wed 10 Jun 2026, 10:01:20", + ]); + // Leap years. + assertEquals(runs("0 0 29 2 *", wed, 2), ["Tue 29 Feb 2028, 00:00", "Sun 29 Feb 2032, 00:00"]); + // Strictly after: a run at the start time itself is not the next one. + assertEquals(formatRun(nextRun(parseCron("0 10 * * *"), at(2026, 6, 10, 10, 0))!), "Thu 11 Jun 2026, 10:00"); + assertEquals(nextRun(parseCron("@reboot"), wed), undefined); + assertEquals(nextRuns(parseCron("0 0 0 1 1 * 2020"), wed), []); + assertEquals(nextRuns(parseCron("0 0 31 2 *"), wed), []); +}); + +Deno.test("day matching follows cron and systemd rules", () => { + const fri12 = at(2026, 6, 12), mon15 = at(2026, 6, 15); + const cron = parseCron("0 0 15 * 5"); + assertEquals([dayMatches(cron, fri12), dayMatches(cron, mon15)], [true, true]); + const systemd = parseSystemd("Fri *-*-15 00:00"); + assertEquals([dayMatches(systemd, fri12), dayMatches(systemd, mon15)], [false, false]); + assertEquals(dayMatches(systemd, at(2026, 5, 15)), true); // Friday 15 May 2026 +}); + +Deno.test("systemd calendar events", () => { + const s = parseSystemd("Mon..Fri *-*-* 09:00:00"); + assertEquals([s.weekday.values, s.hour.values, s.minute.values, s.second.values], [[1, 2, 3, 4, 5], [9], [0], [0]]); + assertEquals(parseSystemd("Sat,Sun 10:30").weekday.values, [0, 6]); + assertEquals(parseSystemd("Sat..Mon 10:30").weekday.values, [0, 1, 6]); + assertEquals(parseSystemd("*:0/15").minute.values, [0, 15, 30, 45]); + assertEquals(parseSystemd("*-*~01").lastDays, [0]); + assertEquals(parseSystemd("Fri *-*~07/1 18:00").lastDays, [0, 1, 2, 3, 4, 5, 6]); + assertEquals(parseSystemd("*-02~03..01").lastDays, [0, 1, 2]); + assertEquals(parseSystemd("10-28 12:00").month.values, [10]); + assertEquals(parseSystemd("2027-01-01").year?.values, [2027]); + assertEquals(parseSystemd("*-*-* 12:00:00.5").second.values, [0]); + assertEquals(parseSystemd("Sat *-*-* 10:30 Europe/Paris").timezone, "Europe/Paris"); + assertEquals(parseSystemd("weekly").macro, "weekly"); + assertEquals(parseSystemd("quarterly").month.values, [1, 4, 7, 10]); + assertThrows(() => parseSystemd("Funday 10:00"), Error, "not a day of the week"); + assertThrows(() => parseSystemd("12:00 *-*-01"), Error, "the date comes before the time"); + assertThrows(() => parseSystemd("*-13-01"), Error, "Month 13 is out of range"); + assertEquals(explain(parseSystemd("Fri *-*~07/1 18:00")), "At 18:00, on the last Friday of the month"); + assertEquals(explain(parseSystemd("Mon *-*-01..07 10:00")), "At 10:00, on the first Monday of the month"); + assertEquals(explain(parseSystemd("Fri *-*-13")), "At 00:00, on day 13 of the month, if it is a Friday"); + assertEquals(runs("Mon..Fri 09:00", at(2026, 1, 9, 12)), [ + "Mon 12 Jan 2026, 09:00", + "Tue 13 Jan 2026, 09:00", + "Wed 14 Jan 2026, 09:00", + ]); + assertEquals(runs("Fri *-*-13", at(2026, 1, 1), 2), ["Fri 13 Feb 2026, 00:00", "Fri 13 Mar 2026, 00:00"]); +}); + +Deno.test("parseAny tells cron and systemd apart", () => { + assertEquals(parseAny("*/5 * * * *").kind, "cron"); + assertEquals(parseAny("0 9 * * MON-FRI").kind, "cron"); + assertEquals(parseAny("@daily").kind, "cron"); + assertEquals(parseAny("daily").kind, "systemd"); + assertEquals(parseAny("Mon..Fri 09:00").kind, "systemd"); + assertEquals(parseAny("*-*-01 00:00").kind, "systemd"); + assertEquals(parseAny("12:30").kind, "systemd"); +}); + +Deno.test("cron → systemd OnCalendar", () => { + const cases: [string, string[]][] = [ + ["*/5 * * * *", ["*-*-* *:00/5:00"]], + ["0 * * * *", ["hourly"]], + ["0 0 * * *", ["daily"]], + ["0 9 * * 1-5", ["Mon..Fri *-*-* 09:00:00"]], + ["0 9 * * 1,3,5", ["Mon,Wed,Fri *-*-* 09:00:00"]], + ["0 0 * * SAT,SUN", ["Sat,Sun *-*-* 00:00:00"]], + ["*/30 9-17 * * 1-5", ["Mon..Fri *-*-* 09..17:00/30:00"]], + ["0 0 1 */3 *", ["*-01/3-01 00:00:00"]], + ["0 4 8-14 * *", ["*-*-08..14 04:00:00"]], + ["0 0 L * *", ["*-*~01 00:00:00"]], + ["0 0 L-2 * *", ["*-*~03 00:00:00"]], + ["0 10 * * 1#1", ["Mon *-*-01..07 10:00:00"]], + ["0 18 * * 5L", ["Fri *-*~07/1 18:00:00"]], + ["0 0 1,15 * 5", ["*-*-01,15 00:00:00", "Fri *-*-* 00:00:00"]], + ["@weekly", ["Sun *-*-* 00:00:00"]], + ["*/10 * * * * *", ["*-*-* *:*:00/10"]], + ["0 0 12 1 1 * 2027", ["2027-01-01 12:00:00"]], + ]; + for (const [expr, lines] of cases) assertEquals(toSystemd(parseCron(expr)), lines, expr); + assertThrows(() => toSystemd(parseCron("@reboot")), Error, "OnBootSec="); +}); + +Deno.test("systemd → cron", () => { + const cases: [string, string][] = [ + ["Mon..Fri 09:00", "0 9 * * 1-5"], + ["daily", "0 0 * * *"], + ["weekly", "0 0 * * 1"], + ["monthly", "0 0 1 * *"], + ["quarterly", "0 0 1 1,4,7,10 *"], + ["*:0/15", "*/15 * * * *"], + ["*-*-* 08..18/2:30", "30 8-18/2 * * *"], + ["Sat,Sun 10:30", "30 10 * * 0,6"], + ["*-*~01 12:00", "0 12 L * *"], + ["Fri *-*~07/1 18:00", "0 18 * * 5L"], + ["Mon *-*-08..14 10:00", "0 10 * * 1#2"], + ["*-*-* *:*:00/10", "*/10 * * * * *"], + ["*-*-* 12:00:30", "30 0 12 * * *"], + ]; + for (const [expr, cron] of cases) assertEquals(toCron(parseSystemd(expr)), cron, expr); + assertThrows(() => toCron(parseSystemd("2027-01-01")), Error, "no year field"); + assertThrows(() => toCron(parseSystemd("Fri *-*-13")), Error, "OR"); + // Round trips agree on the runs. + for (const expr of ["Mon..Fri 09:00", "*-*~01 12:00", "Fri *-*~07/1 18:00", "*:0/15"]) { + const from = at(2026, 6, 10, 10); + assertEquals(runs(toCron(parseSystemd(expr)), from, 5), runs(expr, from, 5), expr); + } +}); + +Deno.test("words, dates and the timer unit", () => { + assertEquals([joinWords(["a"]), joinWords(["a", "b"]), joinWords(["a", "b", "c"], "or")], [ + "a", + "a and b", + "a, b or c", + ]); + assertEquals([1, 2, 3, 4, 11, 12, 13, 21, 22].map(ordinal), [ + "1st", + "2nd", + "3rd", + "4th", + "11th", + "12th", + "13th", + "21st", + "22nd", + ]); + const now = at(2026, 6, 10, 10); + assertEquals( + [until(at(2026, 6, 10, 10, 5), now), until(at(2026, 6, 10, 13), now), until(at(2026, 6, 15, 10), now)], + ["in 5 min", "in 3 h", "in 5 days"], + ); + assertEquals(formatRun(at(2026, 10, 28, 9, 5, 30), true), "Wed 28 Oct 2026, 09:05:30"); + assertEquals( + timerUnit(["Mon..Fri *-*-* 09:00:00"], "At 09:00, on weekdays"), + "[Unit]\nDescription=At 09:00, on weekdays\n\n[Timer]\nOnCalendar=Mon..Fri *-*-* 09:00:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target", + ); +}); diff --git a/extensions/dev-reference/backslash.toml b/extensions/dev-reference/backslash.toml new file mode 100644 index 0000000..82846af --- /dev/null +++ b/extensions/dev-reference/backslash.toml @@ -0,0 +1,35 @@ +[extension] +id = "dev-reference" +title = "Dev Reference" +description = "HTTP status codes, MIME types, ports, ASCII, HTML entities, key codes, signals, exit codes and a chmod calculator" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# No permission: the tables ship with the extension. + +[[commands]] +name = "dev-reference" +title = "Dev Reference" +description = "Search the tables developers look up: 404, .webp, 5432, SIGTERM,  , KeyA, 137, chmod 755" +icon = "bs-book" +keywords = [ + "http", + "status", + "mime", + "content-type", + "port", + "ascii", + "html entity", + "keycode", + "signal", + "exit code", + "chmod", + "permissions", + "reference", + "référence", + "code de statut", + "droits", + "touche", +] diff --git a/extensions/dev-reference/commands/dev-reference.ts b/extensions/dev-reference/commands/dev-reference.ts new file mode 100644 index 0000000..c4e2ee7 --- /dev/null +++ b/extensions/dev-reference/commands/dev-reference.ts @@ -0,0 +1,59 @@ +import { type Action, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { commonModes, entries, type Entry, modeEntry, parseMode, search, SECTIONS } from "../lib.ts"; + +function item(e: Entry): Item { + const section = SECTIONS[e.table]; + const actions: Action[] = e.copies.map(([title, text], i) => + copy(text, title, i === 1 ? { shortcut: "<Ctrl><Shift>c" } : i > 1 ? { section: "Copy" } : {}) + ); + if (e.link) actions.push(open(e.link, "Open Documentation", { shortcut: "<Ctrl>o" })); + return { + id: e.id, + title: e.title, + subtitle: e.subtitle, + icon: { source: section.icon, tint: e.tint }, + section: section.title, + accessories: e.accessory ? [{ text: e.accessory }] : [], + detail: { + markdown: e.markdown, + metadata: [ + meta.text("Table", section.title, { icon: section.icon }), + meta.separator(), + ...e.facts.map(([label, value]) => meta.text(label, value)), + ], + }, + actions, + }; +} + +export default list({ + placeholder: "Search 404, .png, 5432, SIGTERM,  , KeyA, 137… or type 755 / rwxr-x---", + filter: { + options: [ + { value: "all", title: "All" }, + ...Object.entries(SECTIONS).map(([value, s]) => ({ value, title: s.title })), + ], + }, + empty: { title: "Nothing matches", description: "Try a code, a name or an extension.", icon: Icons.search }, + status: (items) => { + const sections = new Set(items.map((i) => i.section)).size; + return `${items.length} entr${items.length === 1 ? "y" : "ies"}${sections > 1 ? ` in ${sections} tables` : ""}`; + }, + search(query, { filter }) { + const table = filter && filter !== "all" ? filter : undefined; + const mode = parseMode(query); + const modes: Entry[] = []; + if (!table || table === "chmod") { + if (mode !== undefined) modes.push(modeEntry(mode)); + else if (table === "chmod" || !query.trim()) { + modes.push(...(query.trim() ? search(commonModes(), query) : commonModes())); + } + } + const pool = table === "chmod" ? [] : table ? entries().filter((e) => e.table === table) : entries(); + const found = search(pool, query); + // "404" is a status code before it is a mode; "755" or "rwxr-x---" is a mode first. + const exact = found.some((e) => e.keys.includes(query.trim().toLowerCase())); + const modeFirst = mode !== undefined && (!/^\d+$/.test(query.trim()) || !exact); + return (modeFirst || !query.trim() ? [...modes, ...found] : [...found, ...modes]).map(item); + }, +}); diff --git a/extensions/dev-reference/data.ts b/extensions/dev-reference/data.ts new file mode 100644 index 0000000..85a7b4d --- /dev/null +++ b/extensions/dev-reference/data.ts @@ -0,0 +1,574 @@ +// The reference tables. Plain data, shown and searched by lib.ts. + +/** [code, name, description] */ +export const HTTP_STATUS: [number, string, string][] = [ + [100, "Continue", "The client may send the request body."], + [101, "Switching Protocols", "The server switches to the protocol in the Upgrade header (WebSocket)."], + [102, "Processing", "WebDAV: the request is being processed, no response yet."], + [103, "Early Hints", "Preload hints (Link headers) before the final response."], + [200, "OK", "The request succeeded."], + [201, "Created", "A resource was created; Location usually points to it."], + [202, "Accepted", "Accepted for processing, which hasn't finished."], + [203, "Non-Authoritative Information", "The payload was modified by a proxy."], + [204, "No Content", "Success, with no body."], + [205, "Reset Content", "Success; the client should reset its form or view."], + [206, "Partial Content", "The requested byte range (Range header)."], + [207, "Multi-Status", "WebDAV: several status codes in an XML body."], + [208, "Already Reported", "WebDAV: members already listed in a previous part."], + [226, "IM Used", "Delta encoding: the response is the result of instance manipulations."], + [300, "Multiple Choices", "Several representations; the client picks one."], + [301, "Moved Permanently", "The resource has a new permanent URL (Location). Methods may change to GET."], + [302, "Found", "Temporary redirect. Methods may change to GET."], + [303, "See Other", "Fetch the result with GET at the Location URL (after a POST)."], + [304, "Not Modified", "The cached copy is still valid (ETag, If-Modified-Since)."], + [307, "Temporary Redirect", "Temporary redirect keeping the method and body."], + [308, "Permanent Redirect", "Permanent redirect keeping the method and body."], + [400, "Bad Request", "The request is malformed or invalid."], + [401, "Unauthorized", "Authentication is missing or wrong (WWW-Authenticate says how)."], + [402, "Payment Required", "Reserved; some APIs use it for billing limits."], + [403, "Forbidden", "Authenticated, but not allowed."], + [404, "Not Found", "Nothing at this URL."], + [405, "Method Not Allowed", "The method isn't supported here (Allow lists those that are)."], + [406, "Not Acceptable", "No representation matches the Accept headers."], + [407, "Proxy Authentication Required", "Authenticate with the proxy first."], + [408, "Request Timeout", "The server gave up waiting for the request."], + [409, "Conflict", "The request conflicts with the current state (edit conflict, duplicate)."], + [410, "Gone", "The resource was removed for good."], + [411, "Length Required", "Content-Length is required."], + [412, "Precondition Failed", "An If-Match / If-Unmodified-Since condition failed."], + [413, "Content Too Large", "The body is larger than the server accepts."], + [414, "URI Too Long", "The URL is longer than the server accepts."], + [415, "Unsupported Media Type", "The Content-Type of the body isn't supported."], + [416, "Range Not Satisfiable", "The Range is outside the resource."], + [417, "Expectation Failed", "The Expect header can't be met."], + [418, "I'm a teapot", "RFC 2324 joke; some servers use it to refuse bots."], + [421, "Misdirected Request", "The connection can't serve this host (HTTP/2 coalescing)."], + [422, "Unprocessable Content", "Well-formed but semantically invalid (validation errors)."], + [423, "Locked", "WebDAV: the resource is locked."], + [424, "Failed Dependency", "WebDAV: a previous request failed."], + [425, "Too Early", "The server won't risk replaying an early-data request."], + [426, "Upgrade Required", "Switch to the protocol in the Upgrade header."], + [428, "Precondition Required", "The request must be conditional (If-Match)."], + [429, "Too Many Requests", "Rate limited; Retry-After says when to try again."], + [431, "Request Header Fields Too Large", "Headers (often cookies) are too large."], + [451, "Unavailable For Legal Reasons", "Blocked for legal reasons."], + [500, "Internal Server Error", "The server failed unexpectedly."], + [501, "Not Implemented", "The server doesn't support this functionality."], + [502, "Bad Gateway", "A proxy got an invalid response from the upstream server."], + [503, "Service Unavailable", "Overloaded or down for maintenance; Retry-After may say when."], + [504, "Gateway Timeout", "A proxy timed out waiting for the upstream server."], + [505, "HTTP Version Not Supported", "The HTTP version isn't supported."], + [506, "Variant Also Negotiates", "Content negotiation misconfiguration."], + [507, "Insufficient Storage", "WebDAV: no space left to store the representation."], + [508, "Loop Detected", "WebDAV: infinite loop while processing."], + [510, "Not Extended", "Further extensions are required."], + [511, "Network Authentication Required", "Log in to the network first (captive portal)."], +]; + +/** [extension, MIME type, kind] */ +export const MIME_TYPES: [string, string, string][] = [ + ["html", "text/html", "HTML document"], + ["htm", "text/html", "HTML document"], + ["css", "text/css", "Stylesheet"], + ["js", "text/javascript", "JavaScript"], + ["mjs", "text/javascript", "JavaScript module"], + ["ts", "video/mp2t", "MPEG transport stream (TypeScript is usually served as text/plain or application/typescript)"], + ["json", "application/json", "JSON"], + ["jsonld", "application/ld+json", "JSON-LD"], + ["map", "application/json", "Source map"], + ["xml", "application/xml", "XML"], + ["xhtml", "application/xhtml+xml", "XHTML"], + ["txt", "text/plain", "Plain text"], + ["md", "text/markdown", "Markdown"], + ["csv", "text/csv", "Comma-separated values"], + ["tsv", "text/tab-separated-values", "Tab-separated values"], + ["ics", "text/calendar", "iCalendar"], + ["vcf", "text/vcard", "vCard"], + ["yaml", "application/yaml", "YAML"], + ["yml", "application/yaml", "YAML"], + ["toml", "application/toml", "TOML"], + ["wasm", "application/wasm", "WebAssembly"], + ["webmanifest", "application/manifest+json", "Web app manifest"], + ["rss", "application/rss+xml", "RSS feed"], + ["atom", "application/atom+xml", "Atom feed"], + ["pdf", "application/pdf", "PDF document"], + ["rtf", "application/rtf", "Rich Text Format"], + ["doc", "application/msword", "Word document (legacy)"], + ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Word document"], + ["xls", "application/vnd.ms-excel", "Excel spreadsheet (legacy)"], + ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Excel spreadsheet"], + ["ppt", "application/vnd.ms-powerpoint", "PowerPoint (legacy)"], + ["pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "PowerPoint presentation"], + ["odt", "application/vnd.oasis.opendocument.text", "OpenDocument text"], + ["ods", "application/vnd.oasis.opendocument.spreadsheet", "OpenDocument spreadsheet"], + ["odp", "application/vnd.oasis.opendocument.presentation", "OpenDocument presentation"], + ["epub", "application/epub+zip", "EPUB e-book"], + ["png", "image/png", "PNG image"], + ["jpg", "image/jpeg", "JPEG image"], + ["jpeg", "image/jpeg", "JPEG image"], + ["gif", "image/gif", "GIF image"], + ["webp", "image/webp", "WebP image"], + ["avif", "image/avif", "AVIF image"], + ["heic", "image/heic", "HEIC image"], + ["jxl", "image/jxl", "JPEG XL image"], + ["svg", "image/svg+xml", "SVG image"], + ["ico", "image/vnd.microsoft.icon", "Icon"], + ["bmp", "image/bmp", "Bitmap image"], + ["tif", "image/tiff", "TIFF image"], + ["tiff", "image/tiff", "TIFF image"], + ["mp3", "audio/mpeg", "MP3 audio"], + ["ogg", "audio/ogg", "Ogg audio"], + ["oga", "audio/ogg", "Ogg audio"], + ["opus", "audio/opus", "Opus audio"], + ["wav", "audio/wav", "WAV audio"], + ["flac", "audio/flac", "FLAC audio"], + ["aac", "audio/aac", "AAC audio"], + ["m4a", "audio/mp4", "MPEG-4 audio"], + ["weba", "audio/webm", "WebM audio"], + ["mid", "audio/midi", "MIDI"], + ["mp4", "video/mp4", "MPEG-4 video"], + ["m4v", "video/mp4", "MPEG-4 video"], + ["webm", "video/webm", "WebM video"], + ["ogv", "video/ogg", "Ogg video"], + ["mkv", "video/x-matroska", "Matroska video"], + ["mov", "video/quicktime", "QuickTime video"], + ["avi", "video/x-msvideo", "AVI video"], + ["mpeg", "video/mpeg", "MPEG video"], + ["m3u8", "application/vnd.apple.mpegurl", "HLS playlist"], + ["woff", "font/woff", "WOFF font"], + ["woff2", "font/woff2", "WOFF2 font"], + ["ttf", "font/ttf", "TrueType font"], + ["otf", "font/otf", "OpenType font"], + ["zip", "application/zip", "ZIP archive"], + ["gz", "application/gzip", "Gzip archive"], + ["tgz", "application/gzip", "Gzipped tarball"], + ["tar", "application/x-tar", "Tar archive"], + ["bz2", "application/x-bzip2", "Bzip2 archive"], + ["xz", "application/x-xz", "XZ archive"], + ["zst", "application/zstd", "Zstandard archive"], + ["7z", "application/x-7z-compressed", "7-Zip archive"], + ["rar", "application/vnd.rar", "RAR archive"], + ["deb", "application/vnd.debian.binary-package", "Debian package"], + ["rpm", "application/x-rpm", "RPM package"], + ["flatpak", "application/vnd.flatpak", "Flatpak bundle"], + ["appimage", "application/vnd.appimage", "AppImage"], + ["iso", "application/x-iso9660-image", "Disk image"], + ["jar", "application/java-archive", "Java archive"], + ["sh", "application/x-sh", "Shell script"], + ["py", "text/x-python", "Python source"], + ["sql", "application/sql", "SQL"], + ["bin", "application/octet-stream", "Binary data (the default for unknown files)"], + ["form", "application/x-www-form-urlencoded", "HTML form body (no extension)"], + ["multipart", "multipart/form-data", "Form body with files (no extension)"], + ["ndjson", "application/x-ndjson", "Newline-delimited JSON"], + ["sse", "text/event-stream", "Server-sent events (no extension)"], + ["pem", "application/x-pem-file", "PEM certificate or key"], + ["crt", "application/x-x509-ca-cert", "X.509 certificate"], + ["torrent", "application/x-bittorrent", "BitTorrent"], +]; + +/** [port, protocol, service, description] */ +export const PORTS: [number, string, string, string][] = [ + [20, "TCP", "FTP data", "File Transfer Protocol data channel"], + [21, "TCP", "FTP", "File Transfer Protocol control"], + [22, "TCP", "SSH", "Secure Shell, SFTP, scp, git over SSH"], + [23, "TCP", "Telnet", "Unencrypted remote login"], + [25, "TCP", "SMTP", "Mail transfer between servers"], + [53, "TCP/UDP", "DNS", "Domain Name System"], + [67, "UDP", "DHCP server", "Dynamic Host Configuration Protocol (server)"], + [68, "UDP", "DHCP client", "Dynamic Host Configuration Protocol (client)"], + [69, "UDP", "TFTP", "Trivial File Transfer Protocol (PXE boot)"], + [80, "TCP", "HTTP", "The web, unencrypted"], + [88, "TCP/UDP", "Kerberos", "Authentication"], + [110, "TCP", "POP3", "Mail retrieval"], + [111, "TCP/UDP", "rpcbind", "ONC RPC portmapper (NFS v3)"], + [119, "TCP", "NNTP", "Usenet news"], + [123, "UDP", "NTP", "Network Time Protocol"], + [137, "UDP", "NetBIOS", "NetBIOS name service"], + [139, "TCP", "NetBIOS", "NetBIOS session (old SMB)"], + [143, "TCP", "IMAP", "Mail access"], + [161, "UDP", "SNMP", "Network management"], + [162, "UDP", "SNMP traps", "Network management notifications"], + [179, "TCP", "BGP", "Border Gateway Protocol"], + [389, "TCP", "LDAP", "Directory access"], + [443, "TCP/UDP", "HTTPS", "The web over TLS; UDP for HTTP/3 (QUIC)"], + [445, "TCP", "SMB", "Windows file sharing, Samba"], + [465, "TCP", "SMTPS", "Mail submission over TLS"], + [500, "UDP", "IKE", "IPsec key exchange"], + [514, "UDP", "Syslog", "Remote logging"], + [515, "TCP", "LPD", "Line printer daemon"], + [587, "TCP", "Submission", "Mail submission (STARTTLS)"], + [631, "TCP", "IPP", "Internet Printing Protocol, CUPS"], + [636, "TCP", "LDAPS", "LDAP over TLS"], + [853, "TCP", "DNS over TLS", "Encrypted DNS"], + [873, "TCP", "rsync", "rsync daemon"], + [990, "TCP", "FTPS", "FTP over TLS"], + [993, "TCP", "IMAPS", "IMAP over TLS"], + [995, "TCP", "POP3S", "POP3 over TLS"], + [1080, "TCP", "SOCKS", "SOCKS proxy"], + [1194, "UDP", "OpenVPN", "OpenVPN"], + [1433, "TCP", "SQL Server", "Microsoft SQL Server"], + [1521, "TCP", "Oracle", "Oracle database listener"], + [1883, "TCP", "MQTT", "Message queue for IoT"], + [2049, "TCP/UDP", "NFS", "Network File System"], + [2375, "TCP", "Docker", "Docker API, unencrypted"], + [2376, "TCP", "Docker TLS", "Docker API over TLS"], + [2379, "TCP", "etcd", "etcd client API"], + [3000, "TCP", "Dev server", "Node, Rails, Grafana default"], + [3306, "TCP", "MySQL", "MySQL, MariaDB"], + [3389, "TCP", "RDP", "Remote Desktop Protocol"], + [3478, "TCP/UDP", "STUN/TURN", "NAT traversal for WebRTC"], + [4369, "TCP", "EPMD", "Erlang port mapper (RabbitMQ, Elixir)"], + [5000, "TCP", "Dev server", "Flask, Docker registry default"], + [5173, "TCP", "Vite", "Vite dev server"], + [5222, "TCP", "XMPP", "Jabber client connections"], + [5353, "UDP", "mDNS", "Multicast DNS (Avahi, Bonjour)"], + [5432, "TCP", "PostgreSQL", "PostgreSQL"], + [5672, "TCP", "AMQP", "RabbitMQ"], + [5900, "TCP", "VNC", "Remote framebuffer (display :0)"], + [6379, "TCP", "Redis", "Redis, Valkey"], + [6443, "TCP", "Kubernetes API", "kube-apiserver"], + [6667, "TCP", "IRC", "Internet Relay Chat"], + [6697, "TCP", "IRC TLS", "IRC over TLS"], + [8000, "TCP", "HTTP alt", "Python http.server, Django default"], + [8080, "TCP", "HTTP alt", "Proxies, Tomcat, dev servers"], + [8086, "TCP", "InfluxDB", "InfluxDB HTTP API"], + [8443, "TCP", "HTTPS alt", "Alternative HTTPS"], + [8883, "TCP", "MQTT TLS", "MQTT over TLS"], + [9000, "TCP", "Dev server", "PHP-FPM, SonarQube, MinIO"], + [9090, "TCP", "Prometheus", "Prometheus, Cockpit"], + [9092, "TCP", "Kafka", "Apache Kafka"], + [9100, "TCP", "Node exporter", "Prometheus node exporter, raw printing"], + [9200, "TCP", "Elasticsearch", "Elasticsearch, OpenSearch HTTP"], + [9418, "TCP", "Git", "git:// protocol"], + [11211, "TCP", "Memcached", "Memcached"], + [11434, "TCP", "Ollama", "Ollama API"], + [15672, "TCP", "RabbitMQ UI", "RabbitMQ management"], + [25565, "TCP", "Minecraft", "Minecraft server"], + [27017, "TCP", "MongoDB", "MongoDB"], + [51820, "UDP", "WireGuard", "WireGuard default"], +]; + +/** Names of the control characters 0–31 and 127: [abbreviation, name, escape]. */ +export const ASCII_CONTROL: Record<number, [string, string, string?]> = { + 0: ["NUL", "Null", "\\0"], + 1: ["SOH", "Start of heading"], + 2: ["STX", "Start of text"], + 3: ["ETX", "End of text (Ctrl+C)"], + 4: ["EOT", "End of transmission (Ctrl+D)"], + 5: ["ENQ", "Enquiry"], + 6: ["ACK", "Acknowledge"], + 7: ["BEL", "Bell", "\\a"], + 8: ["BS", "Backspace", "\\b"], + 9: ["HT", "Horizontal tab", "\\t"], + 10: ["LF", "Line feed", "\\n"], + 11: ["VT", "Vertical tab", "\\v"], + 12: ["FF", "Form feed", "\\f"], + 13: ["CR", "Carriage return", "\\r"], + 14: ["SO", "Shift out"], + 15: ["SI", "Shift in"], + 16: ["DLE", "Data link escape"], + 17: ["DC1", "Device control 1 (XON, Ctrl+Q)"], + 18: ["DC2", "Device control 2"], + 19: ["DC3", "Device control 3 (XOFF, Ctrl+S)"], + 20: ["DC4", "Device control 4"], + 21: ["NAK", "Negative acknowledge"], + 22: ["SYN", "Synchronous idle"], + 23: ["ETB", "End of transmission block"], + 24: ["CAN", "Cancel"], + 25: ["EM", "End of medium"], + 26: ["SUB", "Substitute (Ctrl+Z)"], + 27: ["ESC", "Escape", "\\e"], + 28: ["FS", "File separator"], + 29: ["GS", "Group separator"], + 30: ["RS", "Record separator"], + 31: ["US", "Unit separator"], + 32: ["SP", "Space"], + 127: ["DEL", "Delete"], +}; + +/** [entity name, character, description] */ +export const HTML_ENTITIES: [string, string, string][] = [ + ["amp", "&", "Ampersand"], + ["lt", "<", "Less-than sign"], + ["gt", ">", "Greater-than sign"], + ["quot", '"', "Quotation mark"], + ["apos", "'", "Apostrophe"], + ["nbsp", " ", "No-break space"], + ["ensp", " ", "En space"], + ["emsp", " ", "Em space"], + ["thinsp", " ", "Thin space"], + ["zwj", "‍", "Zero-width joiner"], + ["zwnj", "‌", "Zero-width non-joiner"], + ["shy", "­", "Soft hyphen"], + ["copy", "©", "Copyright"], + ["reg", "®", "Registered"], + ["trade", "™", "Trade mark"], + ["sect", "§", "Section"], + ["para", "¶", "Pilcrow (paragraph)"], + ["deg", "°", "Degree"], + ["plusmn", "±", "Plus-minus"], + ["times", "×", "Multiplication"], + ["divide", "÷", "Division"], + ["minus", "−", "Minus"], + ["ne", "≠", "Not equal"], + ["le", "≤", "Less-than or equal"], + ["ge", "≥", "Greater-than or equal"], + ["asymp", "≈", "Almost equal"], + ["equiv", "≡", "Identical to"], + ["infin", "∞", "Infinity"], + ["sum", "∑", "Summation"], + ["prod", "∏", "Product"], + ["radic", "√", "Square root"], + ["part", "∂", "Partial differential"], + ["int", "∫", "Integral"], + ["forall", "∀", "For all"], + ["exist", "∃", "There exists"], + ["isin", "∈", "Element of"], + ["empty", "∅", "Empty set"], + ["and", "∧", "Logical and"], + ["or", "∨", "Logical or"], + ["not", "¬", "Not sign"], + ["micro", "µ", "Micro"], + ["permil", "‰", "Per mille"], + ["frac12", "½", "One half"], + ["frac14", "¼", "One quarter"], + ["frac34", "¾", "Three quarters"], + ["sup2", "²", "Superscript two"], + ["sup3", "³", "Superscript three"], + ["middot", "·", "Middle dot"], + ["bull", "•", "Bullet"], + ["hellip", "…", "Ellipsis"], + ["ndash", "–", "En dash"], + ["mdash", "—", "Em dash"], + ["lsquo", "‘", "Left single quote"], + ["rsquo", "’", "Right single quote"], + ["ldquo", "“", "Left double quote"], + ["rdquo", "”", "Right double quote"], + ["sbquo", "‚", "Low single quote"], + ["bdquo", "„", "Low double quote"], + ["laquo", "«", "Left guillemet"], + ["raquo", "»", "Right guillemet"], + ["lsaquo", "‹", "Single left angle quote"], + ["rsaquo", "›", "Single right angle quote"], + ["prime", "′", "Prime (minutes, feet)"], + ["Prime", "″", "Double prime (seconds, inches)"], + ["dagger", "†", "Dagger"], + ["Dagger", "‡", "Double dagger"], + ["iexcl", "¡", "Inverted exclamation mark"], + ["iquest", "¿", "Inverted question mark"], + ["euro", "€", "Euro"], + ["pound", "£", "Pound"], + ["yen", "¥", "Yen"], + ["cent", "¢", "Cent"], + ["curren", "¤", "Currency sign"], + ["larr", "←", "Left arrow"], + ["rarr", "→", "Right arrow"], + ["uarr", "↑", "Up arrow"], + ["darr", "↓", "Down arrow"], + ["harr", "↔", "Left-right arrow"], + ["crarr", "↵", "Carriage return arrow"], + ["lArr", "⇐", "Double left arrow"], + ["rArr", "⇒", "Double right arrow (implies)"], + ["hArr", "⇔", "Double left-right arrow (iff)"], + ["check", "✓", "Check mark"], + ["cross", "✗", "Ballot X"], + ["star", "☆", "White star"], + ["starf", "★", "Black star"], + ["hearts", "♥", "Heart suit"], + ["spades", "♠", "Spade suit"], + ["clubs", "♣", "Club suit"], + ["diams", "♦", "Diamond suit"], + ["loz", "◊", "Lozenge"], + ["alpha", "α", "Greek small alpha"], + ["beta", "β", "Greek small beta"], + ["gamma", "γ", "Greek small gamma"], + ["delta", "δ", "Greek small delta"], + ["Delta", "Δ", "Greek capital delta"], + ["epsilon", "ε", "Greek small epsilon"], + ["lambda", "λ", "Greek small lambda"], + ["mu", "μ", "Greek small mu"], + ["pi", "π", "Greek small pi"], + ["sigma", "σ", "Greek small sigma"], + ["Sigma", "Σ", "Greek capital sigma"], + ["tau", "τ", "Greek small tau"], + ["phi", "φ", "Greek small phi"], + ["omega", "ω", "Greek small omega"], + ["Omega", "Ω", "Greek capital omega (ohm)"], + ["eacute", "é", "e with acute"], + ["egrave", "è", "e with grave"], + ["ecirc", "ê", "e with circumflex"], + ["agrave", "à", "a with grave"], + ["ccedil", "ç", "c with cedilla"], + ["ouml", "ö", "o with diaeresis"], + ["uuml", "ü", "u with diaeresis"], + ["ntilde", "ñ", "n with tilde"], + ["szlig", "ß", "Sharp s"], + ["aelig", "æ", "ae ligature"], + ["oelig", "œ", "oe ligature"], + ["oslash", "ø", "o with stroke"], + ["aring", "å", "a with ring"], +]; + +/** [label, KeyboardEvent.key, KeyboardEvent.code, legacy keyCode, Linux evdev name, evdev code] */ +export const KEYS: [string, string, string, number | null, string, number][] = [ + ["Escape", "Escape", "Escape", 27, "KEY_ESC", 1], + ["Enter", "Enter", "Enter", 13, "KEY_ENTER", 28], + ["Tab", "Tab", "Tab", 9, "KEY_TAB", 15], + ["Space", " ", "Space", 32, "KEY_SPACE", 57], + ["Backspace", "Backspace", "Backspace", 8, "KEY_BACKSPACE", 14], + ["Delete", "Delete", "Delete", 46, "KEY_DELETE", 111], + ["Insert", "Insert", "Insert", 45, "KEY_INSERT", 110], + ["Home", "Home", "Home", 36, "KEY_HOME", 102], + ["End", "End", "End", 35, "KEY_END", 107], + ["Page Up", "PageUp", "PageUp", 33, "KEY_PAGEUP", 104], + ["Page Down", "PageDown", "PageDown", 34, "KEY_PAGEDOWN", 109], + ["Arrow Up", "ArrowUp", "ArrowUp", 38, "KEY_UP", 103], + ["Arrow Down", "ArrowDown", "ArrowDown", 40, "KEY_DOWN", 108], + ["Arrow Left", "ArrowLeft", "ArrowLeft", 37, "KEY_LEFT", 105], + ["Arrow Right", "ArrowRight", "ArrowRight", 39, "KEY_RIGHT", 106], + ["Left Shift", "Shift", "ShiftLeft", 16, "KEY_LEFTSHIFT", 42], + ["Right Shift", "Shift", "ShiftRight", 16, "KEY_RIGHTSHIFT", 54], + ["Left Ctrl", "Control", "ControlLeft", 17, "KEY_LEFTCTRL", 29], + ["Right Ctrl", "Control", "ControlRight", 17, "KEY_RIGHTCTRL", 97], + ["Left Alt", "Alt", "AltLeft", 18, "KEY_LEFTALT", 56], + ["Right Alt (AltGr)", "AltGraph", "AltRight", 18, "KEY_RIGHTALT", 100], + ["Left Super", "Meta", "MetaLeft", 91, "KEY_LEFTMETA", 125], + ["Right Super", "Meta", "MetaRight", 92, "KEY_RIGHTMETA", 126], + ["Menu", "ContextMenu", "ContextMenu", 93, "KEY_COMPOSE", 127], + ["Caps Lock", "CapsLock", "CapsLock", 20, "KEY_CAPSLOCK", 58], + ["Num Lock", "NumLock", "NumLock", 144, "KEY_NUMLOCK", 69], + ["Scroll Lock", "ScrollLock", "ScrollLock", 145, "KEY_SCROLLLOCK", 70], + ["Print Screen", "PrintScreen", "PrintScreen", 44, "KEY_SYSRQ", 99], + ["Pause", "Pause", "Pause", 19, "KEY_PAUSE", 119], + ["`", "`", "Backquote", 192, "KEY_GRAVE", 41], + ["-", "-", "Minus", 189, "KEY_MINUS", 12], + ["=", "=", "Equal", 187, "KEY_EQUAL", 13], + ["[", "[", "BracketLeft", 219, "KEY_LEFTBRACE", 26], + ["]", "]", "BracketRight", 221, "KEY_RIGHTBRACE", 27], + ["\\", "\\", "Backslash", 220, "KEY_BACKSLASH", 43], + [";", ";", "Semicolon", 186, "KEY_SEMICOLON", 39], + ["'", "'", "Quote", 222, "KEY_APOSTROPHE", 40], + [",", ",", "Comma", 188, "KEY_COMMA", 51], + [".", ".", "Period", 190, "KEY_DOT", 52], + ["/", "/", "Slash", 191, "KEY_SLASH", 53], + ["Numpad Enter", "Enter", "NumpadEnter", 13, "KEY_KPENTER", 96], + ["Numpad +", "+", "NumpadAdd", 107, "KEY_KPPLUS", 78], + ["Numpad −", "-", "NumpadSubtract", 109, "KEY_KPMINUS", 74], + ["Numpad *", "*", "NumpadMultiply", 106, "KEY_KPASTERISK", 55], + ["Numpad /", "/", "NumpadDivide", 111, "KEY_KPSLASH", 98], + ["Numpad .", ".", "NumpadDecimal", 110, "KEY_KPDOT", 83], + ["Mute", "AudioVolumeMute", "AudioVolumeMute", 173, "KEY_MUTE", 113], + ["Volume Down", "AudioVolumeDown", "AudioVolumeDown", 174, "KEY_VOLUMEDOWN", 114], + ["Volume Up", "AudioVolumeUp", "AudioVolumeUp", 175, "KEY_VOLUMEUP", 115], + ["Play/Pause", "MediaPlayPause", "MediaPlayPause", 179, "KEY_PLAYPAUSE", 164], + ["Next Track", "MediaTrackNext", "MediaTrackNext", 176, "KEY_NEXTSONG", 163], + ["Previous Track", "MediaTrackPrevious", "MediaTrackPrevious", 177, "KEY_PREVIOUSSONG", 165], + ["Brightness Up", "BrightnessUp", "BrightnessUp", null, "KEY_BRIGHTNESSUP", 225], + ["Brightness Down", "BrightnessDown", "BrightnessDown", null, "KEY_BRIGHTNESSDOWN", 224], +]; + +// Letters, digits, function keys and the number pad follow patterns. +// deno-fmt-ignore +const LETTER_EVDEV: Record<string, number> = { + Q: 16, W: 17, E: 18, R: 19, T: 20, Y: 21, U: 22, I: 23, O: 24, P: 25, + A: 30, S: 31, D: 32, F: 33, G: 34, H: 35, J: 36, K: 37, L: 38, + Z: 44, X: 45, C: 46, V: 47, B: 48, N: 49, M: 50, +}; +for (const [letter, evdev] of Object.entries(LETTER_EVDEV).sort()) { + KEYS.push([letter, letter.toLowerCase(), `Key${letter}`, letter.charCodeAt(0), `KEY_${letter}`, evdev]); +} +for (let d = 0; d <= 9; d++) KEYS.push([String(d), String(d), `Digit${d}`, 48 + d, `KEY_${d}`, d === 0 ? 11 : d + 1]); +for (let f = 1; f <= 12; f++) KEYS.push([`F${f}`, `F${f}`, `F${f}`, 111 + f, `KEY_F${f}`, f <= 10 ? 58 + f : 76 + f]); +const KP_EVDEV = [82, 79, 80, 81, 75, 76, 77, 71, 72, 73]; +for (let d = 0; d <= 9; d++) KEYS.push([`Numpad ${d}`, String(d), `Numpad${d}`, 96 + d, `KEY_KP${d}`, KP_EVDEV[d]]); + +/** [number, name, default action, description] on Linux x86 and ARM. */ +export const SIGNALS: [number, string, string, string][] = [ + [1, "SIGHUP", "Terminate", "Terminal closed; daemons often reload their configuration"], + [2, "SIGINT", "Terminate", "Interrupt from the keyboard (Ctrl+C)"], + [3, "SIGQUIT", "Core dump", "Quit from the keyboard (Ctrl+\\)"], + [4, "SIGILL", "Core dump", "Illegal instruction"], + [5, "SIGTRAP", "Core dump", "Breakpoint or trace trap (debuggers)"], + [6, "SIGABRT", "Core dump", "abort(), failed assertion"], + [7, "SIGBUS", "Core dump", "Bus error: bad memory access"], + [8, "SIGFPE", "Core dump", "Arithmetic error, like an integer division by zero"], + [9, "SIGKILL", "Terminate", "Kill at once; can't be caught or ignored"], + [10, "SIGUSR1", "Terminate", "User-defined signal 1"], + [11, "SIGSEGV", "Core dump", "Segmentation fault: invalid memory reference"], + [12, "SIGUSR2", "Terminate", "User-defined signal 2"], + [13, "SIGPIPE", "Terminate", "Write to a pipe with no reader"], + [14, "SIGALRM", "Terminate", "Timer from alarm()"], + [15, "SIGTERM", "Terminate", "Polite termination request (kill's default)"], + [16, "SIGSTKFLT", "Terminate", "Coprocessor stack fault (unused)"], + [17, "SIGCHLD", "Ignore", "A child process stopped or exited"], + [18, "SIGCONT", "Continue", "Continue a stopped process (fg, bg)"], + [19, "SIGSTOP", "Stop", "Stop the process; can't be caught or ignored"], + [20, "SIGTSTP", "Stop", "Stop from the keyboard (Ctrl+Z)"], + [21, "SIGTTIN", "Stop", "Background process read from the terminal"], + [22, "SIGTTOU", "Stop", "Background process wrote to the terminal"], + [23, "SIGURG", "Ignore", "Urgent data on a socket"], + [24, "SIGXCPU", "Core dump", "CPU time limit exceeded"], + [25, "SIGXFSZ", "Core dump", "File size limit exceeded"], + [26, "SIGVTALRM", "Terminate", "Virtual timer expired"], + [27, "SIGPROF", "Terminate", "Profiling timer expired"], + [28, "SIGWINCH", "Ignore", "Terminal window resized"], + [29, "SIGIO", "Terminate", "I/O possible (also SIGPOLL)"], + [30, "SIGPWR", "Terminate", "Power failure"], + [31, "SIGSYS", "Core dump", "Bad system call (seccomp)"], + [34, "SIGRTMIN", "Terminate", "First real-time signal (glibc reserves 32 and 33)"], + [64, "SIGRTMAX", "Terminate", "Last real-time signal"], +]; + +/** [code, name, description] */ +export const EXIT_CODES: [number, string, string][] = [ + [0, "Success", "The command succeeded"], + [1, "General error", "Any failure: the catch-all"], + [2, "Usage error", "Misuse of a shell builtin or wrong arguments (grep: an error occurred)"], + [64, "EX_USAGE", "sysexits.h: the command was used incorrectly"], + [65, "EX_DATAERR", "sysexits.h: the input data was incorrect"], + [66, "EX_NOINPUT", "sysexits.h: an input file didn't exist or wasn't readable"], + [67, "EX_NOUSER", "sysexits.h: the user doesn't exist"], + [68, "EX_NOHOST", "sysexits.h: the host doesn't exist"], + [69, "EX_UNAVAILABLE", "sysexits.h: a service is unavailable"], + [70, "EX_SOFTWARE", "sysexits.h: internal software error"], + [71, "EX_OSERR", "sysexits.h: system error (can't fork)"], + [72, "EX_OSFILE", "sysexits.h: a system file is missing"], + [73, "EX_CANTCREAT", "sysexits.h: can't create an output file"], + [74, "EX_IOERR", "sysexits.h: input/output error"], + [75, "EX_TEMPFAIL", "sysexits.h: temporary failure, try again"], + [76, "EX_PROTOCOL", "sysexits.h: remote protocol error"], + [77, "EX_NOPERM", "sysexits.h: permission denied"], + [78, "EX_CONFIG", "sysexits.h: configuration error"], + [124, "Timed out", "timeout(1): the command ran out of time"], + [125, "Command failed to run", "timeout, env, docker run: the wrapper itself failed"], + [126, "Not executable", "The file was found but can't be executed (permissions, wrong format)"], + [127, "Command not found", "Not in PATH, or a typo"], + [128, "Invalid exit argument", "exit was given something other than 0–255"], + [129, "SIGHUP", "128 + 1: the terminal hung up"], + [130, "SIGINT", "128 + 2: interrupted with Ctrl+C"], + [131, "SIGQUIT", "128 + 3: quit with Ctrl+\\"], + [134, "SIGABRT", "128 + 6: aborted (assertion failed)"], + [137, "SIGKILL", "128 + 9: killed, often by the OOM killer or docker stop"], + [139, "SIGSEGV", "128 + 11: segmentation fault"], + [141, "SIGPIPE", "128 + 13: broken pipe (output closed, like | head)"], + [143, "SIGTERM", "128 + 15: terminated (systemctl stop, kill)"], + [255, "Out of range", "exit -1, or an SSH connection error"], +]; + +/** Modes worth knowing: [octal, what it's for]. */ +export const COMMON_MODES: [string, string][] = [ + ["755", "Directories, scripts and programs"], + ["644", "Ordinary files"], + ["600", "Private files: SSH keys, secrets"], + ["700", "Private directories: ~/.ssh, ~/.gnupg"], + ["640", "Config readable by a group"], + ["775", "Shared directories in a group"], + ["664", "Shared files in a group"], + ["444", "Read-only for everyone"], + ["400", "Read-only for the owner (AWS .pem keys)"], + ["777", "Everyone can do everything: avoid"], + ["4755", "setuid program (passwd, sudo)"], + ["2775", "setgid directory: new files keep the group"], + ["1777", "Sticky world-writable directory (/tmp)"], +]; diff --git a/extensions/dev-reference/lib.ts b/extensions/dev-reference/lib.ts new file mode 100644 index 0000000..522b182 --- /dev/null +++ b/extensions/dev-reference/lib.ts @@ -0,0 +1,421 @@ +// Reference entries, search ranking and the chmod calculator. Pure, tested in lib_test.ts. + +import { + ASCII_CONTROL, + COMMON_MODES, + EXIT_CODES, + HTML_ENTITIES, + HTTP_STATUS, + KEYS, + MIME_TYPES, + PORTS, + SIGNALS, +} from "./data.ts"; + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export interface Entry { + id: string; + /** The table it belongs to: a key of {@link SECTIONS}. */ + table: string; + title: string; + subtitle: string; + /** Exact matches rank first ("404", "png", "sigterm"). */ + keys: string[]; + keywords: string[]; + tint: Tint; + /** Short text on the right. */ + accessory?: string; + /** [title, text]: the first is Enter. */ + copies: [string, string][]; + /** [label, value] lines of the side panel. */ + facts: [string, string][]; + markdown?: string; + /** Documentation page. */ + link?: string; +} + +export const SECTIONS: Record<string, { title: string; icon: string }> = { + chmod: { title: "chmod", icon: "bs-lock" }, + http: { title: "HTTP Status Codes", icon: "bs-globe" }, + mime: { title: "MIME Types", icon: "bs-file" }, + ports: { title: "Ports", icon: "bs-plug" }, + ascii: { title: "ASCII", icon: "bs-text" }, + entities: { title: "HTML Entities", icon: "bs-code" }, + keys: { title: "Keyboard Keys", icon: "bs-keyboard" }, + signals: { title: "Unix Signals", icon: "bs-bolt" }, + exit: { title: "Exit Codes", icon: "bs-terminal" }, +}; + +const hex = (n: number, width = 2) => n.toString(16).toUpperCase().padStart(width, "0"); + +export function httpClass(code: number): { name: string; tint: Tint } { + if (code < 200) return { name: "Informational", tint: "blue" }; + if (code < 300) return { name: "Success", tint: "green" }; + if (code < 400) return { name: "Redirection", tint: "purple" }; + if (code < 500) return { name: "Client error", tint: "orange" }; + return { name: "Server error", tint: "red" }; +} + +function httpEntries(): Entry[] { + return HTTP_STATUS.map(([code, name, description]) => { + const cls = httpClass(code); + return { + id: `http:${code}`, + table: "http", + title: `${code} ${name}`, + subtitle: description, + keys: [String(code), name.toLowerCase()], + keywords: [cls.name, "status", "http"], + tint: cls.tint, + accessory: cls.name, + copies: [["Copy Code", String(code)], ["Copy Code and Name", `${code} ${name}`]], + facts: [["Code", String(code)], ["Name", name], ["Class", `${String(code)[0]}xx · ${cls.name}`]], + markdown: `## ${code} ${name}\n\n${description}`, + link: `https://developer.mozilla.org/docs/Web/HTTP/Reference/Status/${code}`, + }; + }); +} + +function mimeEntries(): Entry[] { + return MIME_TYPES.map(([ext, type, kind]) => ({ + id: `mime:${ext}`, + table: "mime", + title: type, + subtitle: `.${ext} · ${kind}`, + keys: [ext, `.${ext}`, type], + keywords: [kind, type.split("/")[0]], + tint: ({ image: "pink", audio: "purple", video: "red", font: "teal", text: "blue" } as Record<string, Tint>)[ + type.split("/")[0] + ] ?? "gray", + accessory: `.${ext}`, + copies: [["Copy MIME Type", type], ["Copy Content-Type Header", `Content-Type: ${type}`]], + facts: [["Extension", `.${ext}`], ["MIME type", type], ["Kind", kind]], + })); +} + +function portEntries(): Entry[] { + return PORTS.map(([port, protocol, service, description]) => ({ + id: `port:${port}`, + table: "ports", + title: `${port} · ${service}`, + subtitle: description, + keys: [String(port), service.toLowerCase()], + keywords: [protocol, port < 1024 ? "well-known privileged" : "registered"], + tint: port < 1024 ? "blue" : "teal", + accessory: protocol, + copies: [["Copy Port", String(port)], ["Copy ss Command", `ss -tulpn 'sport = :${port}'`]], + facts: [ + ["Port", String(port)], + ["Protocol", protocol], + ["Service", service], + ["Range", port < 1024 ? "Well-known (needs root to listen)" : "Registered"], + ], + })); +} + +function asciiEntries(): Entry[] { + const out: Entry[] = []; + for (let n = 0; n < 128; n++) { + const control = ASCII_CONTROL[n]; + const char = String.fromCharCode(n); + const shown = control && n !== 32 ? control[0] : n === 32 ? "Space" : char; + out.push({ + id: `ascii:${n}`, + table: "ascii", + title: control ? `${shown} · ${control[1]}` : char, + subtitle: `${n} · 0x${hex(n)} · 0o${n.toString(8).padStart(3, "0")}`, + keys: [String(n), `0x${hex(n).toLowerCase()}`, ...(control ? [control[0].toLowerCase()] : []), char], + keywords: control + ? [control[1], "control"] + : [/[a-z]/i.test(char) ? "letter" : /\d/.test(char) ? "digit" : "symbol"], + tint: control ? "gray" : "blue", + accessory: `0x${hex(n)}`, + copies: [ + ...(control && n !== 32 ? [] : [["Copy Character", char] as [string, string]]), + ["Copy Decimal", String(n)], + ["Copy Hex", `0x${hex(n)}`], + ...(control?.[2] ? [["Copy Escape", control[2]] as [string, string]] : []), + ], + facts: [ + ["Decimal", String(n)], + ["Hex", `0x${hex(n)}`], + ["Octal", `0o${n.toString(8)}`], + ["Binary", n.toString(2).padStart(8, "0")], + ["HTML", `&#${n};`], + ...(control?.[2] ? [["Escape", control[2]] as [string, string]] : []), + ...(n > 0 && n < 27 ? [["Keyboard", `Ctrl+${String.fromCharCode(64 + n)}`] as [string, string]] : []), + ], + }); + } + return out; +} + +function entityEntries(): Entry[] { + return HTML_ENTITIES.map(([name, char, description]) => { + const cp = char.codePointAt(0)!; + const visible = /\s|[​-‍­]/.test(char) ? `(${description.toLowerCase()})` : char; + return { + id: `entity:${name}`, + table: "entities", + title: `${visible} &${name};`, + subtitle: description, + keys: [name.toLowerCase(), `&${name.toLowerCase()};`, char], + keywords: [`U+${hex(cp, 4)}`], + tint: "purple", + accessory: `U+${hex(cp, 4)}`, + copies: [["Copy Entity", `&${name};`], ["Copy Character", char], ["Copy Numeric Entity", `&#${cp};`]], + facts: [ + ["Character", visible], + ["Named", `&${name};`], + ["Decimal", `&#${cp};`], + ["Hex", `&#x${hex(cp)};`], + ["Code point", `U+${hex(cp, 4)}`], + ["CSS", `\\${hex(cp, 4).toLowerCase()}`], + ], + }; + }); +} + +function keyEntries(): Entry[] { + return KEYS.map(([label, key, code, keyCode, evdev, evcode]) => ({ + id: `key:${code}`, + table: "keys", + title: label, + subtitle: `code ${code} · key ${key === " " ? '" "' : key}`, + keys: [ + label.toLowerCase(), + code.toLowerCase(), + evdev.toLowerCase(), + ...(keyCode !== null ? [String(keyCode)] : []), + ], + keywords: [key, String(evcode), "keycode"], + tint: "gray", + accessory: keyCode !== null ? `keyCode ${keyCode}` : undefined, + copies: [ + ["Copy event.code", code], + ["Copy event.key", key], + ...(keyCode !== null ? [["Copy keyCode", String(keyCode)] as [string, string]] : []), + ["Copy evdev Name", evdev], + ], + facts: [ + ["event.key", key === " " ? '" "' : key], + ["event.code", code], + ["keyCode (legacy)", keyCode === null ? "–" : String(keyCode)], + ["Linux evdev", `${evdev} (${evcode})`], + ["X11 keycode", String(evcode + 8)], + ], + })); +} + +function signalEntries(): Entry[] { + return SIGNALS.map(([n, name, action, description]) => ({ + id: `signal:${n}`, + table: "signals", + title: `${n} · ${name}`, + subtitle: description, + keys: [String(n), name.toLowerCase(), name.slice(3).toLowerCase()], + keywords: [action], + tint: action === "Core dump" ? "red" : action === "Stop" ? "orange" : action === "Ignore" ? "gray" : "yellow", + accessory: action, + copies: [ + ["Copy Name", name], + ["Copy kill Command", `kill -${name.slice(3)} <pid>`], + ["Copy Number", String(n)], + ], + facts: [ + ["Number", String(n)], + ["Name", name], + ["Default action", action], + ["Exit status", `${128 + n} when it kills a shell command`], + ], + })); +} + +function exitEntries(): Entry[] { + return EXIT_CODES.map(([code, name, description]) => ({ + id: `exit:${code}`, + table: "exit", + title: `${code} · ${name}`, + subtitle: description, + keys: [String(code), name.toLowerCase()], + keywords: ["exit", "status", "$?"], + tint: code === 0 ? "green" : code > 128 ? "orange" : "red", + copies: [["Copy Code", String(code)]], + facts: [ + ["Code", String(code)], + ["Meaning", name], + ...(code > 128 && code < 255 ? [["Signal", String(code - 128)] as [string, string]] : []), + ], + })); +} + +let cache: Entry[] | undefined; + +/** Every table but chmod, in display order. */ +export function entries(): Entry[] { + cache ??= [ + ...httpEntries(), + ...mimeEntries(), + ...portEntries(), + ...asciiEntries(), + ...entityEntries(), + ...keyEntries(), + ...signalEntries(), + ...exitEntries(), + ]; + return cache; +} + +/** Entries matching every word, exact keys first, then title prefixes. */ +export function search(all: Entry[], query: string): Entry[] { + const q = query.trim().toLowerCase(); + if (!q) return all; + const words = q.split(/\s+/); + const scored: [number, number, Entry][] = []; + all.forEach((e, i) => { + let score = 0; + if (e.keys.some((k) => k.toLowerCase() === q)) score = 100; + else if (query.trim().length === 1 && e.keys.includes(query.trim())) score = 100; + else if (e.title.toLowerCase().startsWith(q)) score = 50; + else { + const hay = `${e.title} ${e.subtitle} ${e.keys.join(" ")} ${e.keywords.join(" ")}`.toLowerCase(); + if (!words.every((w) => hay.includes(w))) return; + score = words.every((w) => e.title.toLowerCase().includes(w)) ? 20 : 10; + } + scored.push([score, i, e]); + }); + return scored.sort((a, b) => b[0] - a[0] || a[1] - b[1]).map((s) => s[2]); +} + +// --------------------------------------------------------------------------- +// chmod + +/** `755`, `0755`, `4755`, `rwxr-x---`, `-rwxr-xr-x`, `u=rwx,g=rx,o=` → mode bits; undefined otherwise. */ +export function parseMode(input: string): number | undefined { + const t = input.trim(); + if (/^(?:0o?)?[0-7]{3,4}$/i.test(t)) { + const digits = t.replace(/^0o/i, ""); + return parseInt(digits.length > 4 ? digits.slice(-4) : digits, 8); + } + const ls = t.match(/^[-dlcbps]?([r-][w-][xsS-])([r-][w-][xsS-])([r-][w-][xtT-])$/); + if (ls) { + let mode = 0; + ls.slice(1).forEach((triple, i) => { + const shift = (2 - i) * 3; + if (triple[0] === "r") mode |= 4 << shift; + if (triple[1] === "w") mode |= 2 << shift; + if ("xst".includes(triple[2])) mode |= 1 << shift; + if ("sStT".includes(triple[2])) mode |= [0o4000, 0o2000, 0o1000][i]; + }); + return mode; + } + if (/^[ugoa]*[=+-][rwxst]*(,[ugoa]*[=+-][rwxst]*)*$/.test(t) && /[=+]/.test(t)) { + let mode = 0; + for (const clause of t.split(",")) { + const [, who, op, perms] = clause.match(/^([ugoa]*)([=+-])([rwxst]*)$/)!; + const classes = !who || who.includes("a") ? "ugo" : who; + for (const c of classes) { + const shift = { u: 6, g: 3, o: 0 }[c as "u" | "g" | "o"]; + let bits = 0; + if (perms.includes("r")) bits |= 4 << shift; + if (perms.includes("w")) bits |= 2 << shift; + if (perms.includes("x")) bits |= 1 << shift; + if (perms.includes("s") && c === "u") bits |= 0o4000; + if (perms.includes("s") && c === "g") bits |= 0o2000; + if (perms.includes("t") && c === "o") bits |= 0o1000; + const mask = (7 << shift) | (c === "u" ? 0o4000 : c === "g" ? 0o2000 : 0o1000); + if (op === "=") mode = (mode & ~mask) | bits; + else if (op === "+") mode |= bits; + else mode &= ~bits; + } + } + return mode; + } + return undefined; +} + +export const octal = (mode: number) => mode.toString(8).padStart(mode > 0o777 ? 4 : 3, "0"); + +/** `rwxr-xr-x`, with s/S/t/T for the special bits. */ +export function symbolic(mode: number): string { + let out = ""; + for (let i = 0; i < 3; i++) { + const shift = (2 - i) * 3; + const special = mode & [0o4000, 0o2000, 0o1000][i]; + out += mode & (4 << shift) ? "r" : "-"; + out += mode & (2 << shift) ? "w" : "-"; + const x = mode & (1 << shift); + out += special ? (i === 2 ? (x ? "t" : "T") : x ? "s" : "S") : x ? "x" : "-"; + } + return out; +} + +/** `u=rwx,g=rx,o=rx` for `chmod`. */ +export function chmodSymbolic(mode: number): string { + return ["u", "g", "o"].map((c, i) => { + const shift = (2 - i) * 3; + let p = ""; + if (mode & (4 << shift)) p += "r"; + if (mode & (2 << shift)) p += "w"; + if (mode & (1 << shift)) p += "x"; + if (mode & [0o4000, 0o2000, 0o1000][i]) p += i === 2 ? "t" : "s"; + return `${c}=${p}`; + }).join(","); +} + +export function permissionWords(mode: number, who: "u" | "g" | "o"): string { + const shift = { u: 6, g: 3, o: 0 }[who]; + const words = [ + ...(mode & (4 << shift) ? ["read"] : []), + ...(mode & (2 << shift) ? ["write"] : []), + ...(mode & (1 << shift) ? ["execute"] : []), + ]; + return words.length ? words.join(", ") : "nothing"; +} + +export function specialBits(mode: number): string[] { + return [ + ...(mode & 0o4000 ? ["setuid"] : []), + ...(mode & 0o2000 ? ["setgid"] : []), + ...(mode & 0o1000 ? ["sticky"] : []), + ]; +} + +/** One entry for a mode: octal, symbolic, per class. */ +export function modeEntry(mode: number, note?: string): Entry { + const o = octal(mode), s = symbolic(mode); + const special = specialBits(mode); + return { + id: `chmod:${o}`, + table: "chmod", + title: `${o} · ${s}`, + subtitle: note ?? + `Owner ${permissionWords(mode, "u")} · group ${permissionWords(mode, "g")} · others ${ + permissionWords(mode, "o") + }`, + keys: [o, s], + keywords: [chmodSymbolic(mode), ...special], + tint: mode & 0o002 ? "red" : mode & 0o7000 ? "orange" : "green", + accessory: special.length ? special.join(", ") : undefined, + copies: [ + ["Copy Octal", o], + ["Copy chmod Command", `chmod ${o} `], + ["Copy Symbolic", s], + ["Copy chmod Symbolic Command", `chmod ${chmodSymbolic(mode)} `], + ], + facts: [ + ["Octal", o], + ["Symbolic", s], + ["ls -l", `-${s}`], + ["chmod", chmodSymbolic(mode)], + ["Owner", permissionWords(mode, "u")], + ["Group", permissionWords(mode, "g")], + ["Others", permissionWords(mode, "o")], + ...(special.length ? [["Special", special.join(", ")] as [string, string]] : []), + ["umask giving it", octal(~mode & 0o777)], + ], + }; +} + +export const commonModes = (): Entry[] => COMMON_MODES.map(([o, note]) => modeEntry(parseInt(o, 8), note)); diff --git a/extensions/dev-reference/lib_test.ts b/extensions/dev-reference/lib_test.ts new file mode 100644 index 0000000..c86a4f7 --- /dev/null +++ b/extensions/dev-reference/lib_test.ts @@ -0,0 +1,146 @@ +import { assertEquals } from "@std/assert"; +import { + chmodSymbolic, + commonModes, + entries, + httpClass, + modeEntry, + octal, + parseMode, + permissionWords, + search, + specialBits, + symbolic, +} from "./lib.ts"; +import { EXIT_CODES, HTTP_STATUS, KEYS, SIGNALS } from "./data.ts"; + +const titles = (query: string, table?: string) => + search(table ? entries().filter((e) => e.table === table) : entries(), query).map((e) => e.title); + +Deno.test("tables are complete and ids are unique", () => { + const all = entries(); + assertEquals(new Set(all.map((e) => e.id)).size, all.length); + assertEquals(all.filter((e) => e.table === "ascii").length, 128); + assertEquals(all.filter((e) => e.table === "keys").length, KEYS.length); + assertEquals(KEYS.filter((k) => k[2].startsWith("Key")).length, 26); + assertEquals(HTTP_STATUS.length > 55 && SIGNALS.length > 30 && EXIT_CODES.length > 25, true); +}); + +Deno.test("search ranks exact keys first", () => { + assertEquals(titles("404")[0], "404 Not Found"); + assertEquals(titles("png", "mime"), ["image/png"]); + assertEquals(titles(".webp")[0], "image/webp"); + assertEquals(titles("5432")[0], "5432 · PostgreSQL"); + assertEquals(titles("sigterm").slice(0, 2), ["15 · SIGTERM", "143 · SIGTERM"]); + assertEquals(titles("term", "signals")[0], "15 · SIGTERM"); + assertEquals(titles("nbsp")[0], "(no-break space)  "); + assertEquals(titles("é")[0], "é é"); + assertEquals(titles("keya")[0], "A"); + assertEquals(titles("KEY_ESC")[0], "Escape"); + assertEquals(titles("137", "exit"), ["137 · SIGKILL"]); + assertEquals(titles("esc", "ascii")[0], "ESC · Escape"); + assertEquals(titles("too many")[0], "429 Too Many Requests"); + assertEquals(titles("postgres mongo"), []); + assertEquals(titles("").length, entries().length); +}); + +Deno.test("entries carry the right facts", () => { + const a = entries().find((e) => e.id === "ascii:65")!; + assertEquals(a.facts, [ + ["Decimal", "65"], + ["Hex", "0x41"], + ["Octal", "0o101"], + ["Binary", "01000001"], + ["HTML", "A"], + ]); + const tab = entries().find((e) => e.id === "ascii:9")!; + assertEquals(tab.title, "HT · Horizontal tab"); + assertEquals(tab.facts.slice(-2), [["Escape", "\\t"], ["Keyboard", "Ctrl+I"]]); + const f12 = entries().find((e) => e.id === "key:F12")!; + assertEquals(f12.facts.slice(2), [["keyCode (legacy)", "123"], ["Linux evdev", "KEY_F12 (88)"], [ + "X11 keycode", + "96", + ]]); + const kp0 = entries().find((e) => e.id === "key:Numpad0")!; + assertEquals(kp0.facts[3], ["Linux evdev", "KEY_KP0 (82)"]); + const kill = entries().find((e) => e.id === "signal:9")!; + assertEquals(kill.copies[1], ["Copy kill Command", "kill -KILL <pid>"]); + const euro = entries().find((e) => e.id === "entity:euro")!; + assertEquals(euro.copies, [["Copy Entity", "€"], ["Copy Character", "€"], ["Copy Numeric Entity", "€"]]); + assertEquals([ + httpClass(101).name, + httpClass(204).tint, + httpClass(308).name, + httpClass(429).tint, + httpClass(503).tint, + ], [ + "Informational", + "green", + "Redirection", + "orange", + "red", + ]); +}); + +Deno.test("chmod: parsing every notation", () => { + assertEquals(parseMode("755"), 0o755); + assertEquals(parseMode("0644"), 0o644); + assertEquals(parseMode("0o600"), 0o600); + assertEquals(parseMode("4755"), 0o4755); + assertEquals(parseMode("rwxr-x---"), 0o750); + assertEquals(parseMode("-rw-r--r--"), 0o644); + assertEquals(parseMode("drwxrwxrwt"), 0o1777); + assertEquals(parseMode("rwsr-xr-x"), 0o4755); + assertEquals(parseMode("rwSr--r--"), 0o4644); + assertEquals(parseMode("u=rwx,g=rx,o="), 0o750); + assertEquals(parseMode("a=r"), 0o444); + assertEquals(parseMode("u=rw,go=r"), 0o644); + assertEquals(parseMode("u=rwx,g=rwxs,o=rx"), 0o2775); + assertEquals(parseMode("=rw,o-w"), 0o664); + assertEquals([parseMode("888"), parseMode("75"), parseMode("rwx"), parseMode("hello"), parseMode("u-x")], [ + undefined, + undefined, + undefined, + undefined, + undefined, + ]); +}); + +Deno.test("chmod: formatting", () => { + assertEquals([octal(0o755), octal(0o4755), octal(0o7)], ["755", "4755", "007"]); + assertEquals([ + symbolic(0o755), + symbolic(0o4755), + symbolic(0o4644), + symbolic(0o1777), + symbolic(0o1776), + symbolic(0o2750), + ], [ + "rwxr-xr-x", + "rwsr-xr-x", + "rwSr--r--", + "rwxrwxrwt", + "rwxrwxrwT", + "rwxr-s---", + ]); + assertEquals([chmodSymbolic(0o750), chmodSymbolic(0o2775), chmodSymbolic(0o1777)], [ + "u=rwx,g=rx,o=", + "u=rwx,g=rwxs,o=rx", + "u=rwx,g=rwx,o=rwxt", + ]); + assertEquals([permissionWords(0o750, "u"), permissionWords(0o750, "g"), permissionWords(0o750, "o")], [ + "read, write, execute", + "read, execute", + "nothing", + ]); + assertEquals(specialBits(0o7755), ["setuid", "setgid", "sticky"]); + const e = modeEntry(0o644); + assertEquals([e.title, e.tint], ["644 · rw-r--r--", "green"]); + assertEquals(e.facts.find((f) => f[0] === "umask giving it"), ["umask giving it", "133"]); + assertEquals(modeEntry(0o777).tint, "red"); + // Every symbolic form reads back as the same mode. + for (const m of commonModes()) { + const mode = parseInt(m.keys[0], 8); + assertEquals([parseMode(symbolic(mode)), parseMode(chmodSymbolic(mode))], [mode, mode], m.title); + } +}); diff --git a/extensions/git-repos/backslash.toml b/extensions/git-repos/backslash.toml new file mode 100644 index 0000000..ee256a6 --- /dev/null +++ b/extensions/git-repos/backslash.toml @@ -0,0 +1,85 @@ +[extension] +id = "git-repos" +title = "Git Repos" +description = "Your local git repositories with branch, changes, ahead/behind and last commit, plus fetch, pull and stash" +version = "1.0.0" +author = "Backslash" +icon = "bs-branch" +license = "MIT" + +[permissions] +env = ["HOME"] +# To suggest how to install git when it is missing. +read = ["/etc/os-release"] +# find scans the folders set in the preferences (which a fixed read list +# couldn't cover); git reads the repositories it finds. Then the editors and +# terminals the repositories open in. +run = [ + "git", + "find", + "code", + "codium", + "cursor", + "zed", + "zeditor", + "subl", + "idea", + "gnome-text-editor", + "kate", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "repos" +title = "Git Repositories" +description = "Repositories in your code folders: changes, ahead/behind, last commit; fetch, pull, stash, switch branch" +icon = "bs-branch" +keywords = [ + "git", + "repo", + "repository", + "branch", + "status", + "fetch", + "pull", + "stash", + "commit", + "dépôt", + "branche", + "projet", +] + +[[preferences]] +name = "folders" +title = "Folders" +description = "Where to look for repositories, separated by commas" +default = "~/Projects, ~/projects, ~/code, ~/Code, ~/src, ~/dev, ~/git, ~/repos, ~/workspace" + +[[preferences]] +name = "depth" +title = "Scan depth" +description = "How many folders deep to look below each folder (1 to 8)" +type = "number" +default = 3 + +[[preferences]] +name = "editor" +title = "Editor" +description = "Command opening a repository: code, codium, cursor, zed, subl, idea, kate, gnome-text-editor, or nvim, hx, vim, nano, micro (in the terminal)" +default = "code" + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed: kitty, alacritty, wezterm, foot, ghostty, ptyxis, kgx, gnome-terminal, konsole, xfce4-terminal, tilix, xterm" diff --git a/extensions/git-repos/commands/repos.ts b/extensions/git-repos/commands/repos.ts new file mode 100644 index 0000000..4b82410 --- /dev/null +++ b/extensions/git-repos/commands/repos.ts @@ -0,0 +1,403 @@ +import { + type Action, + action, + close, + type Color, + copy, + detail, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { git, GitMissing, HOME, inspect, pool, recentCommits, type Repo, run } from "../git.ts"; +import { + branchUrl, + changeSummary, + type Commit, + commitUrl, + findArgs, + installCommand, + parseBranches, + parseFolders, + relativeTime, + reposFromFind, + rootOf, + tilde, +} from "../lib.ts"; +import { launch, openTerminalAt, runInTerminal } from "../terminal.ts"; + +interface Prefs { + folders?: string; + depth?: number; + editor?: string; + terminal?: string; +} + +/** Editors that live in a terminal. */ +const TERMINAL_EDITORS = new Set(["nvim", "vim", "vi", "hx", "helix", "nano", "micro", "kak", "emacs"]); + +const EDITOR_NAMES: Record<string, string> = { + code: "VS Code", + codium: "VSCodium", + cursor: "Cursor", + zed: "Zed", + zeditor: "Zed", + subl: "Sublime Text", + idea: "IntelliJ IDEA", + "gnome-text-editor": "Text Editor", + kate: "Kate", + nvim: "Neovim", + hx: "Helix", +}; + +const name = (path: string) => path.split("/").pop() || path; + +function openInEditor(path: string, prefs: Prefs) { + const editor = prefs.editor?.trim() || "code"; + if (TERMINAL_EDITORS.has(editor)) { + runInTerminal([editor, "."], prefs.terminal || undefined, path); + return; + } + if (!launch(editor, [path])) throw new Error(`${editor} is not installed: set your editor in the preferences`); +} + +/** Runs a git command from an action: a toast with its outcome, then the list reloads. */ +function gitAction( + title: string, + repo: Repo, + args: string[], + done: string, + options: { icon?: string; shortcut?: string; section?: string; confirm?: string; destructive?: boolean } = {}, +): Action { + return action(title, async () => { + await toast(`${title}…`); + try { + const out = await git(repo.path, args); + const summary = out.split("\n").filter(Boolean).pop(); + await toast(done, { style: "success", message: summary && summary.length < 120 ? summary : undefined }); + } catch (e) { + await toast(`${title} failed`, { style: "failure", message: (e as Error).message }); + } + return refresh(); + }, options); +} + +function commitItem(c: Commit, repo: Repo, now: number): Item { + const refs = c.refs.map((r) => r.replace(/^HEAD -> /, "")).filter((r) => r !== "HEAD"); + const page = repo.remote?.page; + return { + id: c.hash, + title: c.subject, + subtitle: c.short, + icon: { source: Icons.commit, tint: refs.length ? "blue" : "gray" }, + keywords: [c.author, c.hash, ...refs], + accessories: [ + ...refs.slice(0, 2).map((r) => ({ + tag: r.replace(/^tag: /, ""), + color: (r.startsWith("tag: ") ? "yellow" : "blue") as Color, + })), + { avatar: c.author, tooltip: c.author }, + { text: relativeTime(c.time, now) }, + ], + detail: { + markdown: `## ${c.subject}${c.body ? `\n\n${c.body}` : ""}`, + metadata: [ + meta.text("Commit", c.short, { icon: Icons.commit }), + meta.person("Author", c.author), + meta.text("Email", c.email, { icon: Icons.mail }), + meta.text("Date", new Date(c.time * 1000).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + ...(refs.length ? [meta.tags("Refs", refs)] : []), + ], + }, + actions: [ + copy(c.hash, "Copy Commit Hash"), + ...(page ? [open(commitUrl(page, c.hash), "Open Commit in Browser", { shortcut: "<Ctrl>o" })] : []), + action("Show Changed Files", async () => { + const stat = await git(repo.path, ["show", "--stat", "--format=", c.hash]); + return detail({ + title: c.short, + markdown: `## ${c.subject}\n\n\`\`\`\n${stat || "No file changes."}\n\`\`\``, + metadata: [meta.text("Commit", c.short), meta.person("Author", c.author)], + actions: [copy(c.hash, "Copy Commit Hash")], + }); + }, { icon: Icons.file, shortcut: "<Ctrl>d" }), + copy(c.short, "Copy Short Hash", { section: "Copy" }), + copy(c.subject, "Copy Message", { icon: Icons.text, section: "Copy" }), + ], + }; +} + +function commitsList(repo: Repo): ListSpec { + return list({ + title: `${name(repo.path)} · Recent Commits`, + placeholder: "Filter commits by message, author, hash…", + status: (items) => `${items.length} recent commits on ${repo.status?.branch ?? "HEAD"}`, + empty: { title: "No commits yet", icon: Icons.commit }, + async items({ signal }) { + const now = Date.now(); + return (await recentCommits(repo.path, signal)).map((c) => commitItem(c, repo, now)); + }, + }); +} + +function repoActions(repo: Repo, prefs: Prefs): Action[] { + const s = repo.status; + const page = repo.remote?.page; + const editor = prefs.editor?.trim() || "code"; + return [ + action(`Open in ${EDITOR_NAMES[editor] ?? editor}`, () => { + openInEditor(repo.path, prefs); + return close(); + }, { icon: Icons.code }), + action("Open in Terminal", () => { + openTerminalAt(repo.path, prefs.terminal || undefined); + return close(); + }, { icon: Icons.terminal, shortcut: "<Ctrl>t" }), + action("Recent Commits", () => commitsList(repo), { icon: Icons.history, shortcut: "<Ctrl>l" }), + ...(page ? [open(page, "Open Remote in Browser", { shortcut: "<Ctrl>o" })] : []), + submenu("Switch Branch", async () => { + const branches = parseBranches( + await git(repo.path, ["branch", "--sort=-committerdate", "--format=%(refname:short)"]), + ); + return branches.filter((b) => b !== s?.branch).slice(0, 40).map((b) => + gitAction(b, repo, ["switch", b], `Switched to ${b}`, { icon: Icons.branch }) + ); + }, { icon: Icons.branch, shortcut: "<Ctrl>b", section: "Git" }), + gitAction("Fetch", repo, ["fetch", "--all", "--prune"], "Fetched", { + icon: Icons.download, + shortcut: "<Ctrl>f", + section: "Git", + }), + ...(s?.upstream + ? [ + gitAction("Pull (Fast-Forward Only)", repo, ["pull", "--ff-only"], "Pulled", { + icon: Icons.arrowDown, + shortcut: "<Ctrl>p", + section: "Git", + }), + ] + : []), + ...(repo.changes + ? [ + gitAction( + "Stash Changes", + repo, + [ + "stash", + "push", + "--include-untracked", + "-m", + `Backslash ${new Date().toISOString().slice(0, 16).replace("T", " ")}`, + ], + "Changes stashed", + { icon: Icons.archive, section: "Git" }, + ), + ] + : []), + ...(s?.stashes + ? [ + gitAction("Pop Latest Stash", repo, ["stash", "pop"], "Stash applied", { + icon: Icons.upload, + section: "Git", + confirm: "Apply and drop the latest stash?", + }), + ] + : []), + ...(page && s?.branch + ? [open(branchUrl(page, s.branch), "Open Branch in Browser", { icon: Icons.branch, section: "Open" })] + : []), + open(repo.path, "Open Folder", { icon: Icons.folder, section: "Open" }), + ...(repo.remote + ? [copy(repo.remote.url, "Copy Remote URL", { icon: Icons.link, shortcut: "<Ctrl><Shift>c", section: "Copy" })] + : []), + copy(repo.path, "Copy Path", { section: "Copy" }), + ...(s?.branch ? [copy(s.branch, "Copy Branch Name", { icon: Icons.branch, section: "Copy" })] : []), + ...(repo.last ? [copy(repo.last.hash, "Copy Last Commit Hash", { icon: Icons.commit, section: "Copy" })] : []), + ]; +} + +function repoItem(repo: Repo, roots: string[], prefs: Prefs, now: number, loading: boolean): Item { + const s = repo.status; + const root = rootOf(repo.path, roots); + const parent = repo.path.slice(0, repo.path.length - name(repo.path).length - 1); + const accessories: Item["accessories"] = []; + if (s) { + accessories.push({ icon: Icons.branch, text: s.branch ?? `detached ${s.oid?.slice(0, 7) ?? ""}`.trim() }); + if (s.conflicts) accessories.push({ tag: `${s.conflicts} conflicts`, color: "red" }); + else if (repo.changes) { + accessories.push({ tag: `${repo.changes} changed`, color: "orange", tooltip: changeSummary(s) }); + } + if (s.ahead || s.behind) { + accessories.push({ + tag: [s.ahead && `↑${s.ahead}`, s.behind && `↓${s.behind}`].filter(Boolean).join(" "), + color: s.behind ? "purple" : "blue", + tooltip: `${s.ahead} to push, ${s.behind} to pull`, + }); + } + if (repo.last) accessories.push({ text: relativeTime(repo.last.time, now), tooltip: "Last commit" }); + } else if (repo.error) accessories.push({ tag: "Error", color: "red", tooltip: repo.error }); + const tint: Color = !s + ? "gray" + : s.conflicts + ? "red" + : repo.changes + ? "orange" + : s.ahead || s.behind + ? "blue" + : "green"; + const metadata: Metadata[] = [meta.text("Folder", tilde(repo.path, HOME), { icon: Icons.folder })]; + if (s) { + metadata.push( + meta.text("Branch", s.branch ?? "Detached HEAD", { icon: Icons.branch }), + ...(s.upstream + ? [meta.text("Upstream", s.upstream, { icon: Icons.cloud })] + : [meta.text("Upstream", "None", { color: "gray" })]), + meta.text("Changes", changeSummary(s), { + icon: repo.changes ? Icons.dot : Icons.check, + color: s.conflicts ? "red" : repo.changes ? "orange" : "green", + }), + ...(s.stashes ? [meta.text("Stashes", String(s.stashes), { icon: Icons.archive })] : []), + ); + } + if (repo.last) { + metadata.push( + meta.separator(), + meta.text("Last commit", repo.last.short, { icon: Icons.commit }), + meta.person("Author", repo.last.author), + meta.text("Date", relativeTime(repo.last.time, now), { icon: Icons.clock }), + ); + } + if (repo.remote) { + metadata.push(meta.separator()); + metadata.push( + repo.remote.page + ? meta.link(`Remote (${repo.remote.name})`, repo.remote.page.replace(/^https?:\/\//, ""), repo.remote.page) + : meta.text(`Remote (${repo.remote.name})`, repo.remote.url), + ); + } + return { + id: repo.path, + title: name(repo.path), + subtitle: tilde(parent, HOME), + icon: { source: Icons.repository, tint }, + section: root ? tilde(root, HOME) : undefined, + keywords: [repo.path, s?.branch ?? "", repo.remote?.url ?? ""], + accessories, + detail: { + stats: s + ? [ + { label: "changed", value: repo.changes, color: repo.changes ? "orange" : "green" }, + { label: "to push", value: s.ahead, color: s.ahead ? "blue" : undefined }, + { label: "to pull", value: s.behind, color: s.behind ? "purple" : undefined }, + ] + : [], + markdown: repo.error + ? `**git failed:** ${repo.error}` + : repo.last + ? `**${repo.last.subject}**\n\n${repo.last.author} · ${relativeTime(repo.last.time, now)}` + : loading + ? "_Reading git status…_" + : "_No commits yet._", + metadata, + }, + actions: repoActions(repo, prefs), + }; +} + +async function setupItems(error: GitMissing): Promise<Item[]> { + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const cmd = installCommand(os); + return [{ + id: "setup", + title: error.message, + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { markdown: `## Install git\n\n\`\`\`\n${cmd}\n\`\`\`` }, + actions: [copy(cmd, "Copy Install Command"), open("https://git-scm.com/downloads/linux", "Open git Downloads")], + }]; +} + +let total = 0; + +export default list({ + placeholder: "Filter repositories by name, folder, branch, remote…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "changes", title: "Changes" }, + { value: "sync", title: "Ahead / Behind" }, + ], + }, + empty: { + title: "No repository here", + description: + "Set the folders to scan in Settings → Extensions → Git Repos (default: ~/Projects, ~/code, ~/src, ~/dev…)", + icon: Icons.repository, + }, + status: (items) => { + if (items[0]?.id === "setup") return "git is missing"; + if (items[0]?.id === "none") return "No repository found"; + return `${items.length === total ? total : `${items.length} of ${total}`} repositor${total === 1 ? "y" : "ies"}`; + }, + async *items({ preferences, signal, filter }) { + const prefs = preferences as Prefs; + const roots = parseFolders(prefs.folders, HOME); + const depth = Number(prefs.depth) || 3; + let paths: string[]; + try { + await run("git", ["--version"], { signal }); + // find exits with 1 when a folder doesn't exist; what it found is still good. + paths = reposFromFind(await run("find", findArgs(roots, depth), { signal, allowFailure: true })); + } catch (e) { + if (e instanceof GitMissing) { + yield await setupItems(e); + return; + } + throw e; + } + total = paths.length; + if (!paths.length) { + yield [{ + id: "none", + title: "No git repository found", + subtitle: `Looked ${depth} levels deep in ${roots.map((r) => tilde(r, HOME)).join(", ")}`, + icon: { source: Icons.folder, tint: "gray" }, + detail: { + markdown: + "Set **Folders** in Settings → Extensions → Git Repos → Configure: a list separated by commas, like `~/Projects, ~/work`.", + }, + actions: [open(roots[0] ?? HOME, "Open Folder")], + }]; + return; + } + const now = Date.now(); + const view = filter ?? "all"; + if (view === "all") yield paths.map((p) => repoItem({ path: p, changes: 0 }, roots, prefs, now, true)); + const repos = await pool(paths, 8, (p) => inspect(p, signal)); + const dirty = repos.filter((r) => r.changes > 0).length; + const unsynced = repos.filter((r) => r.status && (r.status.ahead || r.status.behind)).length; + updateCommandMetadata({ + subtitle: [dirty && `${dirty} with changes`, unsynced && `${unsynced} to sync`].filter(Boolean).join(" · ") || + null, + }).catch(() => {}); + const shown = repos.filter((r) => + view === "changes" + ? r.changes > 0 + : view === "sync" + ? !!r.status && (r.status.ahead > 0 || r.status.behind > 0) + : true + ); + total = repos.length; + yield shown.map((r) => repoItem(r, roots, prefs, now, false)); + }, +}); diff --git a/extensions/git-repos/git.ts b/extensions/git-repos/git.ts new file mode 100644 index 0000000..788ff9c --- /dev/null +++ b/extensions/git-repos/git.ts @@ -0,0 +1,89 @@ +// Running git and find. Never asks for credentials: a prompt would hang the launcher. + +import { type Commit, LOG_FORMAT, parseLog, parseStatus, pickRemote, remotePage, type Status } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +export class GitMissing extends Error {} + +const ENV = { GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes", LC_ALL: "C" }; + +/** Runs a program; throws its stderr on failure. `allowFailure` returns stdout anyway. */ +export async function run( + program: string, + args: string[], + options: { signal?: AbortSignal; allowFailure?: boolean; cwd?: string } = {}, +): Promise<string> { + let out: Deno.CommandOutput; + try { + out = await new Deno.Command(program, { + args, + stdin: "null", + stdout: "piped", + stderr: "piped", + signal: options.signal, + env: ENV, + }).output(); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new GitMissing(`${program} is not installed`); + throw e; + } + const text = (b: Uint8Array) => new TextDecoder().decode(b).replace(/^\n+/, "").trimEnd(); + if (!out.success && !options.allowFailure) { + const message = text(out.stderr) || text(out.stdout) || `${program} failed`; + throw new Error(message.replace(/^(fatal|error): /gm, "").split("\n").slice(0, 3).join(" ")); + } + return text(out.stdout); +} + +export const git = (dir: string, args: string[], signal?: AbortSignal) => run("git", ["-C", dir, ...args], { signal }); + +export interface Repo { + path: string; + status?: Status; + /** Files differing from HEAD. */ + changes: number; + last?: Commit; + remote?: { name: string; url: string; page?: string }; + error?: string; +} + +/** Branch, changes, stashes, last commit and remote: three git calls. */ +export async function inspect(path: string, signal?: AbortSignal): Promise<Repo> { + try { + const [statusOut, logOut, remotesOut] = await Promise.all([ + git(path, ["status", "--porcelain=v2", "--branch", "--show-stash"], signal), + git(path, ["log", "-1", `--format=${LOG_FORMAT}`], signal).catch(() => ""), + git(path, ["config", "--get-regexp", "^remote\\..*\\.url$"], signal).catch(() => ""), + ]); + const status = parseStatus(statusOut); + const remote = pickRemote(remotesOut); + return { + path, + status, + changes: status.staged + status.modified + status.untracked + status.conflicts, + last: parseLog(logOut)[0], + remote: remote ? { ...remote, page: remotePage(remote.url) } : undefined, + }; + } catch (e) { + if (e instanceof GitMissing) throw e; + return { path, changes: 0, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** Runs `fn` on every item, `limit` at a time. */ +export async function pool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { + const out: R[] = new Array(items.length); + let next = 0; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]); + } + })); + return out; +} + +export async function recentCommits(path: string, signal?: AbortSignal): Promise<Commit[]> { + return parseLog(await git(path, ["log", "-n", "30", `--format=${LOG_FORMAT}`], signal)); +} diff --git a/extensions/git-repos/lib.ts b/extensions/git-repos/lib.ts new file mode 100644 index 0000000..686129a --- /dev/null +++ b/extensions/git-repos/lib.ts @@ -0,0 +1,223 @@ +// Git output parsing, remote URLs and folder settings. Pure, tested in lib_test.ts. + +export interface Status { + /** Branch name; undefined when HEAD is detached. */ + branch?: string; + /** Commit hash, undefined in a repository without commits. */ + oid?: string; + upstream?: string; + ahead: number; + behind: number; + /** Files with staged changes. */ + staged: number; + /** Tracked files changed but not staged. */ + modified: number; + untracked: number; + conflicts: number; + stashes: number; +} + +/** `git status --porcelain=v2 --branch --show-stash`. */ +export function parseStatus(out: string): Status { + const s: Status = { ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0, conflicts: 0, stashes: 0 }; + for (const line of out.split("\n")) { + if (line.startsWith("# branch.oid ")) { + const oid = line.slice(13).trim(); + s.oid = oid === "(initial)" ? undefined : oid; + } else if (line.startsWith("# branch.head ")) { + const head = line.slice(14).trim(); + s.branch = head === "(detached)" ? undefined : head; + } else if (line.startsWith("# branch.upstream ")) s.upstream = line.slice(18).trim(); + else if (line.startsWith("# branch.ab ")) { + const m = line.match(/\+(\d+) -(\d+)/); + if (m) [s.ahead, s.behind] = [Number(m[1]), Number(m[2])]; + } else if (line.startsWith("# stash ")) s.stashes = Number(line.slice(8)) || 0; + else if (line.startsWith("1 ") || line.startsWith("2 ")) { + // `1 XY …`: X is the index (staged), Y the work tree. + const [x, y] = [line[2], line[3]]; + if (x !== ".") s.staged++; + if (y !== ".") s.modified++; + } else if (line.startsWith("u ")) s.conflicts++; + else if (line.startsWith("? ")) s.untracked++; + } + return s; +} + +/** Files that differ from HEAD in any way (staged, modified, untracked or conflicting). */ +export function changedFiles(out: string): number { + return out.split("\n").filter((l) => /^[12u?] /.test(l)).length; +} + +export const isDirty = (s: Status) => s.staged + s.modified + s.untracked + s.conflicts > 0; + +export interface Commit { + hash: string; + short: string; + subject: string; + author: string; + email: string; + /** Unix seconds. */ + time: number; + /** `HEAD -> main, origin/main, tag: v1.0`. */ + refs: string[]; + body: string; +} + +/** The `--format` that {@link parseLog} reads. */ +export const LOG_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%ct%x1f%D%x1f%b%x1e"; + +export function parseLog(out: string): Commit[] { + return out.split("\x1e").map((r) => r.replace(/^\n/, "")).filter((r) => r.trim()).map((record) => { + const [hash, short, subject, author, email, time, refs, body] = record.split("\x1f"); + return { + hash, + short, + subject, + author, + email, + time: Number(time), + refs: (refs ?? "").split(",").map((r) => r.trim()).filter(Boolean), + body: (body ?? "").trim(), + }; + }); +} + +/** `git config --get-regexp ^remote\..*\.url$` → the origin's URL, else the first remote's. */ +export function pickRemote(out: string): { name: string; url: string } | undefined { + const remotes = out.split("\n").map((l) => l.match(/^remote\.(.+)\.url\s+(.+)$/)).filter((m): m is RegExpMatchArray => + !!m + ) + .map((m) => ({ name: m[1], url: m[2].trim() })); + return remotes.find((r) => r.name === "origin") ?? remotes[0]; +} + +/** `git@github.com:me/app.git`, `ssh://git@host:2222/me/app`, `https://user@host/me/app.git` → the web page. */ +export function remotePage(remote: string): string | undefined { + const r = remote.trim(); + if (!/^[a-z+]+:\/\//i.test(r)) { + // scp-like `user@host:path`; anything else is a local path. + const scp = r.match(/^(?:[^@/]+@)?([^:/]+):(.+?)(?:\.git)?\/?$/); + return scp ? `https://${scp[1]}/${scp[2].replace(/^\/+/, "")}` : undefined; + } + const ssh = r.match(/^(?:ssh|git\+ssh):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/); + if (ssh) return `https://${ssh[1]}/${ssh[2]}`; + const git = r.match(/^git:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/); + if (git) return `https://${git[1]}/${git[2]}`; + const https = r.match(/^(https?):\/\/(?:[^@/]+@)?(.+?)(?:\.git)?\/?$/); + return https ? `${https[1]}://${https[2]}` : undefined; +} + +/** A commit's page on the usual forges. */ +export function commitUrl(page: string, hash: string): string { + if (/bitbucket\.org/.test(page)) return `${page}/commits/${hash}`; + if (/gitlab/.test(page)) return `${page}/-/commit/${hash}`; + return `${page}/commit/${hash}`; +} + +/** A branch's page on the usual forges. */ +export function branchUrl(page: string, branch: string): string { + const b = branch.split("/").map(encodeURIComponent).join("/"); + if (/bitbucket\.org/.test(page)) return `${page}/src/${b}`; + if (/gitlab/.test(page)) return `${page}/-/tree/${b}`; + if (/codeberg\.org|gitea|forgejo/.test(page)) return `${page}/src/branch/${b}`; + return `${page}/tree/${b}`; +} + +export const DEFAULT_FOLDERS = "~/Projects, ~/projects, ~/code, ~/Code, ~/src, ~/dev, ~/git, ~/repos, ~/workspace"; + +/** The folders preference: commas, colons or new lines; `~` expanded; duplicates dropped. */ +export function parseFolders(pref: string | undefined, home: string): string[] { + const raw = (pref?.trim() || DEFAULT_FOLDERS).split(/[,:\n]/).map((f) => f.trim()).filter(Boolean); + const expanded = raw.map((f) => f.replace(/^~(?=\/|$)/, home).replace(/\/+$/, "") || "/"); + return [...new Set(expanded)].filter((f) => f.startsWith("/")); +} + +/** Directories never worth descending into. */ +const SKIP = ["node_modules", "vendor", "target", ".venv", "venv", "__pycache__", "build", "dist", ".cache"]; + +/** `find` arguments listing the `.git` entries at most `depth` folders below the roots. */ +export function findArgs(roots: string[], depth: number): string[] { + const skip = SKIP.flatMap((name, i) => [...(i ? ["-o"] : []), "-name", name]); + return [ + ...roots, + "-mindepth", + "1", + "-maxdepth", + String(Math.max(1, Math.min(8, Math.round(depth))) + 1), + "(", + ...skip, + "-o", + "(", + "-name", + ".*", + "!", + "-name", + ".git", + ")", + ")", + "-prune", + "-o", + "-name", + ".git", + "-print", + "-prune", + ]; +} + +/** `find` output → repository folders, sorted, without nested duplicates of the same path. */ +export function reposFromFind(out: string): string[] { + return [ + ...new Set(out.split("\n").map((l) => l.trim()).filter((l) => l.endsWith("/.git")).map((l) => l.slice(0, -5))), + ] + .sort((a, b) => a.localeCompare(b)); +} + +/** The configured folder a repository was found in. */ +export const rootOf = (path: string, roots: string[]) => + roots.filter((r) => path === r || path.startsWith(`${r}/`)).sort((a, b) => b.length - a.length)[0]; + +export const tilde = (path: string, home: string) => + home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; + +/** "just now", "5 min ago", "3 h ago", "yesterday", "4 days ago", "2 weeks ago", "3 months ago", "2 years ago". */ +export function relativeTime(unixSeconds: number, now = Date.now()): string { + const s = Math.max(0, Math.round(now / 1000 - unixSeconds)); + 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`; + const d = Math.floor(s / 86400); + if (d === 1) return "yesterday"; + if (d < 14) return `${d} days ago`; + if (d < 60) return `${Math.floor(d / 7)} weeks ago`; + if (d < 730) return `${Math.floor(d / 30)} months ago`; + return `${Math.floor(d / 365)} years ago`; +} + +/** "2 staged, 3 modified, 1 untracked". */ +export function changeSummary(s: Status): string { + const parts = [ + s.conflicts && `${s.conflicts} conflicted`, + s.staged && `${s.staged} staged`, + s.modified && `${s.modified} modified`, + s.untracked && `${s.untracked} untracked`, + ].filter(Boolean); + return parts.length ? parts.join(", ") : "Clean"; +} + +/** Branch names from `git branch --format=%(refname:short)`. */ +export const parseBranches = (out: string) => + out.split("\n").map((b) => b.trim()).filter((b) => b && !b.startsWith("(")); + +/** `/etc/os-release` → how to install git. */ +export function installCommand(osRelease: string): string { + 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 "sudo pacman -S git"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "sudo apt install git"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "sudo dnf install git"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "sudo zypper install git"; + if (ids.includes("alpine")) return "sudo apk add git"; + if (ids.includes("void")) return "sudo xbps-install git"; + if (ids.includes("nixos")) return "nix-env -iA nixos.git"; + return "Install git with your package manager"; +} diff --git a/extensions/git-repos/lib_test.ts b/extensions/git-repos/lib_test.ts new file mode 100644 index 0000000..cc7790f --- /dev/null +++ b/extensions/git-repos/lib_test.ts @@ -0,0 +1,178 @@ +import { assertEquals } from "@std/assert"; +import { + branchUrl, + changedFiles, + changeSummary, + commitUrl, + findArgs, + installCommand, + isDirty, + parseBranches, + parseFolders, + parseLog, + parseStatus, + pickRemote, + relativeTime, + remotePage, + reposFromFind, + rootOf, + tilde, +} from "./lib.ts"; +import { shellArgs, shellLine, terminalArgs } from "./terminal.ts"; + +// Real `git status --porcelain=v2 --branch --show-stash` output. +const DIRTY = `# branch.oid 667a1968df5af67e15fca1434f9ddfb3f3763836 +# branch.head dev +# stash 1 +1 .M N... 100644 100644 100644 78981922613b2afb6025042ff6bd878ac1994e85 78981922613b2afb6025042ff6bd878ac1994e85 a +1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 61780798228d17af2d34fce4cfbdf35556832472 b +2 R. N... 100644 100644 100644 d670460b4b4aece5915caf5c68d12f560a9fe3e4 d670460b4b4aece5915caf5c68d12f560a9fe3e4 R100 new.txt\told.txt +1 MM N... 100644 100644 100644 a1 b2 c.txt +u UU N... 100644 100644 100644 100644 a1 b2 c3 conflict.txt +? untracked +? notes/todo.md`; + +const SYNCED = `# branch.oid 90a3b337c2cca1109472e55837eef606bfbaa5ad +# branch.head main +# branch.upstream origin/main +# branch.ab +1 -3`; + +Deno.test("porcelain v2 status", () => { + assertEquals(parseStatus(DIRTY), { + oid: "667a1968df5af67e15fca1434f9ddfb3f3763836", + branch: "dev", + ahead: 0, + behind: 0, + staged: 3, + modified: 2, + untracked: 2, + conflicts: 1, + stashes: 1, + }); + assertEquals(changedFiles(DIRTY), 7); + const s = parseStatus(SYNCED); + assertEquals([s.branch, s.upstream, s.ahead, s.behind, isDirty(s)], ["main", "origin/main", 1, 3, false]); + const detached = parseStatus("# branch.oid d787b58\n# branch.head (detached)"); + assertEquals([detached.branch, detached.oid], [undefined, "d787b58"]); + const empty = parseStatus("# branch.oid (initial)\n# branch.head main\n? README.md"); + assertEquals([empty.oid, empty.branch, empty.untracked], [undefined, "main", 1]); + assertEquals(changeSummary(parseStatus(DIRTY)), "1 conflicted, 3 staged, 2 modified, 2 untracked"); + assertEquals(changeSummary(s), "Clean"); +}); + +Deno.test("log records", () => { + const out = + "90a3b337c2cca1109472e55837eef606bfbaa5ad\x1f90a3b33\x1fSecond: not pushed\x1fAda Lovelace\x1fada@example.com\x1f1793183949\x1fHEAD -> main, tag: v1.0, origin/main\x1fLonger body\n\nSigned-off-by: Ada\n\x1e\n" + + "b3511c4095f9d48b1fcc86a3f0e49d1fecf0f0be\x1fb3511c4\x1fFirst\x1fAda Lovelace\x1fada@example.com\x1f1793180000\x1f\x1f\x1e"; + const [a, b] = parseLog(out); + assertEquals([a.short, a.subject, a.author, a.time, a.refs], [ + "90a3b33", + "Second: not pushed", + "Ada Lovelace", + 1793183949, + ["HEAD -> main", "tag: v1.0", "origin/main"], + ]); + assertEquals(a.body, "Longer body\n\nSigned-off-by: Ada"); + assertEquals([b.refs, b.body], [[], ""]); + assertEquals(parseLog(""), []); +}); + +Deno.test("remotes and their web pages", () => { + const remotes = + "remote.upstream.url git@codeberg.org:ada/synced.git\nremote.origin.url https://github.com/ada/app.git"; + assertEquals(pickRemote(remotes), { name: "origin", url: "https://github.com/ada/app.git" }); + assertEquals(pickRemote("remote.fork.url git@x.org:a/b"), { name: "fork", url: "git@x.org:a/b" }); + assertEquals(pickRemote(""), undefined); + const cases: [string, string | undefined][] = [ + ["git@github.com:backslash-app/plugins.git", "https://github.com/backslash-app/plugins"], + ["git@codeberg.org:ada/notes", "https://codeberg.org/ada/notes"], + ["ssh://git@gitlab.example.com:2222/team/api.git", "https://gitlab.example.com/team/api"], + ["git+ssh://git@github.com/a/b.git", "https://github.com/a/b"], + ["https://user:token@gitlab.com/org/api.git", "https://gitlab.com/org/api"], + ["http://git.local/repo/", "http://git.local/repo"], + ["git://git.kernel.org/pub/scm/git/git.git", "https://git.kernel.org/pub/scm/git/git"], + ["/srv/git/project.git", undefined], + ["../other", undefined], + ]; + for (const [remote, page] of cases) assertEquals(remotePage(remote), page, remote); + assertEquals(commitUrl("https://github.com/a/b", "abc"), "https://github.com/a/b/commit/abc"); + assertEquals(commitUrl("https://gitlab.com/a/b", "abc"), "https://gitlab.com/a/b/-/commit/abc"); + assertEquals(commitUrl("https://bitbucket.org/a/b", "abc"), "https://bitbucket.org/a/b/commits/abc"); + assertEquals(branchUrl("https://codeberg.org/a/b", "fix/x"), "https://codeberg.org/a/b/src/branch/fix/x"); + assertEquals(branchUrl("https://github.com/a/b", "feat/ü"), "https://github.com/a/b/tree/feat/%C3%BC"); +}); + +Deno.test("folders and the find command", () => { + assertEquals(parseFolders("~/Projects, ~/work/:/opt/src\n~/Projects", "/home/ada"), [ + "/home/ada/Projects", + "/home/ada/work", + "/opt/src", + ]); + assertEquals(parseFolders("", "/home/ada").slice(0, 3), [ + "/home/ada/Projects", + "/home/ada/projects", + "/home/ada/code", + ]); + assertEquals(parseFolders("relative, ~", "/home/ada"), ["/home/ada"]); + const args = findArgs(["/home/ada/code"], 3); + assertEquals(args.slice(0, 5), ["/home/ada/code", "-mindepth", "1", "-maxdepth", "4"]); + assertEquals(args.slice(-6), ["-prune", "-o", "-name", ".git", "-print", "-prune"]); + assertEquals(findArgs(["/x"], 50)[4], "9"); + assertEquals( + reposFromFind( + "/home/ada/code/b/.git\n/home/ada/code/a/.git\n/home/ada/code/a/.git\nfind: ‘/home/ada/src’: No such file\n", + ), + ["/home/ada/code/a", "/home/ada/code/b"], + ); + assertEquals( + rootOf("/home/ada/code/org/api", ["/home/ada/code", "/home/ada/code/org", "/home/ada/src"]), + "/home/ada/code/org", + ); + assertEquals(rootOf("/elsewhere", ["/home/ada/code"]), undefined); + assertEquals([tilde("/home/ada/code", "/home/ada"), tilde("/home/adam", "/home/ada")], ["~/code", "/home/adam"]); +}); + +Deno.test("relative times", () => { + const now = 1_800_000_000_000; + const ago = (s: number) => relativeTime(now / 1000 - s, now); + assertEquals([ + ago(10), + ago(300), + ago(7200), + ago(90_000), + ago(4 * 86400), + ago(20 * 86400), + ago(90 * 86400), + ago(800 * 86400), + ], [ + "just now", + "5 min ago", + "2 h ago", + "yesterday", + "4 days ago", + "2 weeks ago", + "3 months ago", + "2 years ago", + ]); +}); + +Deno.test("branches and install hints", () => { + assertEquals(parseBranches("main\nfeature/x\n(HEAD detached at d787b58)\n\n"), ["main", "feature/x"]); + assertEquals(installCommand("ID=fedora\nVERSION_ID=41"), "sudo dnf install git"); + assertEquals(installCommand('ID=pop\nID_LIKE="ubuntu debian"'), "sudo apt install git"); + assertEquals(installCommand("ID=endeavouros\nID_LIKE=arch"), "sudo pacman -S git"); + assertEquals(installCommand('ID="opensuse-tumbleweed"'), "sudo zypper install git"); + assertEquals(installCommand(""), "Install git with your package manager"); +}); + +Deno.test("terminal command lines", () => { + assertEquals(terminalArgs("kitty", ["nvim", "."]), ["nvim", "."]); + assertEquals(terminalArgs("gnome-terminal", ["nvim", "."]), ["--", "nvim", "."]); + assertEquals(terminalArgs("tilix", ["nvim", "my file"]), ["-e", "nvim 'my file'"]); + assertEquals(shellLine(["git", "log", "it's"]), "git log 'it'\\''s'"); + assertEquals(shellArgs("wezterm", "/r"), ["start", "--cwd", "/r"]); + assertEquals(shellArgs("kitty", "/r"), ["--directory", "/r"]); + assertEquals(shellArgs("konsole", "/r"), ["--workdir", "/r"]); + assertEquals(shellArgs("gnome-terminal", "/r"), ["--working-directory=/r"]); + assertEquals(shellArgs("alacritty", "/r"), []); +}); diff --git a/extensions/git-repos/terminal.ts b/extensions/git-repos/terminal.ts new file mode 100644 index 0000000..9204974 --- /dev/null +++ b/extensions/git-repos/terminal.ts @@ -0,0 +1,110 @@ +// Running a command in the user's terminal emulator (copied from the SSH Hosts extension, plus a working directory). + +/** 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]; + } +} + +/** The arguments that open `terminal` on a shell in `dir` (most terminals start where they are launched). */ +export function shellArgs(terminal: string, dir: string): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--cwd", dir]; + case "kitty": + return ["--directory", dir]; + case "ptyxis": + return ["--new-window", "--working-directory", dir]; + case "kgx": + case "gnome-terminal": + case "xfce4-terminal": + case "tilix": + return [`--working-directory=${dir}`]; + case "konsole": + return ["--workdir", dir]; + case "foot": + return [`--working-directory=${dir}`]; + default: // alacritty, ghostty, xterm: the launch directory + return []; + } +} + +/** Opens a shell in `dir` in a new terminal window; returns the terminal used. */ +export function openTerminalAt(dir: string, preferred?: string): string { + return spawnFirst(preferred, (term) => shellArgs(term, dir), dir); +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string, cwd?: string): string { + return spawnFirst(preferred, (term) => terminalArgs(term, argv), cwd); +} + +function spawnFirst(preferred: string | undefined, args: (term: string) => string[], cwd?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: args(term), + 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; + } +} diff --git a/extensions/http-client/backslash.toml b/extensions/http-client/backslash.toml new file mode 100644 index 0000000..16d74c3 --- /dev/null +++ b/extensions/http-client/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "http-client" +title = "HTTP Client" +description = "Send HTTP requests from the launcher and read the status, timing, headers and formatted body" +version = "1.0.0" +author = "Backslash" +icon = "bs-globe" +license = "MIT" + +[permissions] +# Any host: the whole point is to call the URL the user types (APIs, local +# servers, staging hosts), which can't be known in advance. +net = ["*"] + +[[commands]] +name = "request" +title = "Send HTTP Request" +description = "Type GET https://…, a URL or a curl command; saved requests and history included" +icon = "bs-globe" +keywords = [ + "http", + "https", + "request", + "api", + "rest", + "curl", + "get", + "post", + "fetch", + "postman", + "insomnia", + "requête", + "envoyer", +] + +[[commands]] +name = "saved" +title = "Saved HTTP Requests" +description = "Your saved requests with their method, URL, headers and body, and the recent history" +icon = "bs-bookmark" +keywords = ["http", "request", "saved", "history", "api", "postman", "requêtes enregistrées", "historique"] diff --git a/extensions/http-client/commands/request.ts b/extensions/http-client/commands/request.ts new file mode 100644 index 0000000..2847205 --- /dev/null +++ b/extensions/http-client/commands/request.ts @@ -0,0 +1,103 @@ +import { action, close, copy, Icons, type Item, list } from "@backslash/api"; +import { formatBytes, hasSecret, parseInput, type Request, shortUrl, toCurl, validate } from "../lib.ts"; +import { clearHistory, historyItem, loadHistory, loadSaved, requestForm, savedItem, sendAndShow } from "../views.ts"; + +const matches = (q: string, ...fields: string[]) => { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const hay = fields.join(" ").toLowerCase(); + return words.every((w) => hay.includes(w)); +}; + +function requestItem(r: Request): Item { + let problem: string | undefined; + try { + validate(r); + } catch (e) { + problem = (e as Error).message; + } + if (problem) { + return { + id: "invalid", + title: problem, + subtitle: `${r.method} ${r.url}`, + icon: { source: Icons.alert, tint: "red" }, + section: "Request", + }; + } + return { + id: "request", + title: `${r.method} ${shortUrl(r.url)}`, + subtitle: r.body ? `with a ${formatBytes(new TextEncoder().encode(r.body).length)} body` : "Enter to send", + icon: { source: Icons.arrowRight, tint: "blue" }, + section: "Request", + accessories: r.headers.length ? [{ text: `${r.headers.length} header${r.headers.length > 1 ? "s" : ""}` }] : [], + detail: { + markdown: `\`\`\`sh\n${toCurl(r)}\n\`\`\`${r.body ? `\n\n**Body**\n\n\`\`\`\n${r.body}\n\`\`\`` : ""}`, + }, + actions: [ + action("Send Request", (ctx) => sendAndShow(r, ctx), { icon: Icons.arrowRight }), + action("Save Request…", () => requestForm({ request: r }), { icon: Icons.bookmark, shortcut: "<Ctrl>s" }), + copy(toCurl(r), "Copy as curl", { + concealed: hasSecret(r), + icon: Icons.terminal, + shortcut: "<Ctrl><Shift>c", + section: "Copy", + }), + action("Edit Headers and Body…", () => requestForm({ request: r }), { icon: Icons.text, shortcut: "<Ctrl>e" }), + ], + }; +} + +export default list({ + placeholder: "GET https://api.example.com/users, a URL, or paste a curl command…", + empty: { + title: "Type a request", + description: "A URL, METHOD URL [body], or a curl command. Saved requests and history show up here too.", + icon: Icons.globe, + }, + actions: [action("New Saved Request…", () => requestForm(), { icon: Icons.plus, shortcut: "<Ctrl>n" })], + status: (items) => { + const saved = items.filter((i) => i.section === "Saved").length; + const history = items.filter((i) => i.section === "History").length; + return [saved && `${saved} saved`, history && `${history} in history`].filter(Boolean).join(" · ") || + "Responses open in a page with status, headers and body"; + }, + async search(query) { + const items: Item[] = []; + let parsed: Request | undefined; + try { + parsed = parseInput(query); + } catch (e) { + items.push({ + id: "invalid", + title: (e as Error).message, + subtitle: "Can't read this request", + icon: { source: Icons.alert, tint: "red" }, + section: "Request", + }); + } + if (parsed) items.push(requestItem(parsed)); + const filter = parsed ? "" : query; + const [saved, history] = await Promise.all([loadSaved(), loadHistory()]); + items.push( + ...saved.filter((s) => matches(filter, s.name, s.method, s.url)).map((s) => savedItem(s, "close")), + ...history.filter((h) => matches(filter, h.method, h.url, String(h.status))).map((h) => { + const item = historyItem(h, "close"); + item.actions = [ + ...item.actions!, + action("Clear History", async () => { + await clearHistory(); + return close(); + }, { + icon: Icons.trash, + destructive: true, + confirm: "Forget every request in the history?", + section: "Danger", + }), + ]; + return item; + }), + ); + return items; + }, +}); diff --git a/extensions/http-client/commands/saved.ts b/extensions/http-client/commands/saved.ts new file mode 100644 index 0000000..b33fc10 --- /dev/null +++ b/extensions/http-client/commands/saved.ts @@ -0,0 +1,27 @@ +import { action, Icons, list } from "@backslash/api"; +import { clearHistory, historyItem, live, loadHistory, loadSaved, requestForm, savedItem } from "../views.ts"; + +export default list({ + placeholder: "Filter saved requests and history…", + empty: { + title: "No saved requests", + description: "Press Ctrl+N to save one, or send a request from the Send HTTP Request command.", + icon: Icons.bookmark, + }, + actions: [ + action("New Saved Request…", () => requestForm({ after: "pop" }), { icon: Icons.plus, shortcut: "<Ctrl>n" }), + action("Clear History", async () => { + await clearHistory(); + }, { icon: Icons.trash, destructive: true, confirm: "Forget every request in the history?", section: "Danger" }), + ], + status: (items) => { + const saved = items.filter((i) => i.section === "Saved").length; + return `${saved} saved · ${items.length - saved} in history`; + }, + // Reloads after a save, an edit or a deletion made from this list. + items: ({ signal }) => + live(async () => { + const [saved, history] = await Promise.all([loadSaved(), loadHistory()]); + return [...saved.map((s) => savedItem(s, "pop")), ...history.map((h) => historyItem(h, "pop"))]; + }, signal), +}); diff --git a/extensions/http-client/lib.ts b/extensions/http-client/lib.ts new file mode 100644 index 0000000..1776b2e --- /dev/null +++ b/extensions/http-client/lib.ts @@ -0,0 +1,404 @@ +// HTTP requests: parsing what the user types (or a pasted curl command), sending, formatting. Pure, tested in lib_test.ts. + +export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +export interface Request { + method: string; + url: string; + headers: [string, string][]; + body?: string; +} + +export interface SavedRequest extends Request { + id: string; + name: string; +} + +export interface HistoryEntry extends Request { + status: number; + ms: number; + /** Unix ms. */ + at: number; +} + +export interface Response { + status: number; + statusText: string; + ms: number; + size: number; + url: string; + redirected: boolean; + headers: [string, string][]; + contentType: string; + /** Undefined for binary bodies. */ + text?: string; +} + +/** Splits a command line like a POSIX shell: quotes, backslashes, line continuations. */ +export function shellWords(line: string): string[] { + const words: string[] = []; + let word = ""; + let inWord = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === "\\" && line[i + 1] === "\n") { + i++; + } else if (/\s/.test(c)) { + if (inWord) words.push(word); + word = ""; + inWord = false; + } else if (c === "'") { + const end = line.indexOf("'", i + 1); + if (end < 0) throw new Error("Unterminated ' quote"); + word += line.slice(i + 1, end); + i = end; + inWord = true; + } else if (c === '"') { + let j = i + 1; + for (; j < line.length && line[j] !== '"'; j++) { + if (line[j] === "\\" && '"\\$`\n'.includes(line[j + 1])) j++; + word += line[j]; + } + if (j >= line.length) throw new Error('Unterminated " quote'); + i = j; + inWord = true; + } else if (c === "$" && line[i + 1] === "'") { + // $'…' with \n, \t and \' escapes. + let j = i + 2; + for (; j < line.length && line[j] !== "'"; j++) { + if (line[j] === "\\") { + const e = line[++j]; + word += ({ n: "\n", t: "\t", r: "\r", "'": "'", "\\": "\\" } as Record<string, string>)[e] ?? `\\${e}`; + } else word += line[j]; + } + i = j; + inWord = true; + } else if (c === "\\") { + word += line[++i] ?? ""; + inWord = true; + } else { + word += c; + inWord = true; + } + } + if (inWord) words.push(word); + return words; +} + +/** `example.com/x` → `https://example.com/x`; `localhost:8000` → `http://localhost:8000`. */ +export function normalizeUrl(input: string): string { + const t = input.trim(); + if (/^https?:\/\//i.test(t)) return t; + if (/^(localhost|127\.|0\.0\.0\.0|\[::1\]|10\.|192\.168\.|[\w-]+\.local\b)/i.test(t)) return `http://${t}`; + return `https://${t}`; +} + +export function looksLikeUrl(word: string): boolean { + return /^https?:\/\//i.test(word) || + /^(localhost|[\w-]+(\.[\w-]+)+|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i.test(word); +} + +export function parseHeader(line: string): [string, string] | undefined { + const i = line.indexOf(":"); + if (i <= 0) return undefined; + return [line.slice(0, i).trim(), line.slice(i + 1).trim()]; +} + +/** `Key: value` lines of a text area. */ +export function parseHeaders(text: string): [string, string][] { + return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => { + const h = parseHeader(l); + if (!h) throw new Error(`“${l}” is not a header: write Name: value`); + return h; + }); +} + +export const headerText = (headers: [string, string][]) => headers.map(([k, v]) => `${k}: ${v}`).join("\n"); + +const header = (headers: [string, string][], name: string) => + headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1]; + +/** The usual curl flags; others are ignored when harmless, refused otherwise. */ +export function parseCurl(words: string[]): Request { + let method: string | undefined; + let url: string | undefined; + const headers: [string, string][] = []; + const data: string[] = []; + let head = false; + let get = false; + const IGNORED = new Set([ + "-L", + "--location", + "-s", + "--silent", + "-S", + "--show-error", + "-v", + "--verbose", + "-i", + "--include", + "-k", + "--insecure", + "--compressed", + "-f", + "--fail", + "-#", + "--http1.1", + "--http2", + ]); + const WITH_VALUE = new Set([ + "-o", + "--output", + "-m", + "--max-time", + "--connect-timeout", + "-w", + "--write-out", + "--retry", + ]); + // `-fsSL` is `-f -s -S -L`. + words = words.flatMap((w) => /^-[sSLkivfIG#]{2,}$/.test(w) ? [...w.slice(1)].map((c) => `-${c}`) : [w]); + for (let i = 1; i < words.length; i++) { + const w = words[i]; + const value = () => { + const v = words[++i]; + if (v === undefined) throw new Error(`${w} needs a value`); + return v; + }; + if (w === "-X" || w === "--request") method = value().toUpperCase(); + else if (w.startsWith("-X") && w.length > 2) method = w.slice(2).toUpperCase(); + else if (w === "-H" || w === "--header") { + const h = parseHeader(value()); + if (h) headers.push(h); + } else if (["-d", "--data", "--data-raw", "--data-binary", "--data-ascii"].includes(w)) { + const v = value(); + if (v.startsWith("@") && w !== "--data-raw") throw new Error("Bodies read from files (@file) aren't supported"); + data.push(v); + } else if (w === "--data-urlencode") { + const v = value(); + const eq = v.indexOf("="); + data.push(eq >= 0 ? `${v.slice(0, eq + 1)}${encodeURIComponent(v.slice(eq + 1))}` : encodeURIComponent(v)); + } else if (w === "--json") { + data.push(value()); + if (!header(headers, "content-type")) headers.push(["Content-Type", "application/json"]); + if (!header(headers, "accept")) headers.push(["Accept", "application/json"]); + } else if (w === "-u" || w === "--user") headers.push(["Authorization", `Basic ${btoa(value())}`]); + else if (w === "-A" || w === "--user-agent") headers.push(["User-Agent", value()]); + else if (w === "-e" || w === "--referer") headers.push(["Referer", value()]); + else if (w === "-b" || w === "--cookie") headers.push(["Cookie", value()]); + else if (w === "-I" || w === "--head") head = true; + else if (w === "-G" || w === "--get") get = true; + else if (w === "--url") url = value(); + else if (IGNORED.has(w)) continue; + else if (WITH_VALUE.has(w)) value(); + else if (w.startsWith("-")) throw new Error(`curl option ${w} isn't supported here`); + else url = w; + } + if (!url) throw new Error("No URL in this curl command"); + let body = data.length ? data.join("&") : undefined; + url = normalizeUrl(url); + if (get && body) { + url += (url.includes("?") ? "&" : "?") + body; + body = undefined; + } + if (body && !header(headers, "content-type")) headers.push(["Content-Type", "application/x-www-form-urlencoded"]); + return { method: method ?? (head ? "HEAD" : body ? "POST" : "GET"), url, headers, body }; +} + +/** + * What the user typed: `GET https://…`, `POST api.example.com/items {"a":1}`, + * a bare URL, or a pasted curl command. Undefined when it isn't a request. + */ +export function parseInput(input: string): Request | undefined { + const t = input.trim(); + if (!t) return undefined; + if (/^curl\s/.test(t)) return parseCurl(shellWords(t)); + const m = t.match(/^(?:([A-Za-z]+)\s+)?(\S+)(?:\s+([\s\S]*))?$/); + if (!m) return undefined; + const method = m[1]?.toUpperCase(); + if ((method && !METHODS.includes(method)) || !looksLikeUrl(m[2])) return undefined; + const body = m[3]?.trim() || undefined; + const headers: [string, string][] = []; + if (body && /^[[{]/.test(body)) headers.push(["Content-Type", "application/json"]); + return { method: method ?? (body ? "POST" : "GET"), url: normalizeUrl(m[2]), headers, body }; +} + +const quote = (s: string) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`; + +/** A curl command doing the same request. */ +export function toCurl(r: Request): string { + const parts = ["curl"]; + if (r.method === "HEAD") parts.push("-I"); + else if (r.method !== "GET" && !(r.method === "POST" && r.body)) parts.push("-X", r.method); + parts.push(quote(r.url)); + for (const [k, v] of r.headers) parts.push("-H", quote(`${k}: ${v}`)); + if (r.body) parts.push("--data-raw", quote(r.body)); + return parts.join(" "); +} + +export function validate(r: Request): void { + if (!METHODS.includes(r.method)) throw new Error(`Unknown method ${r.method}: use ${METHODS.join(", ")}`); + let u: URL; + try { + u = new URL(r.url); + } catch { + throw new Error(`“${r.url}” is not a valid URL`); + } + if (!/^https?:$/.test(u.protocol)) throw new Error("Only http:// and https:// URLs can be sent"); + if (r.body && (r.method === "GET" || r.method === "HEAD")) { + throw new Error(`A ${r.method} request can't have a body: use POST, PUT or PATCH`); + } +} + +const TEXTUAL = + /^(text\/|application\/(json|.*\+json|xml|.*\+xml|javascript|ecmascript|x-www-form-urlencoded|yaml|toml|graphql|x-ndjson|problem\+json)|image\/svg\+xml)/i; + +export const isTextual = (contentType: string) => !contentType || TEXTUAL.test(contentType); + +/** Sends the request; `fetchFn` is replaceable for tests. */ +export async function send( + r: Request, + options: { signal?: AbortSignal; fetchFn?: typeof fetch; now?: () => number } = {}, +): Promise<Response> { + validate(r); + const now = options.now ?? (() => performance.now()); + const start = now(); + const res = await (options.fetchFn ?? fetch)(r.url, { + method: r.method, + headers: r.headers, + body: r.body, + signal: options.signal, + redirect: "follow", + }); + const bytes = new Uint8Array(await res.arrayBuffer()); + const ms = Math.round(now() - start); + const headers = [...res.headers.entries()] as [string, string][]; + const contentType = res.headers.get("content-type") ?? ""; + const charset = contentType.match(/charset=([\w-]+)/i)?.[1]; + let text: string | undefined; + if (isTextual(contentType)) { + try { + text = new TextDecoder(charset ?? "utf-8").decode(bytes); + } catch { + text = new TextDecoder().decode(bytes); + } + } + return { + status: res.status, + statusText: res.statusText, + ms, + size: bytes.byteLength, + url: res.url || r.url, + redirected: res.redirected, + headers, + contentType, + text, + }; +} + +/** The body for a code block: JSON indented, the rest as is. */ +export function prettyBody(contentType: string, text: string): { lang: string; text: string } { + const type = contentType.toLowerCase(); + const trimmed = text.trim(); + if (/json/.test(type) || (!type && /^[[{]/.test(trimmed))) { + try { + return { lang: "json", text: JSON.stringify(JSON.parse(trimmed), null, 2) }; + } catch { /* not JSON after all */ } + } + if (/x-ndjson/.test(type)) { + try { + return { + lang: "json", + text: trimmed.split("\n").map((l) => JSON.stringify(JSON.parse(l), null, 2)).join("\n"), + }; + } catch { /* keep raw */ } + } + if (/html/.test(type)) return { lang: "html", text }; + if (/xml/.test(type)) return { lang: "xml", text }; + if (/x-www-form-urlencoded/.test(type)) { + return { lang: "", text: [...new URLSearchParams(trimmed)].map(([k, v]) => `${k} = ${v}`).join("\n") }; + } + return { lang: "", text }; +} + +/** The title of an HTML page, if any. */ +export const htmlTitle = (html: string) => + html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1].replace(/\s+/g, " ").trim() || undefined; + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +export const formatMs = (ms: number) => ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`; + +export type Tint = "green" | "blue" | "purple" | "orange" | "red" | "gray"; + +export function statusTint(status: number): Tint { + if (status >= 500) return "red"; + if (status >= 400) return "orange"; + if (status >= 300) return "purple"; + if (status >= 200) return "green"; + return "blue"; +} + +/** Standard reason phrases, for HTTP/2 responses that carry none. */ +const REASONS: Record<number, string> = { + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 206: "Partial Content", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 409: "Conflict", + 410: "Gone", + 415: "Unsupported Media Type", + 422: "Unprocessable Content", + 429: "Too Many Requests", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +export const reason = (status: number, text: string) => text || REASONS[status] || ""; + +/** Keeps the newest entries, one per method and URL. */ +export function addHistory(history: HistoryEntry[], entry: HistoryEntry, max = 30): HistoryEntry[] { + return [entry, ...history.filter((h) => !(h.method === entry.method && h.url === entry.url))].slice(0, max); +} + +/** "api.example.com/v1/items" for a list subtitle. */ +export function shortUrl(url: string): string { + try { + const u = new URL(url); + return `${u.host}${u.pathname === "/" ? "" : u.pathname}${u.search}`; + } catch { + return url; + } +} + +/** "2 min ago", "3 h ago", "yesterday", "12 Oct". */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, Math.round((now - ms) / 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 < 172800) return "yesterday"; + return new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +/** Headers that carry credentials: copies of the request are kept out of clipboard histories. */ +export const hasSecret = (r: Request) => + r.headers.some(([k]) => /^(authorization|proxy-authorization|cookie|x-api-key|x-auth-token|private-token)$/i.test(k)); diff --git a/extensions/http-client/lib_test.ts b/extensions/http-client/lib_test.ts new file mode 100644 index 0000000..8366f20 --- /dev/null +++ b/extensions/http-client/lib_test.ts @@ -0,0 +1,255 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { + addHistory, + ago, + formatBytes, + formatMs, + hasSecret, + type HistoryEntry, + htmlTitle, + isTextual, + looksLikeUrl, + normalizeUrl, + parseCurl, + parseHeaders, + parseInput, + prettyBody, + reason, + send, + shellWords, + shortUrl, + statusTint, + toCurl, + validate, +} from "./lib.ts"; + +Deno.test("what the user types", () => { + assertEquals(parseInput("https://api.github.com/zen"), { + method: "GET", + url: "https://api.github.com/zen", + headers: [], + body: undefined, + }); + assertEquals(parseInput("delete api.example.com/items/3")?.method, "DELETE"); + assertEquals(parseInput("localhost:8000/health")?.url, "http://localhost:8000/health"); + assertEquals(parseInput('POST api.example.com/items {"name": "Ada"}'), { + method: "POST", + url: "https://api.example.com/items", + headers: [["Content-Type", "application/json"]], + body: '{"name": "Ada"}', + }); + // A body without a method is a POST. + assertEquals(parseInput("api.example.com/form a=1&b=2")?.method, "POST"); + assertEquals(parseInput("192.168.1.10:8080")?.url, "http://192.168.1.10:8080"); + assertEquals([parseInput(""), parseInput("hello"), parseInput("FETCH https://x.com"), parseInput("what is http")], [ + undefined, + undefined, + undefined, + undefined, + ]); + assertEquals([looksLikeUrl("example.com"), looksLikeUrl("example"), looksLikeUrl("[::1]:3000/x")], [ + true, + false, + true, + ]); + assertEquals([normalizeUrl("printer.local/status"), normalizeUrl("HTTP://x.y")], [ + "http://printer.local/status", + "HTTP://x.y", + ]); +}); + +Deno.test("shell words", () => { + assertEquals(shellWords(`curl -H 'A: b c' -d "{\\"x\\": 1}" $'a\\nb' plain\\ word`), [ + "curl", + "-H", + "A: b c", + "-d", + '{"x": 1}', + "a\nb", + "plain word", + ]); + assertEquals(shellWords("curl \\\n https://x.com"), ["curl", "https://x.com"]); + assertEquals(shellWords("it''s \"\""), ["its", ""]); + assertThrows(() => shellWords("curl 'oops"), Error, "Unterminated"); +}); + +Deno.test("pasted curl commands", () => { + const r = parseInput( + `curl -fsSL -X PATCH 'https://api.example.com/users/1' -H 'Authorization: Bearer abc' -H "Accept: application/json" --data-raw '{"name":"Ada"}' --compressed`, + ); + assertEquals(r, { + method: "PATCH", + url: "https://api.example.com/users/1", + headers: [["Authorization", "Bearer abc"], ["Accept", "application/json"], [ + "Content-Type", + "application/x-www-form-urlencoded", + ]], + body: '{"name":"Ada"}', + }); + assertEquals(parseCurl(shellWords("curl -d a=1 -d b=2 example.com")), { + method: "POST", + url: "https://example.com", + headers: [["Content-Type", "application/x-www-form-urlencoded"]], + body: "a=1&b=2", + }); + assertEquals(parseCurl(shellWords("curl -G -d q=deno example.com/search")).url, "https://example.com/search?q=deno"); + assertEquals(parseCurl(shellWords("curl --json '{\"a\":1}' example.com")).headers, [ + ["Content-Type", "application/json"], + ["Accept", "application/json"], + ]); + assertEquals(parseCurl(shellWords("curl -u ada:secret example.com")).headers, [[ + "Authorization", + "Basic YWRhOnNlY3JldA==", + ]]); + assertEquals(parseCurl(shellWords("curl -I example.com")).method, "HEAD"); + assertEquals(parseCurl(shellWords("curl --data-urlencode 'q=a b' example.com")).body, "q=a%20b"); + assertEquals(parseCurl(shellWords("curl -o out.html -m 10 example.com")).url, "https://example.com"); + assertThrows(() => parseCurl(shellWords("curl --foo example.com")), Error, "--foo isn't supported"); + assertThrows(() => parseCurl(shellWords("curl -d @file.json example.com")), Error, "@file"); + assertThrows(() => parseCurl(shellWords("curl -s")), Error, "No URL"); +}); + +Deno.test("copy as curl reads back the same request", () => { + const r = { + method: "PUT", + url: "https://api.example.com/items/1?x=a b", + headers: [["Content-Type", "application/json"], ["X-Note", "it's"]] as [string, string][], + body: '{"name": "Ada"}', + }; + const line = toCurl(r); + assertEquals( + line, + `curl -X PUT 'https://api.example.com/items/1?x=a b' -H 'Content-Type: application/json' -H 'X-Note: it'\\''s' --data-raw '{"name": "Ada"}'`, + ); + assertEquals(parseCurl(shellWords(line)), r); + assertEquals(toCurl({ method: "GET", url: "https://x.com", headers: [] }), "curl https://x.com"); + assertEquals(toCurl({ method: "HEAD", url: "https://x.com", headers: [] }), "curl -I https://x.com"); + assertEquals( + toCurl({ method: "POST", url: "https://x.com", headers: [], body: "a" }), + "curl https://x.com --data-raw a", + ); +}); + +Deno.test("headers and validation", () => { + assertEquals(parseHeaders("Accept: application/json\n\n# note\nX-Url: https://a.b/c"), [ + ["Accept", "application/json"], + ["X-Url", "https://a.b/c"], + ]); + assertThrows(() => parseHeaders("no colon here"), Error, "is not a header"); + assertThrows( + () => validate({ method: "GET", url: "https://x.com", headers: [], body: "x" }), + Error, + "can't have a body", + ); + assertThrows(() => validate({ method: "GET", url: "ftp://x.com", headers: [] }), Error, "Only http"); + assertThrows(() => validate({ method: "GET", url: "nope", headers: [] }), Error, "not a valid URL"); + assertThrows(() => validate({ method: "BREW", url: "https://x.com", headers: [] }), Error, "Unknown method"); +}); + +Deno.test("send with a fake fetch", async () => { + let seen: RequestInit | undefined; + const fetchFn = ((_url: string, init: RequestInit) => { + seen = init; + return Promise.resolve( + new Response(JSON.stringify({ id: 1, name: "Ada" }), { + status: 201, + headers: { "content-type": "application/json; charset=utf-8", "x-request-id": "abc" }, + }), + ); + }) as unknown as typeof fetch; + let t = 1000; + const res = await send( + { + method: "POST", + url: "https://api.example.com/users", + headers: [["Content-Type", "application/json"]], + body: "{}", + }, + { fetchFn, now: () => (t += 42) }, + ); + assertEquals(seen?.method, "POST"); + assertEquals(seen?.body, "{}"); + assertEquals([res.status, res.ms, res.size, res.contentType, res.text], [ + 201, + 42, + 21, + "application/json; charset=utf-8", + '{"id":1,"name":"Ada"}', + ]); + assertEquals(res.headers.find(([k]) => k === "x-request-id"), ["x-request-id", "abc"]); + const binary = await send({ method: "GET", url: "https://x.com/a.png", headers: [] }, { + fetchFn: (() => + Promise.resolve( + new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } }), + )) as unknown as typeof fetch, + }); + assertEquals([binary.text, binary.size], [undefined, 3]); + await assertRejects(() => send({ method: "GET", url: "ftp://x", headers: [] }, { fetchFn }), Error, "Only http"); +}); + +Deno.test("bodies are shown readably", () => { + assertEquals(prettyBody("application/json", '{"a":[1,2]}'), { + lang: "json", + text: '{\n "a": [\n 1,\n 2\n ]\n}', + }); + assertEquals(prettyBody("application/problem+json", '{"title":"x"}').lang, "json"); + assertEquals(prettyBody("", ' {"a":1}').text, '{\n "a": 1\n}'); + assertEquals(prettyBody("application/json", "not json"), { lang: "", text: "not json" }); + assertEquals(prettyBody("text/html", "<p>"), { lang: "html", text: "<p>" }); + assertEquals(prettyBody("application/x-www-form-urlencoded", "a=1&b=x%20y").text, "a = 1\nb = x y"); + assertEquals(prettyBody("application/x-ndjson", '{"a":1}\n{"b":2}').text, '{\n "a": 1\n}\n{\n "b": 2\n}'); + assertEquals(htmlTitle("<html><title>\n Deno docs "), "Deno docs"); + assertEquals([ + isTextual("text/plain"), + isTextual("application/vnd.api+json"), + isTextual("image/svg+xml"), + isTextual("image/png"), + ], [ + true, + true, + true, + false, + ]); +}); + +Deno.test("formatting and history", () => { + assertEquals([formatBytes(512), formatBytes(2048), formatBytes(50_000), formatBytes(3_500_000)], [ + "512 B", + "2.0 KB", + "49 KB", + "3.3 MB", + ]); + assertEquals([formatMs(87), formatMs(1534)], ["87 ms", "1.53 s"]); + assertEquals([statusTint(101), statusTint(204), statusTint(304), statusTint(404), statusTint(503)], [ + "blue", + "green", + "purple", + "orange", + "red", + ]); + assertEquals([reason(404, ""), reason(200, "Fine"), reason(299, "")], ["Not Found", "Fine", ""]); + assertEquals(shortUrl("https://api.example.com/v1/items?page=2"), "api.example.com/v1/items?page=2"); + assertEquals(shortUrl("https://example.com/"), "example.com"); + const now = Date.UTC(2026, 9, 28, 12); + assertEquals( + [ago(now - 5_000, now), ago(now - 300_000, now), ago(now - 7_200_000, now), ago(now - 100_000_000, now)], + [ + "just now", + "5 min ago", + "2 h ago", + "yesterday", + ], + ); + const h = (url: string, at: number): HistoryEntry => ({ method: "GET", url, headers: [], status: 200, ms: 10, at }); + const list = addHistory([h("https://a", 1), h("https://b", 2)], h("https://a", 3), 2); + assertEquals(list.map((e) => [e.url, e.at]), [["https://a", 3], ["https://b", 2]]); + assertEquals(addHistory([h("https://a", 1), h("https://b", 2)], h("https://c", 3), 2).map((e) => e.url), [ + "https://c", + "https://a", + ]); +}); + +Deno.test("requests with credentials are flagged", () => { + assertEquals(hasSecret({ method: "GET", url: "https://x.com", headers: [["Authorization", "Bearer x"]] }), true); + assertEquals(hasSecret({ method: "GET", url: "https://x.com", headers: [["Accept", "*/*"]] }), false); +}); diff --git a/extensions/http-client/views.ts b/extensions/http-client/views.ts new file mode 100644 index 0000000..c737cba --- /dev/null +++ b/extensions/http-client/views.ts @@ -0,0 +1,309 @@ +// Views shared by the commands: the response page, the request form, saved and history items. + +import { + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + open, + pop, + replace, + storage, + toast, +} from "@backslash/api"; +import { + addHistory, + ago, + formatBytes, + formatMs, + hasSecret, + headerText, + type HistoryEntry, + htmlTitle, + METHODS, + parseHeaders, + prettyBody, + reason, + type Request, + type Response, + type SavedRequest, + send, + shortUrl, + statusTint, + toCurl, + validate, +} from "./lib.ts"; + +type Json = Parameters[1]; + +// --- Storage ------------------------------------------------------------------- + +const changes = new EventTarget(); + +export const loadSaved = async () => + ((await storage.get("saved").catch(() => undefined)) as SavedRequest[] | undefined) ?? []; +export const loadHistory = async () => + ((await storage.get("history").catch(() => undefined)) as HistoryEntry[] | undefined) ?? []; + +async function writeSaved(list: SavedRequest[]) { + await storage.set("saved", list as unknown as Json); + changes.dispatchEvent(new Event("change")); +} + +async function remember(r: Request, res: Response) { + const entry: HistoryEntry = { ...r, status: res.status, ms: res.ms, at: Date.now() }; + await storage.set("history", addHistory(await loadHistory(), entry) as unknown as Json); + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()` again after every change made from this process. */ +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); + }); + } +} + +// --- Sending --------------------------------------------------------------------- + +const TIMEOUT = 30_000; + +/** Sends, records it in the history, and shows the response. */ +export async function sendAndShow(r: Request, ctx: Context): Promise { + const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(TIMEOUT)]); + let res: Response; + try { + res = await send(r, { signal }); + } catch (e) { + if (e instanceof DOMException && e.name === "TimeoutError") throw new Error(`No answer after ${TIMEOUT / 1000} s`); + const message = e instanceof Error ? e.message : String(e); + throw new Error(message.replace(/^error sending request for url \([^)]*\): /, "")); + } + await remember(r, res).catch(() => {}); + return responseView(r, res); +} + +const fence = (text: string, lang = "") => { + const ticks = "`".repeat(Math.max(3, ...[...text.matchAll(/`{3,}/g)].map((m) => m[0].length + 1))); + return `${ticks}${lang}\n${text}\n${ticks}`; +}; + +const LIMIT = 30_000; + +export function responseView(r: Request, res: Response): DetailSpec { + const status = `${res.status} ${reason(res.status, res.statusText)}`.trim(); + const tint = statusTint(res.status); + let body: string; + if (res.text === undefined) { + body = `_Binary body (${res.contentType || "unknown type"}), ${formatBytes(res.size)}._`; + } else if (!res.text) { + body = "_Empty body._"; + } else { + const pretty = prettyBody(res.contentType, res.text); + const shown = pretty.text.length > LIMIT + ? `${pretty.text.slice(0, LIMIT)}\n… (${formatBytes(res.size)} in all)` + : pretty.text; + body = fence(shown, pretty.lang); + } + const title = res.text && /html/.test(res.contentType) ? htmlTitle(res.text) : undefined; + return detail({ + title: status, + markdown: [`## ${status}`, "", `\`${r.method} ${r.url}\``, ...(title ? ["", `**${title}**`] : []), "", body].join( + "\n", + ), + stats: [ + { label: "Status", value: res.status, color: tint }, + { label: "Time", value: formatMs(res.ms) }, + { label: "Size", value: formatBytes(res.size) }, + ], + metadata: [ + meta.text("Method", r.method, { icon: Icons.arrowRight }), + meta.link("URL", shortUrl(res.url), res.url), + ...(res.redirected ? [meta.text("Redirected", "Yes, the URL above is the final one", { color: "purple" })] : []), + ...(res.contentType ? [meta.text("Content type", res.contentType, { icon: Icons.file })] : []), + meta.separator(), + ...res.headers.map(([k, v]) => meta.text(k, v)), + ], + status: `${status} · ${formatMs(res.ms)} · ${formatBytes(res.size)}`, + actions: [ + ...(res.text ? [copy(res.text, "Copy Body")] : []), + action("Send Again", async (c) => replace(await sendAndShow(r, c)), { icon: Icons.refresh, shortcut: "r" }), + copy(toCurl(r), "Copy as curl", { + concealed: hasSecret(r), + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(headerText(res.headers), "Copy Response Headers", { section: "Copy" }), + copy(res.url, "Copy URL", { icon: Icons.link, section: "Copy" }), + ...(res.text && /json/.test(res.contentType) + ? [ + copy(prettyBody(res.contentType, res.text).text, "Copy Formatted JSON", { + icon: Icons.braces, + section: "Copy", + }), + ] + : []), + action("Save Request…", () => requestForm({ request: r }), { icon: Icons.bookmark, shortcut: "s" }), + ...(r.method === "GET" ? [open(res.url, "Open in Browser", { shortcut: "o" })] : []), + ], + }); +} + +// --- Form ------------------------------------------------------------------------ + +/** + * Creates or edits a saved request. `after` decides where saving leads: + * back to a live list (`pop`), or closes when it's the command itself. + */ +export function requestForm( + options: { request?: Request; saved?: SavedRequest; after?: "pop" | "close" } = {}, +): FormSpec { + const r = options.saved ?? options.request; + return form({ + title: options.saved ? `Edit “${options.saved.name}”` : "Save Request", + submitTitle: options.saved ? "Save Changes" : "Save Request", + fields: [ + field.text("name", "Name", { + required: true, + value: options.saved?.name ?? (r ? `${r.method} ${shortUrl(r.url)}` : ""), + placeholder: "List users (staging)", + }), + field.dropdown("method", "Method", METHODS, { value: r?.method ?? "GET" }), + field.text("url", "URL", { + required: true, + value: r?.url ?? "", + placeholder: "https://api.example.com/v1/users", + }), + field.textArea("headers", "Headers", { + value: r ? headerText(r.headers) : "", + placeholder: "Authorization: Bearer …\nAccept: application/json", + }), + field.textArea("body", "Body", { value: r?.body ?? "", placeholder: '{"name": "Ada"}' }), + ], + async submit(values) { + const request: Request = { + method: String(values.method || "GET"), + url: String(values.url).trim(), + headers: parseHeaders(String(values.headers ?? "")), + body: String(values.body ?? "") || undefined, + }; + validate(request); + const name = String(values.name).trim(); + const list = await loadSaved(); + const id = options.saved?.id ?? crypto.randomUUID(); + const entry: SavedRequest = { ...request, id, name }; + await writeSaved(options.saved ? list.map((s) => s.id === id ? entry : s) : [entry, ...list]); + if (options.after === "pop") return pop(); + await toast(`Saved “${name}”`, { style: "success" }); + return close(`Saved “${name}”`); + }, + }); +} + +// --- Items ----------------------------------------------------------------------- + +const methodTint = (m: string) => + ({ GET: "green", POST: "blue", PUT: "orange", PATCH: "purple", DELETE: "red" } as const)[m as "GET"] ?? "gray"; + +function requestDetail(r: Request) { + return { + markdown: [ + fence(toCurl(r), "sh"), + ...(r.body ? ["", "**Body**", "", fence(prettyBody(header(r, "content-type"), r.body).text)] : []), + ].join("\n"), + metadata: [ + meta.text("Method", r.method, { icon: Icons.arrowRight, color: methodTint(r.method) }), + meta.link("URL", shortUrl(r.url), r.url), + ...(r.headers.length + ? [ + meta.separator(), + ...r.headers.map(([k, v]) => meta.text(k, /authorization|token|key|cookie/i.test(k) ? "••••••" : v)), + ] + : []), + ], + }; +} + +const header = (r: Request, name: string) => r.headers.find(([k]) => k.toLowerCase() === name)?.[1] ?? ""; + +function requestActions(r: Request): Action[] { + return [ + action("Send Request", (ctx) => sendAndShow(r, ctx), { icon: Icons.arrowRight }), + copy(toCurl(r), "Copy as curl", { + concealed: hasSecret(r), + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(r.url, "Copy URL", { icon: Icons.link, section: "Copy" }), + ]; +} + +export function savedItem(s: SavedRequest, after: "pop" | "close"): Item { + return { + id: `saved:${s.id}`, + title: s.name, + subtitle: `${s.method} ${shortUrl(s.url)}`, + icon: { source: Icons.bookmark, tint: methodTint(s.method) }, + section: "Saved", + keywords: [s.url, s.method], + accessories: [{ tag: s.method, color: methodTint(s.method) }], + detail: requestDetail(s), + actions: [ + ...requestActions(s), + action("Edit Request…", () => requestForm({ saved: s, after }), { icon: Icons.text, shortcut: "e" }), + action("Duplicate", async () => { + await writeSaved([{ ...s, id: crypto.randomUUID(), name: `${s.name} (copy)` }, ...await loadSaved()]); + await toast("Duplicated", { style: "success" }); + }, { icon: Icons.copy }), + action("Delete Request", async () => { + await writeSaved((await loadSaved()).filter((x) => x.id !== s.id)); + await toast(`Deleted “${s.name}”`); + }, { icon: Icons.trash, destructive: true, confirm: `Delete “${s.name}”?`, section: "Danger" }), + ], + }; +} + +export function historyItem(h: HistoryEntry, after: "pop" | "close"): Item { + return { + id: `history:${h.method}:${h.url}`, + title: shortUrl(h.url), + subtitle: h.method, + icon: { source: Icons.history, tint: statusTint(h.status) }, + section: "History", + keywords: [h.url, h.method, String(h.status)], + accessories: [ + { tag: String(h.status), color: statusTint(h.status) }, + { text: formatMs(h.ms), tooltip: "Response time" }, + { text: ago(h.at) }, + ], + detail: requestDetail(h), + actions: [ + ...requestActions(h), + action("Save Request…", () => requestForm({ request: h, after }), { icon: Icons.bookmark, shortcut: "s" }), + ], + }; +} + +export async function clearHistory() { + await storage.delete("history"); + changes.dispatchEvent(new Event("change")); +} diff --git a/extensions/package-registries/api.ts b/extensions/package-registries/api.ts new file mode 100644 index 0000000..4b7d986 --- /dev/null +++ b/extensions/package-registries/api.ts @@ -0,0 +1,112 @@ +// One search per registry, over their public JSON APIs. + +import { + escapeModule, + looksLikeModule, + mapCrates, + mapGems, + mapGo, + mapHex, + mapMaven, + mapNuget, + mapPackagist, + mapPub, + mapPypi, + mavenQuery, + type Package, + packagistLatest, + pubNames, + pypiCandidates, + type RegistryId, +} from "./lib.ts"; + +// crates.io refuses requests without a User-Agent saying who is calling. +const USER_AGENT = "Backslash launcher package search (https://github.com/backslash-app/plugins)"; + +export class NotFound extends Error {} + +async function get(url: string, signal: AbortSignal, accept = "application/json"): Promise { + const res = await fetch(url, { signal, headers: { "User-Agent": USER_AGENT, Accept: accept } }); + if (res.status === 404) throw new NotFound(url); + if (res.status === 429) throw new Error(`${new URL(url).host} is rate limiting: wait a little`); + if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`); + return await res.json(); +} + +const optional = (p: Promise) => p.catch((e) => (e instanceof NotFound ? undefined : Promise.reject(e))); +const q = encodeURIComponent; + +const SEARCHES: Record Promise> = { + async crates(query, signal) { + return mapCrates(await get(`https://crates.io/api/v1/crates?q=${q(query)}&per_page=20`, signal)); + }, + + async pypi(query, signal) { + // No search API: exact JSON lookups for the likely names. + const names = pypiCandidates(query); + const found = await Promise.all(names.map((n) => optional(get(`https://pypi.org/pypi/${n}/json`, signal)))); + return found.filter(Boolean).map(mapPypi); + }, + + async go(query, signal) { + const path = query.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""); + if (!looksLikeModule(path)) return []; + // Also the newer major versions, which are separate modules (/v2, /v3…). + const paths = /\/v\d+$/.test(path) ? [path] : [path, `${path}/v2`, `${path}/v3`, `${path}/v4`, `${path}/v5`]; + const latest = await Promise.all( + paths.map((p) => + optional(get(`https://proxy.golang.org/${escapeModule(p)}/@latest`, signal)).catch(() => undefined) + ), + ); + return paths.flatMap((p, i) => latest[i] ? [mapGo(p, latest[i])] : []).reverse(); + }, + + async gems(query, signal) { + return mapGems(await get(`https://rubygems.org/api/v1/search.json?query=${q(query)}`, signal)).slice(0, 20); + }, + + async packagist(query, signal) { + const json = await get(`https://packagist.org/search.json?q=${q(query)}&per_page=15`, signal); + // Versions and licenses come from the static metadata files, top results only. + const names: string[] = (json.results ?? []).slice(0, 8).map((r: any) => r.name); + const files = await Promise.all( + names.map((n) => get(`https://repo.packagist.org/p2/${n}.json`, signal).catch(() => undefined)), + ); + const latest = Object.fromEntries(names.map((n, i) => [n, packagistLatest(files[i], n)])); + return mapPackagist(json, latest); + }, + + async maven(query, signal) { + const json = await get( + `https://search.maven.org/solrsearch/select?q=${q(mavenQuery(query))}&rows=20&wt=json`, + signal, + ); + return mapMaven(json); + }, + + async hex(query, signal) { + return mapHex(await get(`https://hex.pm/api/packages?search=${q(query)}&sort=recent_downloads`, signal)).slice( + 0, + 20, + ); + }, + + async pub(query, signal) { + const names = pubNames(await get(`https://pub.dev/api/search?q=${q(query)}`, signal)).slice(0, 8); + const details = await Promise.all(names.map((n) => + Promise.all([ + get(`https://pub.dev/api/packages/${n}`, signal, "application/vnd.pub.v2+json"), + get(`https://pub.dev/api/packages/${n}/score`, signal).catch(() => undefined), + ]).catch(() => undefined) + )); + return details.filter((d): d is [any, any] => !!d).map(([pkg, score]) => mapPub(pkg, score)); + }, + + async nuget(query, signal) { + return mapNuget( + await get(`https://azuresearch-usnc.nuget.org/query?q=${q(query)}&take=20&semVerLevel=2.0.0`, signal), + ); + }, +}; + +export const searchRegistry = (id: RegistryId, query: string, signal: AbortSignal) => SEARCHES[id](query, signal); diff --git a/extensions/package-registries/backslash.toml b/extensions/package-registries/backslash.toml new file mode 100644 index 0000000..79a5be5 --- /dev/null +++ b/extensions/package-registries/backslash.toml @@ -0,0 +1,51 @@ +[extension] +id = "package-registries" +title = "Package Registries" +description = "Search crates.io, PyPI, Go modules, RubyGems, Packagist, Maven Central, Hex, pub.dev and NuGet" +version = "1.0.0" +author = "Backslash" +icon = "bs-package" +license = "MIT" + +[permissions] +# Each registry's public JSON API. Packagist versions and licenses come from +# repo.packagist.org; Go modules from the module proxy (pkg.go.dev has no API). +net = [ + "crates.io", + "pypi.org", + "proxy.golang.org", + "rubygems.org", + "packagist.org", + "repo.packagist.org", + "search.maven.org", + "hex.pm", + "pub.dev", + "azuresearch-usnc.nuget.org", +] + +[[commands]] +name = "search" +title = "Search Package Registries" +description = "Version, downloads, license and install commands; pick the registry with Tab or type cargo, pip, go…" +icon = "bs-package" +keywords = [ + "package", + "library", + "crate", + "cargo", + "pypi", + "pip", + "go", + "gem", + "composer", + "maven", + "gradle", + "hex", + "pub", + "flutter", + "nuget", + "dependency", + "paquet", + "bibliothèque", + "dépendance", +] diff --git a/extensions/package-registries/commands/search.ts b/extensions/package-registries/commands/search.ts new file mode 100644 index 0000000..46bfd35 --- /dev/null +++ b/extensions/package-registries/commands/search.ts @@ -0,0 +1,149 @@ +import { type Action, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { searchRegistry } from "../api.ts"; +import { + compact, + type Package, + parseQuery, + rank, + REGISTRIES, + registry, + type RegistryId, + relativeDate, +} from "../lib.ts"; + +function packageItem(p: Package): Item { + const r = registry(p.registry)!; + const updated = relativeDate(p.updated); + const [mainTitle, mainCommand] = p.install[0]; + const links: Action[] = [ + ...(p.repository ? [open(p.repository, "Open Repository", { icon: Icons.repository, shortcut: "r" })] : []), + ...(p.docs ? [open(p.docs, "Open Documentation", { icon: Icons.book, shortcut: "d" })] : []), + ...(p.homepage && p.homepage !== p.repository ? [open(p.homepage, "Open Homepage", { icon: Icons.home })] : []), + ]; + return { + id: `${p.registry}:${p.name}`, + title: p.name, + subtitle: p.description, + icon: { source: p.deprecated ? Icons.alert : Icons.package, tint: p.deprecated ? "red" : r.tint }, + keywords: p.keywords, + accessories: [ + ...(p.deprecated ? [{ tag: "Deprecated", color: "red" as const, tooltip: p.deprecated }] : []), + ...(p.downloads !== undefined + ? [{ icon: Icons.download, text: compact(p.downloads), tooltip: `Downloads, ${p.downloadsPeriod}` }] + : []), + ...(p.version ? [{ tag: p.version, color: r.tint }] : []), + ], + detail: { + stats: [ + ...(p.version ? [{ label: "Version", value: p.version }] : []), + ...(p.downloads !== undefined + ? [{ label: `Downloads · ${p.downloadsPeriod}`, value: compact(p.downloads) }] + : []), + ...(updated ? [{ label: "Released", value: updated }] : []), + ], + markdown: [ + p.description ?? "", + ...(p.deprecated ? ["", `> ${p.deprecated}`] : []), + "", + `**${mainTitle}**`, + "", + "```", + mainCommand, + "```", + ].join("\n").trim(), + metadata: [ + meta.text("Registry", `${r.title} · ${r.ecosystem}`, { icon: Icons.package }), + ...(p.license ? [meta.text("License", p.license, { icon: Icons.shield })] : []), + ...(p.updated + ? [ + meta.text("Updated", new Date(p.updated).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.calendar, + }), + ] + : []), + ...(p.facts ?? []).map(([label, value]) => meta.text(label, value)), + ...(p.keywords?.length ? [meta.tags("Keywords", p.keywords.slice(0, 8))] : []), + meta.separator(), + meta.link(r.title, p.page.replace(/^https:\/\/(www\.)?/, ""), p.page), + ...(p.repository + ? [meta.link("Repository", p.repository.replace(/^https:\/\/(www\.)?/, ""), p.repository)] + : []), + ...(p.docs ? [meta.link("Docs", p.docs.replace(/^https:\/\/(www\.)?/, ""), p.docs)] : []), + ], + }, + actions: [ + copy(mainCommand, `Copy ${mainTitle} Command`), + open(p.page, `Open on ${r.title}`, { shortcut: "o" }), + ...p.install.slice(1).map(([title, text], i) => + copy(text, `Copy ${title}`, { + icon: Icons.code, + section: "Install", + ...(i === 0 ? { shortcut: "c" } : {}), + }) + ), + ...links.map((a) => ({ ...a, section: "Links" })), + copy(p.name, "Copy Name", { section: "Copy" }), + ...(p.version ? [copy(p.version, "Copy Version", { section: "Copy" })] : []), + ], + }; +} + +function hint(id: RegistryId): Item[] { + return REGISTRIES.map((r) => ({ + id: `hint:${r.id}`, + title: `${r.aliases[0]} ${r.example}`, + subtitle: `${r.title} · ${r.ecosystem}`, + icon: { source: Icons.package, tint: r.tint }, + section: r.id === id ? "Selected Registry" : "Type a Registry Name First", + accessories: r.id === id ? [{ tag: "Selected", color: r.tint }] : [{ text: r.aliases.slice(1, 4).join(", ") }], + detail: r.note ? { markdown: r.note } : undefined, + actions: [open(r.webSearch(""), `Open ${r.title}`)], + })).sort((a, b) => Number(b.id === `hint:${id}`) - Number(a.id === `hint:${id}`)); +} + +let last = ""; + +export default list({ + placeholder: "Search packages… (prefix with cargo, pip, go, gem, composer, maven, hex, pub, nuget)", + filter: { options: REGISTRIES.map((r) => ({ value: r.id, title: r.title })), default: "crates" }, + empty: { + title: "No package found", + description: "Check the spelling, or try another registry.", + icon: Icons.package, + }, + status: () => last, + async search(input, { filter, signal }) { + const { registry: id, query } = parseQuery(input, (filter as RegistryId) ?? "crates"); + const r = registry(id)!; + if (!query) { + last = `${r.title} · ${r.ecosystem}`; + return hint(id); + } + let found: Package[]; + try { + found = rank(await searchRegistry(id, query, signal), query); + } catch (e) { + if (signal.aborted) throw e; + last = `${r.title} is unreachable`; + return [{ + id: "error", + title: `${r.title} can't be reached`, + subtitle: e instanceof Error ? e.message : String(e), + icon: { source: Icons.wifiOff, tint: "red" }, + actions: [open(r.webSearch(query), `Search on ${r.title}`)], + }]; + } + last = `${found.length} result${found.length === 1 ? "" : "s"} on ${r.title}`; + const items = found.map(packageItem); + // What the API can't find, the website might. + items.push({ + id: "web", + title: `Search “${query}” on ${r.title}`, + subtitle: r.note ?? "Open the registry's own search", + icon: { source: Icons.search, tint: "gray" }, + section: found.length ? "More" : undefined, + actions: [open(r.webSearch(query), `Search on ${r.title}`)], + }); + return items; + }, +}); diff --git a/extensions/package-registries/lib.ts b/extensions/package-registries/lib.ts new file mode 100644 index 0000000..8797254 --- /dev/null +++ b/extensions/package-registries/lib.ts @@ -0,0 +1,492 @@ +// Package registries: query parsing and mapping each API's JSON to one shape. Pure, tested in lib_test.ts. + +export type RegistryId = "crates" | "pypi" | "go" | "gems" | "packagist" | "maven" | "hex" | "pub" | "nuget"; + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export interface Registry { + id: RegistryId; + title: string; + ecosystem: string; + tint: Tint; + /** Words that pick this registry when typed first: `cargo serde`. */ + aliases: string[]; + example: string; + /** The registry's own web search, for what its API can't do. */ + webSearch: (q: string) => string; + /** Explains a limitation of the API, shown with the results. */ + note?: string; +} + +export const REGISTRIES: Registry[] = [ + { + id: "crates", + title: "crates.io", + ecosystem: "Rust", + tint: "orange", + aliases: ["cargo", "crates", "crate", "rust"], + example: "serde", + webSearch: (q) => `https://crates.io/search?q=${encodeURIComponent(q)}`, + }, + { + id: "pypi", + title: "PyPI", + ecosystem: "Python", + tint: "blue", + aliases: ["pip", "pypi", "python", "py", "uv"], + example: "requests", + webSearch: (q) => `https://pypi.org/search/?q=${encodeURIComponent(q)}`, + note: + "PyPI has no search API: names are looked up exactly (with python- and py prefixes). Search the site for more.", + }, + { + id: "go", + title: "Go modules", + ecosystem: "Go", + tint: "teal", + aliases: ["go", "golang"], + example: "github.com/go-chi/chi/v5", + webSearch: (q) => `https://pkg.go.dev/search?q=${encodeURIComponent(q)}`, + note: "pkg.go.dev has no search API: type a module path (github.com/…) to look it up on the Go module proxy.", + }, + { + id: "gems", + title: "RubyGems", + ecosystem: "Ruby", + tint: "red", + aliases: ["gem", "gems", "ruby", "rubygems", "bundle"], + example: "rails", + webSearch: (q) => `https://rubygems.org/search?query=${encodeURIComponent(q)}`, + }, + { + id: "packagist", + title: "Packagist", + ecosystem: "PHP", + tint: "purple", + aliases: ["composer", "packagist", "php"], + example: "laravel", + webSearch: (q) => `https://packagist.org/?query=${encodeURIComponent(q)}`, + }, + { + id: "maven", + title: "Maven Central", + ecosystem: "Java, Kotlin", + tint: "pink", + aliases: ["maven", "mvn", "java", "kotlin", "gradle"], + example: "guava", + webSearch: (q) => `https://central.sonatype.com/search?q=${encodeURIComponent(q)}`, + }, + { + id: "hex", + title: "Hex", + ecosystem: "Elixir, Erlang", + tint: "purple", + aliases: ["hex", "elixir", "erlang", "mix"], + example: "phoenix", + webSearch: (q) => `https://hex.pm/packages?search=${encodeURIComponent(q)}`, + }, + { + id: "pub", + title: "pub.dev", + ecosystem: "Dart, Flutter", + tint: "teal", + aliases: ["pub", "dart", "flutter"], + example: "http", + webSearch: (q) => `https://pub.dev/packages?q=${encodeURIComponent(q)}`, + }, + { + id: "nuget", + title: "NuGet", + ecosystem: ".NET", + tint: "blue", + aliases: ["nuget", "dotnet", "csharp", "c#"], + example: "Newtonsoft.Json", + webSearch: (q) => `https://www.nuget.org/packages?q=${encodeURIComponent(q)}`, + }, +]; + +export const registry = (id: string): Registry | undefined => REGISTRIES.find((r) => r.id === id); + +/** `cargo serde` → crates.io and "serde"; anything else uses `fallback`. */ +export function parseQuery(input: string, fallback: RegistryId): { registry: RegistryId; query: string } { + const m = input.trim().match(/^(\S+)\s+([\s\S]+)$/); + if (m) { + const hit = REGISTRIES.find((r) => r.aliases.includes(m[1].toLowerCase())); + if (hit) return { registry: hit.id, query: m[2].trim() }; + } + return { registry: fallback, query: input.trim() }; +} + +export interface Package { + registry: RegistryId; + name: string; + version?: string; + description?: string; + license?: string; + downloads?: number; + /** "all time", "last 90 days"… */ + downloadsPeriod?: string; + /** ISO date of the latest release or update. */ + updated?: string; + page: string; + homepage?: string; + repository?: string; + docs?: string; + keywords?: string[]; + /** [title, text]: the first is the main install command. */ + install: [string, string][]; + /** More [label, value] lines for the side panel. */ + facts?: [string, string][]; + /** The registry says it's the exact name typed. */ + exact?: boolean; + deprecated?: string; +} + +const clean = (s: unknown): string | undefined => { + const t = typeof s === "string" ? s.replace(/\s+/g, " ").trim() : ""; + return t || undefined; +}; + +const url = (s: unknown): string | undefined => { + const t = clean(s); + return t && /^https?:\/\//.test(t) ? t.replace(/\.git$/, "").replace(/^git\+/, "") : undefined; +}; + +// --- crates.io ----------------------------------------------------------------- + +export function mapCrates(json: any): Package[] { + return (json?.crates ?? []).map((c: any): Package => { + const version = c.max_stable_version ?? c.default_version ?? c.max_version; + return { + registry: "crates", + name: c.name, + version, + description: clean(c.description), + downloads: c.downloads, + downloadsPeriod: "all time", + updated: c.updated_at, + page: `https://crates.io/crates/${c.name}`, + homepage: url(c.homepage), + repository: url(c.repository), + docs: url(c.documentation) ?? `https://docs.rs/${c.name}`, + install: [ + ["cargo add", `cargo add ${c.name}`], + ["Cargo.toml", `${c.name} = "${version}"`], + ], + facts: [ + ...(c.recent_downloads + ? [["Recent downloads", `${compact(c.recent_downloads)} in 90 days`] as [string, string]] + : []), + ...(c.num_versions ? [["Versions", String(c.num_versions)] as [string, string]] : []), + ], + exact: c.exact_match === true, + deprecated: c.yanked ? "Yanked" : undefined, + }; + }); +} + +// --- PyPI ------------------------------------------------------------------------ + +/** PEP 503 normalization: `Foo_Bar.baz` → `foo-bar-baz`. */ +export const normalizePython = (name: string) => name.trim().toLowerCase().replace(/[-_.]+/g, "-"); + +/** Names worth an exact lookup for what the user typed. */ +export function pypiCandidates(query: string): string[] { + const base = normalizePython(query.replace(/\s+/g, "-")); + if (!/^[a-z0-9][a-z0-9-]*$/.test(base)) return []; + const names = [base, `python-${base}`, `${base}-python`, `py${base}`]; + return [...new Set(names.filter((n) => n !== "py" && !/^python-python/.test(n)))]; +} + +export function mapPypi(json: any): Package { + const i = json.info; + const urls: Record = i.project_urls ?? {}; + const find = (re: RegExp) => Object.entries(urls).find(([k]) => re.test(k))?.[1]; + const uploaded = (json.urls ?? []).map((u: any) => u.upload_time_iso_8601).filter(Boolean).sort().pop(); + const license = clean(i.license_expression) ?? + (clean(i.license) && i.license.length < 40 ? clean(i.license) : undefined) ?? + (i.classifiers ?? []).find((c: string) => c.startsWith("License :: OSI Approved :: "))?.replace( + "License :: OSI Approved :: ", + "", + ); + const python = clean(i.requires_python); + return { + registry: "pypi", + name: i.name, + version: i.version, + description: clean(i.summary), + license, + updated: uploaded, + page: i.package_url ?? `https://pypi.org/project/${i.name}/`, + homepage: url(i.home_page) ?? url(find(/^home/i)), + repository: url(find(/source|repo|github|code/i)), + docs: url(i.docs_url) ?? url(find(/doc/i)), + keywords: clean(i.keywords)?.split(/[,\s]+/).filter(Boolean).slice(0, 8), + install: [ + ["pip install", `pip install ${i.name}`], + ["uv add", `uv add ${i.name}`], + ["poetry add", `poetry add ${i.name}`], + ["requirements.txt", `${i.name}==${i.version}`], + ], + facts: [ + ...(python ? [["Python", python] as [string, string]] : []), + ...(clean(i.author) ? [["Author", clean(i.author)!] as [string, string]] : []), + ], + exact: true, + deprecated: i.yanked ? `Yanked${i.yanked_reason ? `: ${i.yanked_reason}` : ""}` : undefined, + }; +} + +// --- Go -------------------------------------------------------------------------- + +/** The module proxy's case encoding: `github.com/BurntSushi/toml` → `github.com/!burnt!sushi/toml`. */ +export const escapeModule = (path: string) => path.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`); + +export const looksLikeModule = (q: string) => /^[a-z0-9.-]+\.[a-z]{2,}(\/[\w.~-]+)*$/i.test(q.trim()); + +export function mapGo(module: string, latest: any): Package { + const repo = url(latest?.Origin?.URL); + return { + registry: "go", + name: module, + version: latest?.Version, + updated: latest?.Time, + page: `https://pkg.go.dev/${module}`, + repository: repo, + docs: `https://pkg.go.dev/${module}`, + install: [ + ["go get", `go get ${module}@${latest?.Version ?? "latest"}`], + ["import", `import "${module}"`], + ], + facts: latest?.Origin?.Hash ? [["Commit", String(latest.Origin.Hash).slice(0, 12)]] : [], + exact: true, + }; +} + +// --- RubyGems -------------------------------------------------------------------- + +export function mapGems(json: any): Package[] { + return (Array.isArray(json) ? json : []).map((g: any): Package => ({ + registry: "gems", + name: g.name, + version: g.version, + description: clean(g.info), + license: (g.licenses ?? []).join(", ") || undefined, + downloads: g.downloads, + downloadsPeriod: "all time", + page: g.project_uri ?? `https://rubygems.org/gems/${g.name}`, + homepage: url(g.homepage_uri), + repository: url(g.source_code_uri), + docs: url(g.documentation_uri), + install: [ + ["gem install", `gem install ${g.name}`], + ["bundle add", `bundle add ${g.name}`], + ["Gemfile", `gem "${g.name}", "~> ${String(g.version ?? "").split(".").slice(0, 2).join(".")}"`], + ], + facts: [ + ...(clean(g.authors) ? [["Authors", clean(g.authors)!] as [string, string]] : []), + ...(g.version_downloads + ? [["This version", `${compact(g.version_downloads)} downloads`] as [string, string]] + : []), + ], + })); +} + +// --- Packagist ------------------------------------------------------------------- + +/** The newest stable version of a `repo.packagist.org/p2/.json` file. */ +export function packagistLatest(p2: any, name: string): any | undefined { + const versions: any[] = p2?.packages?.[name] ?? []; + return versions.find((v) => !/dev|alpha|beta|rc/i.test(v.version)) ?? versions[0]; +} + +export function mapPackagist(json: any, latest: Record = {}): Package[] { + return (json?.results ?? []).filter((r: any) => !r.virtual).map((r: any): Package => { + const v = latest[r.name]; + const version = v?.version?.replace(/^v/, ""); + return { + registry: "packagist", + name: r.name, + version, + description: clean(r.description), + license: v?.license?.join(", ") || undefined, + downloads: r.downloads, + downloadsPeriod: "all time", + updated: v?.time, + page: r.url ?? `https://packagist.org/packages/${r.name}`, + homepage: url(v?.homepage), + repository: url(r.repository), + install: [ + ["composer require", `composer require ${r.name}`], + ...(version + ? [["composer.json", `"${r.name}": "^${version.split(".").slice(0, 2).join(".")}"`] as [string, string]] + : []), + ], + facts: r.favers ? [["Stars", compact(r.favers)]] : [], + deprecated: r.abandoned ? `Abandoned${typeof r.abandoned === "string" ? `: use ${r.abandoned}` : ""}` : undefined, + }; + }); +} + +// --- Maven Central --------------------------------------------------------------- + +/** `com.google.guava:guava` searches that exact artifact; words search everything. */ +export function mavenQuery(q: string): string { + const m = q.trim().match(/^([\w.-]+):([\w.-]+)$/); + return m ? `g:"${m[1]}" AND a:"${m[2]}"` : q.trim(); +} + +export function mapMaven(json: any): Package[] { + return (json?.response?.docs ?? []).map((d: any): Package => { + const version = d.latestVersion ?? d.v; + const coords = `${d.g}:${d.a}`; + return { + registry: "maven", + name: coords, + version, + updated: d.timestamp ? new Date(d.timestamp).toISOString() : undefined, + page: `https://central.sonatype.com/artifact/${d.g}/${d.a}`, + install: [ + ["Gradle (Kotlin)", `implementation("${coords}:${version}")`], + ["Gradle (Groovy)", `implementation '${coords}:${version}'`], + [ + "Maven", + `\n ${d.g}\n ${d.a}\n ${version}\n`, + ], + ], + facts: [ + ["Group", d.g], + ["Artifact", d.a], + ...(d.p ? [["Packaging", d.p] as [string, string]] : []), + ...(d.versionCount ? [["Versions", String(d.versionCount)] as [string, string]] : []), + ], + }; + }); +} + +// --- Hex ------------------------------------------------------------------------- + +export function mapHex(json: any): Package[] { + return (Array.isArray(json) ? json : []).map((p: any): Package => { + const version = p.latest_stable_version ?? p.latest_version; + const links: Record = p.meta?.links ?? {}; + const repo = Object.entries(links).find(([k]) => /github|gitlab|source|codeberg/i.test(k))?.[1]; + return { + registry: "hex", + name: p.name, + version, + description: clean(p.meta?.description), + license: (p.meta?.licenses ?? []).join(", ") || undefined, + downloads: p.downloads?.all, + downloadsPeriod: "all time", + updated: p.updated_at, + page: p.html_url ?? `https://hex.pm/packages/${p.name}`, + repository: url(repo), + docs: url(p.docs_html_url), + install: [ + ["mix.exs", p.configs?.["mix.exs"] ?? `{:${p.name}, "~> ${version}"}`], + ...(p.configs?.["rebar.config"] ? [["rebar.config", p.configs["rebar.config"]] as [string, string]] : []), + ], + facts: p.downloads?.recent ? [["Recent downloads", `${compact(p.downloads.recent)} in 90 days`]] : [], + deprecated: p.retirements && Object.keys(p.retirements).includes(version) ? "This version is retired" : undefined, + }; + }); +} + +// --- pub.dev --------------------------------------------------------------------- + +export const pubNames = (json: any): string[] => (json?.packages ?? []).map((p: any) => p.package); + +export function mapPub(pkg: any, score?: any): Package { + const spec = pkg.latest?.pubspec ?? {}; + const license = (score?.tags ?? []).find((t: string) => t.startsWith("license:") && !/fsf|osi/.test(t)) + ?.slice(8).toUpperCase(); + const flutter = (score?.tags ?? []).includes("sdk:flutter") && !(score?.tags ?? []).includes("sdk:dart"); + return { + registry: "pub", + name: pkg.name, + version: pkg.latest?.version, + description: clean(spec.description), + license, + downloads: score?.downloadCount30Days, + downloadsPeriod: "last 30 days", + updated: pkg.latest?.published, + page: `https://pub.dev/packages/${pkg.name}`, + homepage: url(spec.homepage), + repository: url(spec.repository), + docs: `https://pub.dev/documentation/${pkg.name}/latest/`, + keywords: spec.topics, + install: [ + [flutter ? "flutter pub add" : "dart pub add", `${flutter ? "flutter" : "dart"} pub add ${pkg.name}`], + ...(flutter ? [] : [["flutter pub add", `flutter pub add ${pkg.name}`] as [string, string]]), + ["pubspec.yaml", `${pkg.name}: ^${pkg.latest?.version}`], + ], + facts: [ + ...(score?.likeCount !== undefined ? [["Likes", compact(score.likeCount)] as [string, string]] : []), + ...(score?.grantedPoints !== undefined + ? [["Pub points", `${score.grantedPoints} / ${score.maxPoints}`] as [string, string]] + : []), + ...((score?.tags ?? []).find((t: string) => t.startsWith("publisher:")) + ? [["Publisher", score.tags.find((t: string) => t.startsWith("publisher:")).slice(10)] as [string, string]] + : []), + ], + deprecated: pkg.isDiscontinued ? `Discontinued${pkg.replacedBy ? `: use ${pkg.replacedBy}` : ""}` : undefined, + }; +} + +// --- NuGet ----------------------------------------------------------------------- + +export function mapNuget(json: any): Package[] { + return (json?.data ?? []).map((p: any): Package => ({ + registry: "nuget", + name: p.id, + version: p.version, + description: clean(p.description)?.split("\n")[0], + license: /licenses\.nuget\.org\/(.+)$/.exec(p.licenseUrl ?? "")?.[1], + downloads: p.totalDownloads, + downloadsPeriod: "all time", + page: `https://www.nuget.org/packages/${p.id}`, + homepage: url(p.projectUrl), + keywords: (p.tags ?? []).slice(0, 8), + install: [ + ["dotnet add package", `dotnet add package ${p.id}`], + ["PackageReference", ``], + ["Package Manager", `Install-Package ${p.id}`], + ], + facts: [ + ...((p.owners ?? []).length ? [["Owners", [].concat(p.owners).join(", ")] as [string, string]] : []), + ...(p.verified ? [["Prefix", "Verified owner"] as [string, string]] : []), + ], + deprecated: p.deprecation ? `Deprecated${p.deprecation.message ? `: ${p.deprecation.message}` : ""}` : undefined, + })); +} + +// --- Formatting ------------------------------------------------------------------ + +/** 1234 → "1.2k", 1_602_431_895 → "1.6B". */ +export function compact(n: number): string { + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`; + return String(n); +} + +export function relativeDate(iso: string | undefined, now = new Date()): string | undefined { + if (!iso) return undefined; + const d = new Date(iso); + if (isNaN(d.getTime())) return undefined; + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days} days ago`; + if (days < 365) return `${Math.round(days / 30)} month${Math.round(days / 30) > 1 ? "s" : ""} ago`; + const years = Math.round(days / 365); + return `${years} year${years > 1 ? "s" : ""} ago`; +} + +/** Exact names first, then the registry's own order. */ +export function rank(packages: Package[], query: string): Package[] { + const q = query.trim().toLowerCase(); + return packages.map((p, i) => ({ p, i, exact: p.exact || p.name.toLowerCase() === q })) + .sort((a, b) => Number(b.exact) - Number(a.exact) || a.i - b.i).map((x) => x.p); +} diff --git a/extensions/package-registries/lib_test.ts b/extensions/package-registries/lib_test.ts new file mode 100644 index 0000000..eea69fb --- /dev/null +++ b/extensions/package-registries/lib_test.ts @@ -0,0 +1,436 @@ +import { assertEquals } from "@std/assert"; +import { + compact, + escapeModule, + looksLikeModule, + mapCrates, + mapGems, + mapGo, + mapHex, + mapMaven, + mapNuget, + mapPackagist, + mapPub, + mapPypi, + mavenQuery, + normalizePython, + packagistLatest, + parseQuery, + pubNames, + pypiCandidates, + rank, + relativeDate, +} from "./lib.ts"; + +// Trimmed real responses (October 2026); Maven Central and NuGet follow their documented shapes. +const CRATES = { + "crates": [{ + "id": "serde", + "name": "serde", + "updated_at": "2026-07-18T23:05:13.266456Z", + "versions": null, + "keywords": null, + "categories": null, + "badges": [], + "created_at": "2014-12-05T20:20:39.487502Z", + "downloads": 1602431895, + "recent_downloads": 386912775, + "default_version": "1.0.229", + "num_versions": 316, + "yanked": false, + "max_version": "1.0.229", + "newest_version": "1.0.229", + "max_stable_version": "1.0.229", + "description": "A generic serialization/deserialization framework", + "homepage": "https://serde.rs", + "documentation": "https://docs.rs/serde", + "repository": "https://github.com/serde-rs/serde", + "exact_match": true, + "trustpub_only": false, + }], + "meta": { "total": 24467 }, +}; +const PYPI = { + "info": { + "name": "requests", + "version": "2.34.2", + "summary": "Python HTTP for Humans.", + "license": "Apache-2.0", + "license_expression": null, + "home_page": null, + "project_urls": { "Documentation": "https://requests.readthedocs.io", "Source": "https://github.com/psf/requests" }, + "requires_python": ">=3.10", + "author": null, + "keywords": null, + "package_url": "https://pypi.org/project/requests/", + "docs_url": null, + "yanked": false, + "yanked_reason": null, + "classifiers": ["License :: OSI Approved :: Apache Software License"], + }, + "urls": [{ "upload_time_iso_8601": "2026-05-14T19:25:26.443000Z" }, { + "upload_time_iso_8601": "2026-05-14T19:25:27.735762Z", + }], +}; +const GO = { + "Version": "v5.5.0", + "Time": "2026-10-14T13:20:04Z", + "Origin": { + "VCS": "git", + "URL": "https://github.com/go-chi/chi", + "Hash": "3a33ba578e0dd93444e0ff386cc26e665d92e31e", + "Ref": "refs/tags/v5.5.0", + }, +}; +const GEMS = [{ + "documentation_uri": "https://api.rubyonrails.org/v8.1.4/", + "homepage_uri": "https://rubyonrails.org", + "funding_uri": null, + "bug_tracker_uri": "https://github.com/rails/rails/issues", + "project_uri": "https://rubygems.org/gems/rails", + "version": "8.1.4", + "platform": "ruby", + "changelog_uri": "https://github.com/rails/rails/releases/tag/v8.1.4", + "source_code_uri": "https://github.com/rails/rails/tree/v8.1.4", + "licenses": ["MIT"], + "gem_uri": "https://rubygems.org/gems/rails-8.1.4.gem", + "downloads": 807907647, + "mailing_list_uri": "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "name": "rails", + "wiki_uri": null, + "version_downloads": 3197458, + "info": + "Ruby on Rails is a full-stack web framework optimized for programmer happiness and sustainable productivity. It encourages beautiful code by favoring convention over configuration.", + "authors": "David Heinemeier Hansson", +}]; +const PACKAGIST = { + "results": [{ + "name": "laravel/framework", + "description": "The Laravel Framework.", + "url": "https://packagist.org/packages/laravel/framework", + "repository": "https://github.com/laravel/framework", + "downloads": 606007207, + "favers": 35474, + }, { + "name": "spatie/laravel-permission", + "description": "Permission handling for Laravel 12 and up", + "url": "https://packagist.org/packages/spatie/laravel-permission", + "repository": "https://github.com/spatie/laravel-permission", + "downloads": 128373642, + "favers": 13145, + }], + "total": 99085, + "next": "https://packagist.org/search.json?q=laravel&page=2&per_page=2", +}; +const P2 = { + "packages": { + "laravel/framework": [{ "version": "v13.40.0-beta1" }, { + "version": "v13.39.0", + "license": ["MIT"], + "time": "2026-10-27T14:04:39+00:00", + "homepage": "https://laravel.com", + }], + }, +}; +const HEX = [{ + "meta": { + "links": { + "Changelog": "https://phoenix.hexdocs.pm/changelog.html", + "GitHub": "https://github.com/phoenixframework/phoenix", + }, + "description": "Peace of mind from prototype to production", + "licenses": ["MIT"], + "maintainers": [], + }, + "name": "phoenix", + "url": "https://hex.pm/api/packages/phoenix", + "inserted_at": "2014-04-21T22:38:32.000000Z", + "updated_at": "2026-10-20T10:33:08.398912Z", + "repository": "hexpm", + "html_url": "https://hex.pm/packages/phoenix", + "latest_version": "1.8.16", + "downloads": { "all": 158653497, "day": 97873, "recent": 5919337, "week": 615197 }, + "docs_html_url": "https://phoenix.hexdocs.pm/", + "retirements": {}, + "configs": { + "erlang.mk": "dep_phoenix = hex 1.8.16", + "mix.exs": '{:phoenix, "~> 1.8"}', + "rebar.config": '{phoenix, "1.8.16"}', + }, + "latest_stable_version": "1.8.16", +}]; +const PUB = { + "name": "http", + "latest": { + "version": "1.6.0", + "published": "2025-11-10T18:27:56.434747Z", + "pubspec": { + "name": "http", + "description": "A composable, multi-platform, Future-based API for HTTP requests.", + "repository": "https://github.com/dart-lang/http/tree/master/pkgs/http", + "topics": ["http", "network", "protocols"], + }, + }, +}; +const PUB_SCORE = { + "grantedPoints": 160, + "maxPoints": 160, + "likeCount": 8480, + "downloadCount30Days": 13858269, + "tags": [ + "publisher:dart.dev", + "has:topic", + "sdk:dart", + "sdk:flutter", + "platform:android", + "platform:ios", + "platform:windows", + "platform:linux", + "platform:macos", + "platform:web", + "runtime:native-aot", + "runtime:native-jit", + "runtime:web", + "is:null-safe", + "is:wasm-ready", + "is:dart3-compatible", + "license:bsd-3-clause", + "license:fsf-libre", + "license:osi-approved", + "topic:http", + "topic:network", + "topic:protocols", + ], +}; +const MAVEN = { + "responseHeader": { "status": 0 }, + "response": { + "numFound": 2, + "start": 0, + "docs": [{ + "id": "com.google.guava:guava", + "g": "com.google.guava", + "a": "guava", + "latestVersion": "33.5.0-jre", + "repositoryId": "central", + "p": "bundle", + "timestamp": 1758114632000, + "versionCount": 122, + "text": ["com.google.guava", "guava", "-sources.jar", ".jar", ".pom"], + "ec": ["-sources.jar", ".jar", ".pom"], + }, { + "id": "com.google.guava:guava-testlib", + "g": "com.google.guava", + "a": "guava-testlib", + "latestVersion": "33.5.0-jre", + "repositoryId": "central", + "p": "jar", + "timestamp": 1758114631000, + "versionCount": 98, + }], + }, +}; +const NUGET = { + "totalHits": 2, + "data": [{ + "@id": "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json", + "@type": "Package", + "registration": "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json", + "id": "Newtonsoft.Json", + "version": "13.0.4", + "description": "Json.NET is a popular high-performance JSON framework for .NET\nSecond line", + "summary": "", + "title": "Json.NET", + "iconUrl": "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.4/icon", + "licenseUrl": "https://www.nuget.org/packages/Newtonsoft.Json/13.0.4/license", + "projectUrl": "https://www.newtonsoft.com/json", + "tags": ["json"], + "authors": ["James Newton-King"], + "owners": ["dotnetfoundation", "jamesnk", "newtonsoft"], + "totalDownloads": 7006651044, + "verified": true, + "packageTypes": [{ "name": "Dependency" }], + "versions": [], + }, { + "id": "System.Text.Json", + "version": "9.0.10", + "description": "Provides high-performance and low-allocating types.", + "licenseUrl": "https://licenses.nuget.org/MIT", + "projectUrl": "https://dot.net/", + "tags": [], + "owners": "Microsoft", + "totalDownloads": 4100000000, + "verified": true, + }], +}; + +Deno.test("registry prefixes", () => { + assertEquals(parseQuery("cargo serde", "pypi"), { registry: "crates", query: "serde" }); + assertEquals(parseQuery("pip install-tools", "crates"), { registry: "pypi", query: "install-tools" }); + assertEquals(parseQuery("go github.com/go-chi/chi", "crates"), { registry: "go", query: "github.com/go-chi/chi" }); + assertEquals(parseQuery(" rails ", "gems"), { registry: "gems", query: "rails" }); + assertEquals(parseQuery("serde json", "crates"), { registry: "crates", query: "serde json" }); + assertEquals(parseQuery("nuget", "crates"), { registry: "crates", query: "nuget" }); +}); + +Deno.test("crates.io", () => { + const [serde] = mapCrates(CRATES); + assertEquals([serde.name, serde.version, serde.downloads, serde.exact], ["serde", "1.0.229", 1602431895, true]); + assertEquals([serde.homepage, serde.repository, serde.docs], [ + "https://serde.rs", + "https://github.com/serde-rs/serde", + "https://docs.rs/serde", + ]); + assertEquals(serde.install, [["cargo add", "cargo add serde"], ["Cargo.toml", 'serde = "1.0.229"']]); + assertEquals(serde.facts?.[0], ["Recent downloads", "387M in 90 days"]); + assertEquals(mapCrates({}), []); +}); + +Deno.test("PyPI: exact lookups", () => { + assertEquals(pypiCandidates("Requests"), ["requests", "python-requests", "requests-python", "pyrequests"]); + assertEquals(pypiCandidates("python dateutil")[0], "python-dateutil"); + assertEquals(pypiCandidates("??"), []); + assertEquals(normalizePython("Foo_Bar.baz"), "foo-bar-baz"); + const p = mapPypi(PYPI); + assertEquals([p.name, p.version, p.description, p.license], [ + "requests", + "2.34.2", + "Python HTTP for Humans.", + "Apache-2.0", + ]); + assertEquals([p.repository, p.docs, p.page], [ + "https://github.com/psf/requests", + "https://requests.readthedocs.io", + "https://pypi.org/project/requests/", + ]); + assertEquals(p.install[0], ["pip install", "pip install requests"]); + assertEquals(p.install[3], ["requirements.txt", "requests==2.34.2"]); + assertEquals(p.facts, [["Python", ">=3.10"]]); + // The license falls back to the classifiers. + const old = mapPypi({ info: { ...PYPI.info, license: null, license_expression: null }, urls: [] }); + assertEquals(old.license, "Apache Software License"); +}); + +Deno.test("Go modules", () => { + assertEquals(escapeModule("github.com/BurntSushi/toml"), "github.com/!burnt!sushi/toml"); + assertEquals([ + looksLikeModule("github.com/go-chi/chi/v5"), + looksLikeModule("golang.org/x/net"), + looksLikeModule("chi"), + ], [ + true, + true, + false, + ]); + const m = mapGo("github.com/go-chi/chi/v5", GO); + assertEquals([m.version, m.repository, m.page], [ + "v5.5.0", + "https://github.com/go-chi/chi", + "https://pkg.go.dev/github.com/go-chi/chi/v5", + ]); + assertEquals(m.install, [["go get", "go get github.com/go-chi/chi/v5@v5.5.0"], [ + "import", + 'import "github.com/go-chi/chi/v5"', + ]]); +}); + +Deno.test("RubyGems", () => { + const [rails] = mapGems(GEMS); + assertEquals([rails.name, rails.version, rails.license, rails.downloads], ["rails", "8.1.4", "MIT", 807907647]); + assertEquals(rails.install, [ + ["gem install", "gem install rails"], + ["bundle add", "bundle add rails"], + ["Gemfile", 'gem "rails", "~> 8.1"'], + ]); + assertEquals(rails.repository, "https://github.com/rails/rails/tree/v8.1.4"); +}); + +Deno.test("Packagist with versions from the metadata files", () => { + const latest = { "laravel/framework": packagistLatest(P2, "laravel/framework") }; + const [laravel, spatie] = mapPackagist(PACKAGIST, latest); + assertEquals([laravel.name, laravel.version, laravel.license, laravel.downloads], [ + "laravel/framework", + "13.39.0", + "MIT", + 606007207, + ]); + assertEquals(laravel.install, [["composer require", "composer require laravel/framework"], [ + "composer.json", + '"laravel/framework": "^13.39"', + ]]); + assertEquals([spatie.version, spatie.install.length], [undefined, 1]); + assertEquals(laravel.facts, [["Stars", "35k"]]); +}); + +Deno.test("Maven Central", () => { + assertEquals(mavenQuery("com.google.guava:guava"), 'g:"com.google.guava" AND a:"guava"'); + assertEquals(mavenQuery(" guava "), "guava"); + const [guava] = mapMaven(MAVEN); + assertEquals([guava.name, guava.version, guava.page], [ + "com.google.guava:guava", + "33.5.0-jre", + "https://central.sonatype.com/artifact/com.google.guava/guava", + ]); + assertEquals(guava.install[0], ["Gradle (Kotlin)", 'implementation("com.google.guava:guava:33.5.0-jre")']); + assertEquals( + guava.install[2][1], + "\n com.google.guava\n guava\n 33.5.0-jre\n", + ); + assertEquals(guava.updated, "2025-09-17T13:10:32.000Z"); +}); + +Deno.test("Hex", () => { + const [phoenix] = mapHex(HEX); + assertEquals([phoenix.version, phoenix.license, phoenix.downloads, phoenix.repository], [ + "1.8.16", + "MIT", + 158653497, + "https://github.com/phoenixframework/phoenix", + ]); + assertEquals(phoenix.install[0], ["mix.exs", '{:phoenix, "~> 1.8"}']); +}); + +Deno.test("pub.dev", () => { + assertEquals(pubNames({ packages: [{ package: "http" }, { package: "dio" }] }), ["http", "dio"]); + const http = mapPub(PUB, PUB_SCORE); + assertEquals([http.version, http.license, http.downloadsPeriod], ["1.6.0", "BSD-3-CLAUSE", "last 30 days"]); + assertEquals(http.install[0], ["dart pub add", "dart pub add http"]); + assertEquals(http.install[2], ["pubspec.yaml", "http: ^1.6.0"]); + assertEquals(http.facts?.map((f) => f[0]), ["Likes", "Pub points", "Publisher"]); + assertEquals(mapPub(PUB).downloads, undefined); +}); + +Deno.test("NuGet", () => { + const [newtonsoft, stj] = mapNuget(NUGET); + assertEquals([newtonsoft.name, newtonsoft.description, newtonsoft.license], [ + "Newtonsoft.Json", + "Json.NET is a popular high-performance JSON framework for .NET Second line", + undefined, + ]); + assertEquals(newtonsoft.install[0], ["dotnet add package", "dotnet add package Newtonsoft.Json"]); + assertEquals(newtonsoft.facts, [["Owners", "dotnetfoundation, jamesnk, newtonsoft"], ["Prefix", "Verified owner"]]); + assertEquals([stj.license, stj.facts?.[0]], ["MIT", ["Owners", "Microsoft"]]); +}); + +Deno.test("formatting and ranking", () => { + assertEquals([compact(950), compact(1234), compact(45_600), compact(2_300_000), compact(1_602_431_895)], [ + "950", + "1.2k", + "46k", + "2.3M", + "1.6B", + ]); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals( + [ + relativeDate("2026-10-28T08:00:00Z", now), + relativeDate("2026-10-20T08:00:00Z", now), + relativeDate("2026-05-01T00:00:00Z", now), + relativeDate("2023-01-01", now), + relativeDate(undefined, now), + ], + ["today", "8 days ago", "6 months ago", "4 years ago", undefined], + ); + const pkgs = mapGems([{ name: "rails-i18n" }, { name: "Rails" }]); + assertEquals(rank(pkgs, "rails").map((p) => p.name), ["Rails", "rails-i18n"]); +}); diff --git a/extensions/regex/backslash.toml b/extensions/regex/backslash.toml new file mode 100644 index 0000000..4b1c3b4 --- /dev/null +++ b/extensions/regex/backslash.toml @@ -0,0 +1,28 @@ +[extension] +id = "regex" +title = "Regex Tester" +description = "Test JavaScript regular expressions with groups, replacements, explanations and a library of common patterns" +version = "1.0.0" +author = "Backslash" +icon = "bs-braces" +license = "MIT" + +# No permission: patterns run locally on text you type or save. + +[[commands]] +name = "regex" +title = "Regex Tester" +description = "Type /pattern/flags and a text: matches, numbered and named groups, replace preview, token by token" +icon = "bs-braces" +keywords = [ + "regex", + "regexp", + "regular expression", + "pattern", + "match", + "replace", + "test", + "expression régulière", + "motif", + "remplacer", +] diff --git a/extensions/regex/commands/regex.ts b/extensions/regex/commands/regex.ts new file mode 100644 index 0000000..4104d35 --- /dev/null +++ b/extensions/regex/commands/regex.ts @@ -0,0 +1,333 @@ +import { + type Action, + action, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + replace, + storage, +} from "@backslash/api"; +import { + compile, + constructor, + context, + explainTokens, + findMatches, + FLAGS, + groupNames, + highlight, + LIBRARY, + literal, + parseQuery, + type Query, + type Recipe, + replacePreview, + SAMPLE_TEXT, +} from "../lib.ts"; + +interface Saved { + text: string; + replacement?: string; +} + +async function saved(): Promise { + const text = await storage.get("text").catch(() => undefined); + const replacement = await storage.get("replacement").catch(() => undefined); + return { text: text ?? SAMPLE_TEXT, replacement: replacement ?? undefined }; +} + +const fence = (text: string) => { + const shown = text.length > 8000 ? `${text.slice(0, 8000)}\n…` : text; + return `\`\`\`\n${shown}\n\`\`\``; +}; +const oneLine = (text: string, max = 90) => { + const line = text.replace(/\n/g, "↵").replace(/\t/g, "→"); + return line.length > max ? `${line.slice(0, max)}…` : line; +}; +const plural = (n: number, one: string, many = `${one}es`) => `${n} ${n === 1 ? one : many}`; + +function editForm(q: Query | undefined, s: Saved): FormSpec { + return form({ + title: "Test Text", + submitTitle: "Test", + fields: [ + field.text("pattern", "Pattern", { value: q?.pattern ?? "", placeholder: String.raw`\b\w+@\w+\.\w+\b` }), + field.text("flags", "Flags", { value: q?.flags ?? "g", placeholder: "g, i, m, s, u, y, d, v" }), + field.textArea("text", "Test text", { value: q?.text ?? s.text, required: true }), + field.text("replacement", "Replacement", { + value: q?.replacement ?? s.replacement ?? "", + placeholder: "Optional: $1, $, $& (empty for no preview)", + }), + ], + async submit(values) { + const text = String(values.text ?? ""); + const replacement = String(values.replacement ?? "") || undefined; + await storage.set("text", text); + if (replacement === undefined) await storage.delete("replacement"); + else await storage.set("replacement", replacement); + const pattern = String(values.pattern ?? ""); + const next: Query = { pattern, flags: String(values.flags ?? "").trim(), literal: true, text, replacement }; + return replace(pattern ? resultsList(next, { text, replacement }) : home({ text, replacement })); + }, + }); +} + +function patternActions(pattern: string, flags: string): Action[] { + return [ + copy(literal(pattern, flags), "Copy as Regex Literal"), + copy(pattern, "Copy Pattern", { shortcut: "c" }), + copy(constructor(pattern, flags), "Copy as new RegExp()", { icon: Icons.code, section: "Copy" }), + copy(JSON.stringify(pattern), "Copy as JSON String", { icon: Icons.braces, section: "Copy" }), + ]; +} + +function resultItems(q: Query, s: Saved): Item[] { + const text = q.text ?? s.text; + const replacement = q.replacement ?? s.replacement; + const edit = action("Edit Pattern and Test Text…", () => editForm(q, s), { icon: Icons.text, shortcut: "e" }); + const tokens = explainTokens(q.pattern, q.flags); + const tokenItems: Item[] = tokens.slice(0, 60).map((t, i) => ({ + id: `token:${i}`, + title: t.text, + subtitle: t.description, + icon: { source: Icons.chevronRight, tint: t.kind === "group" ? "purple" : t.kind === "class" ? "teal" : "gray" }, + section: "Explanation", + actions: [copy(t.text, "Copy Token"), edit], + })); + let re: RegExp; + try { + re = compile(q.pattern, q.flags); + } catch (e) { + return [{ + id: "error", + title: (e as Error).message, + subtitle: literal(q.pattern, q.flags), + icon: { source: Icons.alert, tint: "red" }, + section: "Result", + detail: { markdown: `## Invalid pattern\n\n${(e as Error).message}\n\n${fence(literal(q.pattern, q.flags))}` }, + actions: [edit, copy(q.pattern, "Copy Pattern")], + }, ...tokenItems]; + } + const { matches, truncated } = findMatches(re, text); + const groupCount = groupNames(q.pattern).length; + const all = re.global || re.sticky; + const shownFlags = q.flags.replace("d", ""); + const flagTags = [...q.flags].map((f) => ({ text: f, color: "blue" as const })); + const count = `${truncated ? `${matches.length}+` : matches.length}`; + const items: Item[] = [{ + id: "summary", + title: matches.length ? `${count} ${matches.length === 1 ? "match" : "matches"}` : "No match", + subtitle: literal(q.pattern, shownFlags), + icon: { source: matches.length ? Icons.check : Icons.cross, tint: matches.length ? "green" : "gray" }, + section: "Result", + accessories: [ + ...(!all && matches.length + ? [{ tag: "first only", color: "orange" as const, tooltip: "Add the g flag for all" }] + : []), + { text: q.text ? "typed text" : "saved text", tooltip: "Test text" }, + ], + detail: { + stats: [ + { label: "Matches", value: count, color: matches.length ? "green" : "gray" }, + { label: "Groups", value: groupCount }, + { label: "Characters", value: [...text].length }, + ], + markdown: `${fence(highlight(text, matches))}${ + !all && matches.length ? "\n\nOnly the first match: add the **g** flag to find them all." : "" + }`, + metadata: [ + meta.text("Pattern", literal(q.pattern, shownFlags), { icon: Icons.code }), + ...(flagTags.length ? [meta.tags("Flags", flagTags)] : []), + ...[...q.flags].filter((f) => FLAGS[f]).map((f) => meta.text(f, FLAGS[f])), + ], + }, + actions: [ + ...patternActions(q.pattern, shownFlags), + edit, + ...(matches.length + ? [copy(matches.map((m) => m.text).join("\n"), "Copy All Matches", { icon: Icons.layers, section: "Copy" })] + : []), + ], + }]; + if (replacement !== undefined) { + const out = replacePreview(re, text, replacement); + items.push({ + id: "replace", + title: oneLine(out) || "(empty)", + subtitle: `→ ${JSON.stringify(replacement)}`, + icon: { source: Icons.repeat, tint: "purple" }, + section: "Replace", + detail: { + markdown: `${fence(out)}\n\n\`replace(${literal(q.pattern, shownFlags)}, ${JSON.stringify(replacement)})\`${ + all ? "" : "\n\nWithout **g**, only the first match is replaced." + }`, + }, + actions: [ + copy(out, "Copy Result"), + copy(`.replace(${literal(q.pattern, shownFlags)}, ${JSON.stringify(replacement)})`, "Copy replace() Call", { + icon: Icons.code, + }), + edit, + ], + }); + } + matches.slice(0, 100).forEach((m, i) => { + const groups = m.groups.map((g) => ({ + label: g.name ? `$${g.number} (${g.name})` : `$${g.number}`, + value: g.value === undefined ? "(not matched)" : g.value === "" ? "(empty)" : oneLine(g.value, 60), + })); + const json = JSON.stringify( + { + match: m.text, + index: m.index, + groups: m.groups.map((g) => g.value ?? null), + ...(m.groups.some((g) => g.name) + ? { named: Object.fromEntries(m.groups.filter((g) => g.name).map((g) => [g.name, g.value ?? null])) } + : {}), + }, + null, + 2, + ); + items.push({ + id: `match:${i}`, + title: m.text === "" ? "(empty match)" : oneLine(m.text), + subtitle: `${m.index}–${m.end}`, + icon: { source: Icons.dot, tint: "yellow" }, + section: "Matches", + keywords: m.groups.map((g) => g.value ?? ""), + accessories: m.groups.length + ? [{ + text: plural(m.groups.length, "group", "groups"), + tooltip: groups.map((g) => `${g.label}: ${g.value}`).join("\n"), + }] + : [], + detail: { + markdown: fence(context(text, m)), + metadata: [ + meta.text("Match", oneLine(m.text, 60) || "(empty)", { icon: Icons.search }), + meta.text("Position", `${m.index} to ${m.end} (${m.end - m.index} chars)`), + ...(groups.length ? [meta.separator(), ...groups.map((g) => meta.text(g.label, g.value))] : []), + ], + }, + actions: [ + copy(m.text, "Copy Match"), + ...m.groups.filter((g) => g.value).slice(0, 9).map((g) => + copy(g.value!, `Copy Group ${g.name ? `“${g.name}”` : g.number}`, { section: "Groups" }) + ), + copy(json, "Copy as JSON", { icon: Icons.braces, section: "Copy" }), + ], + }); + }); + if (matches.length > 100) { + items.push({ + id: "more", + title: `${matches.length - 100} more matches`, + icon: { source: Icons.info, tint: "gray" }, + section: "Matches", + actions: [copy(matches.map((m) => m.text).join("\n"), "Copy All Matches")], + }); + } + items.push(...tokenItems, textItem(q, s)); + return items; +} + +function textItem(q: Query | undefined, s: Saved): Item { + const text = q?.text ?? s.text; + return { + id: "text", + title: oneLine(text), + subtitle: q?.text ? "Typed after the pattern" : text === SAMPLE_TEXT ? "Sample text: Enter to use yours" : "Saved", + icon: { source: Icons.text, tint: "blue" }, + section: "Test Text", + accessories: [{ text: plural(text.split("\n").length, "line", "lines") }], + detail: { markdown: fence(text) }, + actions: [ + action("Edit Test Text…", () => editForm(q, s), { icon: Icons.text }), + copy(text, "Copy Test Text"), + action("Reset to Sample Text", async () => { + await storage.delete("text"); + await storage.delete("replacement"); + return replace(home({ text: SAMPLE_TEXT })); + }, { icon: Icons.refresh, section: "Danger", destructive: true, confirm: "Forget your test text?" }), + ], + }; +} + +function recipeItem(r: Recipe, s: Saved): Item { + const re = compile(r.pattern, r.flags); + const { matches } = findMatches(re, r.examples); + const q: Query = { pattern: r.pattern, flags: r.flags, literal: true }; + return { + id: `recipe:${r.id}`, + title: r.name, + subtitle: oneLine(r.pattern, 60), + icon: { source: Icons.braces, tint: "purple" }, + section: "Library", + keywords: [r.description, r.id, ...(r.keywords ?? [])], + accessories: [{ text: `/${r.flags}`, tooltip: [...r.flags].map((f) => FLAGS[f]).join("\n") }], + detail: { + markdown: [ + `## ${r.name}`, + "", + r.description, + "", + fence(literal(r.pattern, r.flags)), + "", + "**Examples**", + "", + fence(highlight(r.examples, matches)), + ].join("\n"), + }, + actions: [ + ...patternActions(r.pattern, r.flags), + action("Test on the Examples", () => resultsList({ ...q, text: r.examples }, s), { + icon: Icons.search, + shortcut: "t", + section: "Test", + }), + action("Test on My Text", () => resultsList(q, s), { icon: Icons.text, section: "Test" }), + ], + }; +} + +const library = (s: Saved, filter = "") => { + const words = filter.toLowerCase().split(/\s+/).filter(Boolean); + return LIBRARY.filter((r) => { + const hay = `${r.name} ${r.id} ${r.description} ${(r.keywords ?? []).join(" ")}`.toLowerCase(); + return words.every((w) => hay.includes(w)); + }).map((r) => recipeItem(r, s)); +}; + +const status = (items: Item[]) => { + const summary = items.find((i) => i.id === "summary" || i.id === "error"); + if (summary) return `${summary.id === "error" ? "Invalid pattern" : summary.title} · ${summary.subtitle}`; + return `${items.filter((i) => i.section === "Library").length} patterns in the library`; +}; + +function resultsList(q: Query, s: Saved): ListSpec { + return list({ title: literal(q.pattern, q.flags), items: resultItems(q, s), status }); +} + +function home(s: Saved): ListSpec { + return list({ title: "Regex Tester", items: [textItem(undefined, s), ...library(s)], status }); +} + +export default list({ + placeholder: "/pattern/flags test text, s/pattern/replacement/g, or a pattern…", + status, + async search(query) { + const s = await saved(); + const q = parseQuery(query); + if (!q) return [textItem(undefined, s), ...library(s)]; + const items = resultItems(q, s); + // Plain words also look up the library ("email", "uuid"). + if (!q.literal && /^[a-z][a-z0-9 .-]*$/i.test(q.pattern)) items.splice(1, 0, ...library(s, q.pattern)); + return items; + }, +}); diff --git a/extensions/regex/lib.ts b/extensions/regex/lib.ts new file mode 100644 index 0000000..400af94 --- /dev/null +++ b/extensions/regex/lib.ts @@ -0,0 +1,624 @@ +// Regular expressions: query parsing, matching, highlighting and a short explanation. Pure, tested in lib_test.ts. + +export interface Query { + pattern: string; + flags: string; + /** Text typed after the pattern, if any. */ + text?: string; + /** From `s/pattern/replacement/flags`. */ + replacement?: string; + /** Typed between slashes (flags respected as typed). */ + literal: boolean; +} + +/** Index of the next unescaped `/` from `start`, outside character classes; -1 if none. */ +export function closingSlash(s: string, start: number): number { + let inClass = false; + for (let i = start; i < s.length; i++) { + const c = s[i]; + if (c === "\\") i++; + else if (inClass) inClass = c !== "]"; + else if (c === "[") inClass = true; + else if (c === "/") return i; + } + return -1; +} + +function afterFlags(rest: string): { flags: string; text?: string } { + const m = rest.match(/^([a-z]*)(?:\s([\s\S]*))?$/); + if (!m) throw new Error(`Flags go right after the closing slash: “${rest.split(/\s/)[0]}”`); + return { flags: m[1], text: m[2] || undefined }; +} + +/** + * `/pattern/flags test text`, `s/pattern/replacement/flags text`, or a bare + * pattern (tested with the global flag against the saved text). + */ +export function parseQuery(input: string): Query | undefined { + const q = input.replace(/^\s+/, ""); + if (!q.trim()) return undefined; + if (q.startsWith("s/")) { + const end = closingSlash(q, 2); + const repEnd = end < 0 ? -1 : closingSlash(q, end + 1); + if (end > 0 && repEnd > 0) { + const { flags, text } = afterFlags(q.slice(repEnd + 1)); + return { pattern: q.slice(2, end), replacement: q.slice(end + 1, repEnd), flags, text, literal: true }; + } + } + if (q.startsWith("/")) { + const end = closingSlash(q, 1); + if (end > 0) return { pattern: q.slice(1, end), ...afterFlags(q.slice(end + 1)), literal: true }; + // Still typing: `/abc` is the pattern so far. + return { pattern: q.slice(1), flags: "g", literal: true }; + } + return { pattern: q.trimEnd(), flags: "g", literal: false }; +} + +/** "Invalid regular expression: /(/: Unterminated group" → "Unterminated group". */ +export function compile(pattern: string, flags: string): RegExp { + const bad = [...flags].find((f) => !"dgimsuyv".includes(f)); + if (bad) throw new Error(`Unknown flag “${bad}”: use d, g, i, m, s, u, v or y`); + try { + return new RegExp(pattern, flags.includes("d") ? flags : `${flags}d`); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error( + message.replace(/^Invalid regular expression: \/.*\/[a-z]*: /s, "").replace(/^Invalid flags.*/, "Invalid flags"), + ); + } +} + +export interface Group { + /** 1-based. */ + number: number; + name?: string; + /** Undefined when the group did not take part in the match. */ + value?: string; + start?: number; + end?: number; +} + +export interface Match { + index: number; + end: number; + text: string; + groups: Group[]; +} + +/** Every match (or just the first without `g`/`y`), at most `limit`. */ +export function findMatches(re: RegExp, text: string, limit = 500): { matches: Match[]; truncated: boolean } { + const names = groupNames(re.source); + const matches: Match[] = []; + const all = re.global || re.sticky; + re.lastIndex = 0; + while (matches.length < limit) { + const m = re.exec(text) as (RegExpExecArray & { indices?: [number, number][] }) | null; + if (!m) break; + const groups: Group[] = []; + for (let i = 1; i < m.length; i++) { + const at = m.indices?.[i]; + groups.push({ number: i, name: names[i - 1], value: m[i], start: at?.[0], end: at?.[1] }); + } + matches.push({ index: m.index, end: m.index + m[0].length, text: m[0], groups }); + if (!all) break; + // Empty matches would loop forever. + if (m[0] === "") { + const cp = text.codePointAt(re.lastIndex) ?? 0; + re.lastIndex += (re.unicode || re.unicodeSets) && cp > 0xffff ? 2 : 1; + } + if (re.lastIndex > text.length) break; + } + const truncated = matches.length === limit && all && re.exec(text) !== null; + re.lastIndex = 0; + return { matches, truncated }; +} + +/** Names of the capture groups in order, `undefined` for numbered ones. */ +export function groupNames(source: string): (string | undefined)[] { + const names: (string | undefined)[] = []; + let inClass = false; + for (let i = 0; i < source.length; i++) { + const c = source[i]; + if (c === "\\") i++; + else if (inClass) inClass = c !== "]"; + else if (c === "[") inClass = true; + else if (c === "(") { + if (source[i + 1] !== "?") names.push(undefined); + else { + const named = source.slice(i).match(/^\(\?<([A-Za-z_$][\w$]*)>/); + if (named) names.push(named[1]); + } + } + } + return names; +} + +export const OPEN = "⟦"; +export const CLOSE = "⟧"; + +/** The text with every match wrapped in ⟦…⟧. */ +export function highlight(text: string, matches: { index: number; end: number }[]): string { + let out = ""; + let at = 0; + for (const m of matches) { + if (m.index < at) continue; + out += text.slice(at, m.index) + OPEN + text.slice(m.index, m.end) + CLOSE; + at = m.end; + } + return out + text.slice(at); +} + +/** A few characters around one match, on one line, for the side panel. */ +export function context(text: string, m: { index: number; end: number }, around = 40): string { + const start = Math.max(0, m.index - around), end = Math.min(text.length, m.end + around); + return `${start > 0 ? "…" : ""}${ + highlight(text.slice(start, end), [{ index: m.index - start, end: m.end - start }]) + }${end < text.length ? "…" : ""}`; +} + +export function replacePreview(re: RegExp, text: string, replacement: string): string { + re.lastIndex = 0; + const out = text.replace(re, replacement); + re.lastIndex = 0; + return out; +} + +/** `/a\/b/gi` as a JavaScript literal. */ +export const literal = (pattern: string, flags: string) => `/${pattern.replace(/(^|[^\\])\//g, "$1\\/")}/${flags}`; + +/** `new RegExp("\\d+", "g")`. */ +export const constructor = (pattern: string, flags: string) => + `new RegExp(${JSON.stringify(pattern)}${flags ? `, ${JSON.stringify(flags)}` : ""})`; + +// --------------------------------------------------------------------------- +// Explanation + +export interface Token { + text: string; + description: string; + kind: "literal" | "class" | "escape" | "group" | "anchor" | "quantifier" | "alternation" | "backreference"; +} + +const ESCAPES: Record = { + d: "a digit (0–9)", + D: "any character except a digit", + w: "a word character (letter, digit or _)", + W: "any character except a word character", + s: "a whitespace character", + S: "any character except whitespace", + b: "a word boundary", + B: "not a word boundary", + n: "a line feed", + r: "a carriage return", + t: "a tab", + v: "a vertical tab", + f: "a form feed", + "0": "a NUL character", +}; + +const CLASS_ESCAPES: Record = { + d: "digits", + D: "non-digits", + w: "word characters", + W: "non-word characters", + s: "whitespace", + S: "non-whitespace", + n: "line feed", + r: "carriage return", + t: "tab", +}; + +function describeClass(body: string): string { + const items: string[] = []; + let i = 0; + const one = (): string => { + if (body[i] === "\\") { + const c = body[i + 1]; + i += 2; + return CLASS_ESCAPES[c] ?? `“${c}”`; + } + return `“${body[i++]}”`; + }; + while (i < body.length) { + const start = i; + const a = one(); + if (body[i] === "-" && i + 1 < body.length && body[start] !== "\\") { + i++; + const b = one(); + items.push(`${a.replace(/[“”]/g, "")}–${b.replace(/[“”]/g, "")}`); + } else items.push(a); + } + return items.join(", "); +} + +function quantifier(q: string): string { + const lazy = q.length > 1 && q.endsWith("?") ? ", as few as possible" : ""; + const base = lazy ? q.slice(0, -1) : q; + let text: string; + if (base === "*") text = "zero or more times"; + else if (base === "+") text = "one or more times"; + else if (base === "?") text = "optional"; + else { + const [, n, comma, m] = base.match(/^\{(\d+)(,)?(\d+)?\}$/) ?? []; + if (!comma) text = `exactly ${n} time${n === "1" ? "" : "s"}`; + else if (m === undefined) text = `${n} or more times`; + else text = `between ${n} and ${m} times`; + } + return text + lazy; +} + +/** Splits a pattern into tokens with a short English description each. */ +export function explainTokens(pattern: string, flags = ""): Token[] { + const tokens: Token[] = []; + let group = 0; + const open: string[] = []; + let i = 0; + const push = (t: Token) => { + // A quantifier right after: attach it. + const q = pattern.slice(i).match(/^(?:[*+?]|\{\d+(?:,\d*)?\})\??/); + if (q && t.kind !== "anchor" && t.kind !== "alternation" && !(t.kind === "group" && t.text !== ")")) { + i += q[0].length; + t = { ...t, text: t.text + q[0], description: `${t.description}, ${quantifier(q[0])}` }; + } + const prev = tokens[tokens.length - 1]; + // Merge runs of plain characters into one literal. + if (t.kind === "literal" && prev?.kind === "literal" && t.text.length === 1 && !/[*+?}]$/.test(prev.text)) { + prev.text += t.text; + prev.description = `the text “${prev.text}”`; + } else tokens.push(t); + }; + while (i < pattern.length) { + const c = pattern[i]; + if (c === "\\") { + const rest = pattern.slice(i + 1); + let m: RegExpMatchArray | null; + if ((m = rest.match(/^k<([^>]+)>/))) { + i += 1 + m[0].length; + push({ text: `\\${m[0]}`, description: `the same text as group “${m[1]}”`, kind: "backreference" }); + } else if ((m = rest.match(/^[1-9]\d*/))) { + i += 1 + m[0].length; + push({ text: `\\${m[0]}`, description: `the same text as group ${m[0]}`, kind: "backreference" }); + } else if ((m = rest.match(/^([pP])\{([^}]+)\}/))) { + i += 1 + m[0].length; + const not = m[1] === "P" ? "not " : ""; + push({ text: `\\${m[0]}`, description: `a character ${not}in Unicode category ${m[2]}`, kind: "escape" }); + } else if ((m = rest.match(/^x[0-9a-fA-F]{2}|^u[0-9a-fA-F]{4}|^u\{[0-9a-fA-F]+\}/))) { + i += 1 + m[0].length; + const hex = m[0].replace(/^[xu]\{?|\}$/g, "").toUpperCase().padStart(4, "0"); + push({ text: `\\${m[0]}`, description: `the character U+${hex}`, kind: "escape" }); + } else if ((m = rest.match(/^c[A-Za-z]/))) { + i += 3; + push({ text: `\\${m[0]}`, description: `Ctrl+${m[0][1].toUpperCase()}`, kind: "escape" }); + } else { + const e = rest[0] ?? ""; + i += 2; + if (ESCAPES[e]) { + push({ text: `\\${e}`, description: ESCAPES[e], kind: e === "b" || e === "B" ? "anchor" : "escape" }); + } else push({ text: `\\${e}`, description: `the character “${e}”`, kind: "literal" }); + } + } else if (c === "[") { + let j = i + 1; + if (pattern[j] === "^") j++; + if (pattern[j] === "]") j++; + while (j < pattern.length && pattern[j] !== "]") j += pattern[j] === "\\" ? 2 : 1; + const text = pattern.slice(i, j + 1); + const negated = text.startsWith("[^"); + const body = text.slice(negated ? 2 : 1, -1); + i = j + 1; + push({ + text, + description: body + ? `${negated ? "any character except" : "one of"} ${describeClass(body)}` + : negated + ? "any character" + : "nothing", + kind: "class", + }); + } else if (c === "(") { + const rest = pattern.slice(i); + let m: RegExpMatchArray | null; + if ((m = rest.match(/^\(\?<([A-Za-z_$][\w$]*)>/))) { + group++; + open.push(`group ${group} “${m[1]}”`); + i += m[0].length; + push({ text: m[0], description: `start of capture group ${group}, named “${m[1]}”`, kind: "group" }); + } else if ((m = rest.match(/^\(\?(?:[=!]|<[=!])/))) { + const what = { + "(?=": "followed by", + "(?!": "not followed by", + "(?<=": "preceded by", + "(? = { + g: "global: every match, not just the first", + i: "ignore case", + m: "multiline: ^ and $ match at line breaks", + s: "dotAll: . matches line breaks too", + u: "unicode: code points and \\p{…}", + v: "unicodeSets: u with set operations in classes", + y: "sticky: matches only at lastIndex", + d: "indices: start and end of each group", +}; + +// --------------------------------------------------------------------------- +// Library + +export interface Recipe { + id: string; + name: string; + pattern: string; + flags: string; + description: string; + examples: string; + keywords?: string[]; +} + +export const LIBRARY: Recipe[] = [ + { + id: "email", + name: "Email address", + pattern: String.raw`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`, + flags: "g", + description: "A practical address check, not the full RFC 5322 grammar", + examples: "Write to ada@example.com or grace.hopper+navy@mail.example.org, not to @nobody.", + keywords: ["mail", "courriel"], + }, + { + id: "url", + name: "URL (http, https)", + pattern: String.raw`https?:\/\/[\w-]+(?:\.[\w-]+)+(?::\d+)?(?:[/?#][^\s"'<>]*[^\s"'<>.,;:!?)]|[/?#])?`, + flags: "gi", + description: "Web links with an optional port, path, query and fragment", + examples: "See https://deno.com/blog?tag=release#top and http://localhost.example:8080/api, not ftp://old.", + keywords: ["link", "lien", "web"], + }, + { + id: "ipv4", + name: "IPv4 address", + pattern: String.raw`\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b`, + flags: "g", + description: "Four numbers from 0 to 255 separated by dots", + examples: "Gateway 192.168.1.1, DNS 1.1.1.1, invalid 256.1.1.1", + keywords: ["ip", "address", "adresse"], + }, + { + id: "ipv6", + name: "IPv6 address", + pattern: String + .raw`(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?::[0-9A-Fa-f]{1,4}){1,6}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)`, + flags: "g", + description: "Full and compressed (::) forms, without zone IDs or embedded IPv4", + examples: "2001:0db8:85a3:0000:0000:8a2e:0370:7334, fe80::1 and ::1", + keywords: ["ip", "address", "adresse"], + }, + { + id: "uuid", + name: "UUID", + pattern: String.raw`\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b`, + flags: "gi", + description: "Versions 1 to 8 with the RFC 9562 variant", + examples: "id=f47ac10b-58cc-4372-a567-0e02b2c3d479 and 01890a5d-ac96-774b-bcce-b302099a8057", + keywords: ["guid", "identifier"], + }, + { + id: "date", + name: "Date (ISO 8601)", + pattern: String.raw`\b(?\d{4})-(?0[1-9]|1[0-2])-(?0[1-9]|[12]\d|3[01])\b`, + flags: "g", + description: "YYYY-MM-DD, with year, month and day as named groups", + examples: "Released 2026-10-28, patched 2026-11-02; 2026-13-01 is not a date.", + keywords: ["iso", "yyyy-mm-dd"], + }, + { + id: "datetime", + name: "Date and time (ISO 8601)", + pattern: String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?`, + flags: "g", + description: "Timestamps like 2026-10-28T09:30:00Z or with an offset", + examples: "created_at: 2026-10-28T09:30:00Z, updated_at: 2026-10-28T11:45:12.250+02:00", + keywords: ["timestamp", "iso", "rfc3339"], + }, + { + id: "time", + name: "Time (24-hour)", + pattern: String.raw`\b(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\b`, + flags: "g", + description: "HH:MM or HH:MM:SS", + examples: "Opens 08:30, closes 18:00:00; 24:00 is not valid.", + keywords: ["hour", "heure"], + }, + { + id: "semver", + name: "Semantic version", + pattern: String + .raw`\bv?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?\b`,
+    flags: "g",
+    description: "MAJOR.MINOR.PATCH with optional pre-release and build metadata",
+    examples: "Upgrade from v1.4.2 to 2.0.0-rc.1+build.42",
+    keywords: ["version", "semver"],
+  },
+  {
+    id: "hex",
+    name: "Hex color",
+    pattern: String.raw`#(?:[0-9a-fA-F]{3,4}){1,2}\b`,
+    flags: "g",
+    description: "#rgb, #rgba, #rrggbb and #rrggbbaa",
+    examples: "color: #fff; background: #1e1e2e; border: #ff000080;",
+    keywords: ["colour", "couleur", "css"],
+  },
+  {
+    id: "mac",
+    name: "MAC address",
+    pattern: String.raw`\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b`,
+    flags: "g",
+    description: "Six hex pairs separated by colons or dashes",
+    examples: "eth0 at 3c:52:82:4f:aa:01, wlan0 at 3C-52-82-4F-AA-02",
+    keywords: ["ethernet", "hardware"],
+  },
+  {
+    id: "domain",
+    name: "Domain name",
+    pattern: String.raw`\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}\b`,
+    flags: "gi",
+    description: "Host names with at least one dot",
+    examples: "Point api.example.com and www.backslash.dev at the new server.",
+    keywords: ["host", "hostname", "domaine"],
+  },
+  {
+    id: "phone",
+    name: "Phone number (E.164)",
+    pattern: String.raw`\+[1-9]\d{6,14}\b`,
+    flags: "g",
+    description: "International format: + and up to 15 digits",
+    examples: "Call +33612345678 or +14155550123.",
+    keywords: ["telephone", "téléphone"],
+  },
+  {
+    id: "number",
+    name: "Number",
+    pattern: String.raw`[-+]?\d+(?:[.,]\d+)?`,
+    flags: "g",
+    description: "Integers and decimals, with a dot or a comma",
+    examples: "Total: 42 items, -3.5 °C, 12,75 €",
+    keywords: ["integer", "decimal", "nombre"],
+  },
+  {
+    id: "slug",
+    name: "Slug",
+    pattern: String.raw`^[a-z0-9]+(?:-[a-z0-9]+)*$`,
+    flags: "gm",
+    description: "Lowercase words joined by dashes, one per line",
+    examples: "hello-world\nHello World\nmy-post-2026",
+    keywords: ["url", "permalink"],
+  },
+  {
+    id: "password",
+    name: "Strong password",
+    pattern: String.raw`^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{12,}$`,
+    flags: "gm",
+    description: "12+ characters with lower and upper case, a digit and a symbol (lookaheads)",
+    examples: "password123\nCorrect-Horse-42!\nShort1!",
+    keywords: ["mot de passe", "lookahead"],
+  },
+  {
+    id: "duplicate",
+    name: "Repeated word",
+    pattern: String.raw`\b(\w+)\s+\1\b`,
+    flags: "gi",
+    description: "The same word twice in a row (a back-reference)",
+    examples: "This is is a test of the the regex.",
+    keywords: ["typo", "doublon"],
+  },
+  {
+    id: "html",
+    name: "HTML tag",
+    pattern: String.raw`<\/?(?[a-z][a-z0-9-]*)\b[^>]*>`,
+    flags: "gi",
+    description: "Opening and closing tags, with the tag name as a group",
+    examples: '

Hello world

', + keywords: ["xml", "markup"], + }, + { + id: "markdown-link", + name: "Markdown link", + pattern: String.raw`\[(?