diff --git a/README.md b/README.md index e035f2f..e859e2f 100644 --- a/README.md +++ b/README.md @@ -340,103 +340,148 @@ Ideas for new extensions and bug reports are welcome in the [issues](https://git Every extension we maintain, plus npm and System that come with the launcher. More are added all the time. -| Extension | What it does | Permissions | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| [AI](extensions/ai) | Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama | `api.anthropic.com`, `api.openai.com`… | -| [Anki](extensions/anki) | Your Anki decks with today's new, learning and review counts, quick card creation and sync, through AnkiConnect | `127.0.0.1:8765`; runs `systemd-run`; reads /etc/os-release, /usr/bin/anki… | -| [Asana](extensions/asana) | Your Asana tasks by due date and your projects; complete, reschedule and create tasks | `app.asana.com` | -| [AWS](extensions/aws) | Switch AWS profiles, start and stop EC2 instances, open SSM sessions, browse S3 buckets and tail CloudWatch logs with the aws CLI | runs `aws`, `kitty`, `alacritty`…; reads ~/.aws/config, ~/.aws/credentials… | -| [Books](extensions/books) | Search Open Library for books with covers, authors, editions and subjects, copy ISBNs and citations, and keep a reading list | `openlibrary.org`, `covers.openlibrary.org` | -| [Browser](extensions/browser) | History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi | reads ~/.mozilla/firefox, ~/snap/firefox/common/.mozilla/firefox… | -| [Caffeine](extensions/caffeine) | Keep the computer awake for a while, or until you turn it off, even with Backslash closed | runs `systemd-run`, `systemctl`, `systemd-inhibit` | -| [Calendar](extensions/calendar) | Your upcoming events from Google, Outlook, iCloud, Fastmail or Proton calendars, with one-key meeting joins | `calendar.google.com`, `outlook.office365.com`… | -| [ClickUp](extensions/clickup) | Your ClickUp tasks across workspaces with their status, priority and due date; change status and create tasks | `api.clickup.com`, `attachments.clickup.com` | -| [Cloudflare](extensions/cloudflare) | Manage Cloudflare zones and DNS records, purge the cache and follow Pages deployments | `api.cloudflare.com` | -| [Code Projects](extensions/projects) | Recent projects of VS Code, VSCodium, Cursor, Zed and JetBrains IDEs, plus your git folders | runs `git`, `code`, `code-oss`…; reads ~/.config/Code, ~/.config/Code - OSS… | -| [Codeberg](extensions/codeberg) | Your Codeberg pull requests with checks, issues, repositories and notifications | `codeberg.org` | -| [Color Picker](extensions/color-picker) | Pick a color anywhere on the screen, keep a history, and convert colors between HEX, RGB, HSL and OKLCH | runs `systemd-run`, `hyprpicker`, `grim`…; reads ~/.cache/backslash/color-picker, /etc/os-release | -| [Containers & VMs](extensions/distrobox) | Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines | runs `distrobox`, `toolbox`, `podman`… | -| [Containers](extensions/docker) | Docker and Podman containers: start, stop, logs | runs `docker`, `podman` | -| [Countdowns](extensions/countdowns) | Count down to vacations, launches and birthdays, and pin the next one to the root search | none | -| [Cron Helper](extensions/cron) | Explain cron and systemd calendar expressions, see their next runs and convert between them | none | -| [Crypto](extensions/crypto) | Cryptocurrency prices, 24 h and 7 day changes, market caps and a watchlist from CoinGecko, and the value of your holdings | `api.coingecko.com`, `coin-images.coingecko.com`… | -| [Databases](extensions/databases) | Open your PostgreSQL, MySQL, SQLite and Redis connections in a terminal, check that they answer and copy their URLs | runs `psql`, `pgcli`, `pg_isready`…; reads ~/.pgpass, ~/.pg_service.conf… | -| [Default Apps](extensions/default-apps) | See and change your default browser, mail app, file manager, viewers, players, editor and terminal | runs `xdg-mime`, `xdg-settings`; reads ~/.local/share/applications, ~/.local/share/flatpak/exports/share/applications… | -| [Deployments](extensions/deployments) | Recent Vercel deployments and Netlify deploys with their status, commit, branch and build time; open, copy, redeploy, cancel, build logs | `api.vercel.com`, `api.netlify.com`… | -| [Desktop Settings](extensions/desktop-settings) | Search every settings panel of GNOME, KDE Plasma, Xfce or Cinnamon in English or French, and flip quick toggles | runs `gsettings`, `kreadconfig6`, `kreadconfig5`…; reads /usr/bin, /usr/local/bin… | -| [Dev Reference](extensions/dev-reference) | HTTP status codes, MIME types, ports, ASCII, HTML entities, key codes, signals, exit codes and a chmod calculator | none | -| [Dev Utils](extensions/dev-utils) | Encode, decode, hash, convert and generate developer values right from the search bar | none | -| [Dictionary](extensions/dictionary) | Definitions, examples, pronunciation and synonyms in English and French from Wiktionary, and quick translations | `en.wiktionary.org`, `fr.wiktionary.org`… | -| [Disk Usage](extensions/disk-usage) | Find what fills your disk: biggest folders and files, caches, trash, logs and containers, with cleanups | runs `du`, `find`, `df`…; reads ~, /etc/os-release | -| [Display](extensions/display) | Screen brightness with presets, night light, and your monitors' resolution, refresh rate and scale | runs `brightnessctl`, `udevadm`, `busctl`…; reads /etc/os-release | -| [Docs](extensions/docs) | Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages | `devdocs.io`, `documents.devdocs.io`…; runs `man`, `apropos`, `kitty`… | -| [Downloads](extensions/downloads) | Recent downloads with previews and where they came from; open, copy, move, rename or trash them | runs `getfattr`, `gio`, `mv`…; reads ~ | -| [Drives](extensions/drives) | Mount, open, unmount and safely eject USB sticks, SD cards and internal drives | runs `lsblk`, `udisksctl` | -| [Emoji & Symbols](extensions/emoji) | Search emoji and symbols by name and copy them | none | -| [Flatpak Apps](extensions/flatpak) | Browse installed Flatpak apps and runtimes, see their size and permissions, run or uninstall them | runs `flatpak`; reads /var/lib/flatpak/exports/share/icons/hicolor, ~/.local/share/flatpak/exports/share/icons/hicolor… | -| [Fonts](extensions/fonts) | Browse installed font families with previews, copy their CSS and install new ones | runs `fc-list`, `fc-cache`, `pango-view`…; reads ~/.cache/backslash/fonts, ~/.local/share/fonts | -| [Games](extensions/games) | Launch your installed Steam, Lutris and Heroic (Epic, GOG, Amazon) games, with cover art, play time, size and ProtonDB ratings | `www.protondb.com`, `shared.steamstatic.com`…; runs `lutris`; reads ~/.local/share/Steam, ~/.steam… | -| [Git Repos](extensions/git-repos) | Your local git repositories with branch, changes, ahead/behind and last commit, plus fetch, pull and stash | runs `git`, `find`, `code`…; reads /etc/os-release | -| [GitHub](extensions/github) | Pull requests with their checks and reviews, your issues, and repository search | `api.github.com`, `avatars.githubusercontent.com`; runs `gh` | -| [GitLab](extensions/gitlab) | Merge requests with pipelines and approvals, your issues, projects and pipelines on gitlab.com (self-hosted instances are not supported) | `gitlab.com`, `secure.gravatar.com` | -| [Google Cloud](extensions/google-cloud) | Switch gcloud projects, SSH into Compute Engine VMs and follow Cloud Run services, their URLs and logs | runs `gcloud`, `kitty`, `alacritty`…; reads /etc/os-release | -| [Hacker News & RSS](extensions/hacker-news) | Top, new, best, Ask and Show HN stories with their comments, and your RSS and Atom feeds with unread tracking | any website | -| [Home Assistant](extensions/home-assistant) | Lights, switches, covers, climate, media players, scenes, scripts and sensors of your Home Assistant: toggle, dim, set the temperature, run scenes | `localhost`, `127.0.0.1`… | -| [HTTP Client](extensions/http-client) | Send HTTP requests from the launcher and read the status, timing, headers and formatted body | any website | -| [Internet Radio](extensions/radio) | Search 50,000 internet radio stations by name, genre or country and play them in the background with mpv, ffplay or VLC | `de1.api.radio-browser.info`, `de2.api.radio-browser.info`…; runs `systemd-run`, `systemctl`, `mpv`…; reads ~/.cache/backslash/radio, /etc/os-release | -| [Jira](extensions/jira) | Your Jira Cloud issues by status with priority, sprint and labels; change status, assign, search with text or JQL, create issues | `*.atlassian.net`, `secure.gravatar.com`… | -| [Joplin](extensions/joplin) | Search your Joplin notes by notebook, preview them with their tags, create notes and copy links | `127.0.0.1:41184` | -| [Keyboard Layouts](extensions/keyboard-layouts) | See the current keyboard layout and switch between layouts and input methods | runs `gsettings`, `gdbus`, `hyprctl`…; reads /usr/share/X11/xkb/rules/evdev.lst, /usr/share/X11/xkb/rules/base.lst… | -| [Kubernetes](extensions/kubernetes) | Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces | runs `kubectl`, `kitty`, `alacritty`… | -| [Linear](extensions/linear) | Your issues with their status, priority, labels and assignee; search, triage and quick issue creation | `api.linear.app`, `public.linear.app` | -| [Logseq](extensions/logseq) | Search your Logseq pages and blocks, preview them with properties and backlinks, open today's journal and capture blocks | reads ~ | -| [Media Controls](extensions/media) | Control Spotify, browsers, VLC, mpv and any MPRIS player | `i.scdn.co`; runs `busctl`; reads /tmp/backslash-media | -| [Movies & TV](extensions/movies) | Search movies and TV shows on TMDB with posters, ratings, cast, trailers and where to stream them, and keep a watchlist | `api.themoviedb.org`, `image.tmdb.org` | -| [MPD Music](extensions/mpd) | Control the Music Player Daemon: now playing with covers, queue, library search and playlists | `localhost:6600`, `127.0.0.1:6600`; runs `systemctl`; reads ~/.cache/backslash/mpd, /etc/os-release | -| [Nerd Font Icons](extensions/nerd-fonts) | Search every Nerd Font glyph by name or code point and copy the glyph, its class, code point or HTML entity | `raw.githubusercontent.com` | -| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs and VPNs in two keystrokes | runs `nmcli`, `bluetoothctl`, `pactl`… | -| [Network Tools](extensions/network-tools) | Public IP and location, local interfaces, DNS lookup, whois, ping and a speed test | `api.ipify.org`, `api6.ipify.org`…; runs `ip`, `ping`, `whois`…; reads /etc/os-release | -| [Notifications](extensions/notifications) | Do Not Disturb and the notification history for mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon | runs `gdbus`, `makoctl`, `dunstctl`… | -| [Notion](extensions/notion) | Search the pages and databases shared with your integration, read them, add text to a page, create pages in a database | `api.notion.com` | -| [Obsidian](extensions/obsidian) | Search your Obsidian notes by title, tag and text, preview them, open today's daily note and capture ideas | reads ~ | -| [Ollama Models](extensions/ollama) | See the local Ollama models and the ones loaded in memory, pull new ones with live progress and delete them | `127.0.0.1:11434`; runs `ollama`, `systemd-run`; reads /etc/os-release | -| [Package Registries](extensions/package-registries) | Search crates.io, PyPI, Go modules, RubyGems, Packagist, Maven Central, Hex, pub.dev and NuGet | `crates.io`, `pypi.org`… | -| [Packages](extensions/packages) | Search your distribution's packages and Flathub | runs `apt-cache`, `dnf`, `pacman`… | -| [Passwords](extensions/passwords) | Copy passwords, usernames and one-time codes from pass, Bitwarden, 1Password and KeePassXC | runs `pass`, `bw`, `op`…; reads ~/.password-store | -| [Ports](extensions/ports) | What listens on :3000? Dev servers and services by port, with their process and project | runs `ss`, `ps`, `readlink`… | -| [Power](extensions/power) | Switch power profiles and check your battery's charge, health, cycles and time left | runs `powerprofilesctl`, `tuned-adm`, `udevadm`… | -| [Printers](extensions/printers) | See your printers and their queues, cancel jobs, pause printers and print files | runs `lpstat`, `lpq`, `lp`…; reads /etc/os-release | -| [Privacy](extensions/privacy) | See which apps use your camera, microphone, screen and location, and stop them | runs `pw-dump`, `pactl`, `find`… | -| [QR Codes](extensions/qr-codes) | Turn text, links and Wi-Fi networks into QR codes, and scan the ones on your screen | runs `systemd-run`, `zbarimg`, `grim`…; reads ~/.cache/backslash/qr, ~/Pictures… | -| [Quick Notes](extensions/quick-notes) | Jot, find, pin and paste notes kept as plain markdown files, ready for Obsidian, your editor or sync | reads ~/Documents | -| [Raindrop.io](extensions/raindrop) | Search your Raindrop.io bookmarks with their covers, tags and collection; save links, favorite, move and delete | `api.raindrop.io`, `rdl.ink` | -| [Random](extensions/random) | Flip a coin, roll dice, draw a number, pick or shuffle from a list, get a color or ask the Magic 8-Ball | none | -| [Reddit](extensions/reddit) | Hot, new and top posts from your subreddits with scores, comments, flairs, pictures and the top comments, without an account | `www.reddit.com`, `i.redd.it`… | -| [Regex Tester](extensions/regex) | Test JavaScript regular expressions with groups, replacements, explanations and a library of common patterns | none | -| [Screenshot & Recording](extensions/screenshot) | Capture an area, a window or the whole screen, record the screen, and find your recent captures | runs `systemd-run`, `systemctl`, `grim`…; reads ~/Pictures, ~/Images… | -| [Sentry](extensions/sentry) | Unresolved issues across your projects with events, users and assignee; resolve, ignore, assign; latest event with its stack trace (sentry.io, US and EU regions) | `sentry.io`, `us.sentry.io`… | -| [Services](extensions/systemd) | Start, stop and inspect systemd services | runs `systemctl`, `journalctl` | -| [Slack](extensions/slack) | Set your Slack status from presets, pause notifications, jump to channels and search messages | `slack.com`, `avatars.slack-edge.com`… | -| [Spotify](extensions/spotify) | Search Spotify, play on any of your devices, queue songs, browse your playlists and save what's playing to your library | `api.spotify.com`, `accounts.spotify.com`… | -| [SSH Hosts](extensions/ssh) | Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager | runs `kitty`, `alacritty`, `wezterm`…; reads ~/.ssh | -| [Startup Apps](extensions/startup-apps) | See and change what starts when you log in: autostart apps, systemd user services and Hyprland | runs `systemctl`; reads ~/.config/autostart, /etc/xdg/autostart… | -| [Stocks](extensions/stocks) | A stock watchlist with live quotes, day ranges, company profiles and news, from Finnhub's free API | `finnhub.io`, `static2.finnhub.io` | -| [System Logs](extensions/system-logs) | Errors and warnings of this boot from systemd's journal, grouped by service | runs `journalctl`, `kitty`, `alacritty`… | -| [System Monitor](extensions/system-monitor) | Live CPU, memory, disk, network, GPU and battery usage, and the processes behind it | runs `grep`, `ps`, `df`…; reads /etc/os-release | -| [System Updates](extensions/updates) | Available updates from APT, DNF, Pacman, Zypper, Flatpak, Snap and firmware, applied in a terminal | runs `apt`, `dnf`, `rpm`…; reads /etc/os-release | -| [Terminal Sessions](extensions/terminal-sessions) | Attach to, create, rename and kill your tmux and zellij sessions | runs `tmux`, `zellij`, `kitty`… | -| [Text from Screen](extensions/ocr) | Select any part of the screen and copy the text in it, recognized offline with Tesseract | runs `systemd-run`, `tesseract`, `grim`…; reads ~/.cache/backslash/ocr, /etc/os-release | -| [Text to Speech](extensions/text-to-speech) | Read the clipboard, the selection or any text aloud with Piper, eSpeak NG, Speech Dispatcher or Festival | runs `systemd-run`, `systemctl`, `piper`…; reads ~/.cache/backslash/tts, ~/.local/share/piper… | -| [TickTick](extensions/ticktick) | Today's and upcoming TickTick tasks by project; complete, reschedule and quick-add tasks in plain language | `api.ticktick.com`, `ticktick.com` | -| [Timers](extensions/timers) | Timers and Pomodoro that ring even when Backslash is closed (systemd timers) | runs `systemd-run`, `systemctl` | -| [Todo.txt](extensions/todo-txt) | Your todo.txt tasks by due date, with priorities, +projects, @contexts, recurrence and quick add | reads ~/todo.txt, ~/done.txt… | -| [Todoist](extensions/todoist) | Today's tasks, search and quick add in natural language | `api.todoist.com` | -| [Trash](extensions/trash) | See what is in the trash, preview it, restore items or delete them for good | runs `gio`, `du`, `mv`…; reads ~ | -| [Trello](extensions/trello) | Your boards and cards with labels, due dates and members; move, complete, archive and create cards | `api.trello.com`, `trello-members.s3.amazonaws.com`… | -| [Weather](extensions/weather) | Current conditions, the next 24 hours and the week ahead for any city, from Open-Meteo | `geocoding-api.open-meteo.com`, `api.open-meteo.com` | -| [Web Search](extensions/web-search) | Search the web with live suggestions, 50 !bangs and your recent searches, on the engine you prefer | `duckduckgo.com`, `suggestqueries.google.com` | -| [Wikipedia](extensions/wikipedia) | Search Wikipedia in your language, read summaries and whole articles, and discover random pages | `en.wikipedia.org`, `fr.wikipedia.org`… | -| [Window Manager](extensions/window-manager) | Snap, maximize, center, close and move the focused window between workspaces | runs `systemd-run`, `hyprctl`, `swaymsg`…; reads /etc/os-release | -| [World Clock](extensions/world-clock) | The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris” | none | -| [YouTube](extensions/youtube) | Search YouTube with thumbnails, views and durations, open videos in the browser or play them in mpv in the background, and browse channels | `www.googleapis.com`, `i.ytimg.com`; runs `systemd-run`, `systemctl`, `mpv`…; reads /etc/os-release | -| [Zotero](extensions/zotero) | Search your Zotero library by title, author or tag, copy APA citations and BibTeX, and open PDFs | `127.0.0.1:23119`; runs `sqlite3`; reads ~/Zotero, ~/.cache/backslash-zotero… | +| Extension | What it does | Permissions | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| [AI](extensions/ai) | Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama | `api.anthropic.com`, `api.openai.com`… | +| [Anki](extensions/anki) | Your Anki decks with today's new, learning and review counts, quick card creation and sync, through AnkiConnect | `127.0.0.1:8765`; runs `systemd-run`; reads /etc/os-release, /usr/bin/anki… | +| [Asana](extensions/asana) | Your Asana tasks by due date and your projects; complete, reschedule and create tasks | `app.asana.com` | +| [Authenticator](extensions/authenticator) | Two-factor (TOTP) codes for your accounts, with secrets kept in the system keyring | runs `secret-tool`, `zbarimg`…; reads the clipboard | +| [AWS](extensions/aws) | Switch AWS profiles, start and stop EC2 instances, open SSM sessions, browse S3 buckets and tail CloudWatch logs with the aws CLI | runs `aws`, `kitty`, `alacritty`…; reads ~/.aws/config, ~/.aws/credentials… | +| [Bitbucket](extensions/bitbucket) | Bitbucket Cloud pull requests to review or opened by you, with builds and approvals, your issues and repository search | `api.bitbucket.org`, `bitbucket.org`… | +| [Books](extensions/books) | Search Open Library for books with covers, authors, editions and subjects, copy ISBNs and citations, and keep a reading list | `openlibrary.org`, `covers.openlibrary.org` | +| [Browser](extensions/browser) | History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi | reads ~/.mozilla/firefox, ~/snap/firefox/common/.mozilla/firefox… | +| [Caffeine](extensions/caffeine) | Keep the computer awake for a while, or until you turn it off, even with Backslash closed | runs `systemd-run`, `systemctl`, `systemd-inhibit` | +| [Calendar](extensions/calendar) | Your upcoming events from Google, Outlook, iCloud, Fastmail or Proton calendars, with one-key meeting joins | `calendar.google.com`, `outlook.office365.com`… | +| [Can I Use](extensions/can-i-use) | Browser support for web platform features from caniuse.com, with versions, partial support and notes | `raw.githubusercontent.com` | +| [ClickUp](extensions/clickup) | Your ClickUp tasks across workspaces with their status, priority and due date; change status and create tasks | `api.clickup.com`, `attachments.clickup.com` | +| [Cloudflare](extensions/cloudflare) | Manage Cloudflare zones and DNS records, purge the cache and follow Pages deployments | `api.cloudflare.com` | +| [Code Projects](extensions/projects) | Recent projects of VS Code, VSCodium, Cursor, Zed and JetBrains IDEs, plus your git folders | runs `git`, `code`, `code-oss`…; reads ~/.config/Code, ~/.config/Code - OSS… | +| [Codeberg](extensions/codeberg) | Your Codeberg pull requests with checks, issues, repositories and notifications | `codeberg.org` | +| [Color Picker](extensions/color-picker) | Pick a color anywhere on the screen, keep a history, and convert colors between HEX, RGB, HSL and OKLCH | runs `systemd-run`, `hyprpicker`, `grim`…; reads ~/.cache/backslash/color-picker, /etc/os-release | +| [Commit Helper](extensions/commit-helper) | Write Conventional Commits messages with scopes learned from your history, gitmoji, and commit the staged changes | runs `git`, `find` | +| [Container Registries](extensions/container-registries) | Search Docker Hub and Quay.io images and browse tags on Docker Hub, GHCR and Quay with sizes, digests and platforms | `hub.docker.com`, `ghcr.io`… | +| [Containers](extensions/docker) | Docker and Podman containers: start, stop, logs | runs `docker`, `podman` | +| [Containers & VMs](extensions/distrobox) | Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines | runs `distrobox`, `toolbox`, `podman`… | +| [Countdowns](extensions/countdowns) | Count down to vacations, launches and birthdays, and pin the next one to the root search | none | +| [Cron Helper](extensions/cron) | Explain cron and systemd calendar expressions, see their next runs and convert between them | none | +| [Crypto](extensions/crypto) | Cryptocurrency prices, 24 h and 7 day changes, market caps and a watchlist from CoinGecko, and the value of your holdings | `api.coingecko.com`, `coin-images.coingecko.com`… | +| [Databases](extensions/databases) | Open your PostgreSQL, MySQL, SQLite and Redis connections in a terminal, check that they answer and copy their URLs | runs `psql`, `pgcli`, `pg_isready`…; reads ~/.pgpass, ~/.pg_service.conf… | +| [DeepL & LanguageTool](extensions/writing) | Translate text with DeepL and fix grammar and spelling with LanguageTool, from what you type, select or copy | `api-free.deepl.com`, `api.deepl.com`…; runs `wl-paste`, `xclip`…; reads the clipboard | +| [Default Apps](extensions/default-apps) | See and change your default browser, mail app, file manager, viewers, players, editor and terminal | runs `xdg-mime`, `xdg-settings`; reads ~/.local/share/applications, ~/.local/share/flatpak/exports/share/applications… | +| [Deployments](extensions/deployments) | Recent Vercel deployments and Netlify deploys with their status, commit, branch and build time; open, copy, redeploy, cancel, build logs | `api.vercel.com`, `api.netlify.com`… | +| [Desktop Settings](extensions/desktop-settings) | Search every settings panel of GNOME, KDE Plasma, Xfce or Cinnamon in English or French, and flip quick toggles | runs `gsettings`, `kreadconfig6`, `kreadconfig5`…; reads /usr/bin, /usr/local/bin… | +| [Dev Reference](extensions/dev-reference) | HTTP status codes, MIME types, ports, ASCII, HTML entities, key codes, signals, exit codes and a chmod calculator | none | +| [Dev Utils](extensions/dev-utils) | Encode, decode, hash, convert and generate developer values right from the search bar | none | +| [Dictionary](extensions/dictionary) | Definitions, examples, pronunciation and synonyms in English and French from Wiktionary, and quick translations | `en.wiktionary.org`, `fr.wiktionary.org`… | +| [Disk Usage](extensions/disk-usage) | Find what fills your disk: biggest folders and files, caches, trash, logs and containers, with cleanups | runs `du`, `find`, `df`…; reads ~, /etc/os-release | +| [Display](extensions/display) | Screen brightness with presets, night light, and your monitors' resolution, refresh rate and scale | runs `brightnessctl`, `udevadm`, `busctl`…; reads /etc/os-release | +| [Docs](extensions/docs) | Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages | `devdocs.io`, `documents.devdocs.io`…; runs `man`, `apropos`, `kitty`… | +| [Downloads](extensions/downloads) | Recent downloads with previews and where they came from; open, copy, move, rename or trash them | runs `getfattr`, `gio`, `mv`…; reads ~ | +| [Drives](extensions/drives) | Mount, open, unmount and safely eject USB sticks, SD cards and internal drives | runs `lsblk`, `udisksctl` | +| [Emoji & Symbols](extensions/emoji) | Search emoji and symbols by name and copy them | none | +| [Firewall](extensions/firewall) | State, default policies and rules of ufw or firewalld; allow or block a port, delete a rule, turn the firewall on or off | runs `firewall-cmd`, `pkexec` | +| [Flatpak Apps](extensions/flatpak) | Browse installed Flatpak apps and runtimes, see their size and permissions, run or uninstall them | runs `flatpak`; reads /var/lib/flatpak/exports/share/icons/hicolor, ~/.local/share/flatpak/exports/share/icons/hicolor… | +| [Fly.io & Railway](extensions/paas) | Fly.io apps and Railway services with their status, latest deploy and URL; open, logs, restart and deploy history | `backboard.railway.com`; runs `flyctl`, `fly` | +| [Fonts](extensions/fonts) | Browse installed font families with previews, copy their CSS and install new ones | runs `fc-list`, `fc-cache`, `pango-view`…; reads ~/.cache/backslash/fonts, ~/.local/share/fonts | +| [Games](extensions/games) | Launch your installed Steam, Lutris and Heroic (Epic, GOG, Amazon) games, with cover art, play time, size and ProtonDB ratings | `www.protondb.com`, `shared.steamstatic.com`…; runs `lutris`; reads ~/.local/share/Steam, ~/.steam… | +| [Gists](extensions/gists) | Your GitHub gists with their code in the preview: copy, paste, create from the clipboard, delete | `api.github.com`, `avatars.githubusercontent.com`…; runs `gh`; reads the clipboard | +| [Git Repos](extensions/git-repos) | Your local git repositories with branch, changes, ahead/behind and last commit, plus fetch, pull and stash | runs `git`, `find`, `code`…; reads /etc/os-release | +| [Gitea & Forgejo](extensions/gitea) | Pull requests to review, issues, repositories and notifications of your Gitea or Forgejo server, self-hosted or Codeberg-like | any host, `codeberg.org`… | +| [GitHub](extensions/github) | Pull requests with their checks and reviews, your issues, and repository search | `api.github.com`, `avatars.githubusercontent.com`; runs `gh` | +| [GitHub Actions](extensions/github-actions) | Workflow runs of your repositories, failing and running first, with jobs, steps and logs; re-run or cancel them | `api.github.com`, `avatars.githubusercontent.com`…; runs `gh` | +| [GitLab](extensions/gitlab) | Merge requests with pipelines and approvals, your issues, projects and pipelines on gitlab.com (self-hosted instances are not supported) | `gitlab.com`, `secure.gravatar.com` | +| [Google Cloud](extensions/google-cloud) | Switch gcloud projects, SSH into Compute Engine VMs and follow Cloud Run services, their URLs and logs | runs `gcloud`, `kitty`, `alacritty`…; reads /etc/os-release | +| [Google Drive & Tasks](extensions/google-workspace) | Search Google Drive and manage Google Tasks with your own Google sign-in | `www.googleapis.com`, `tasks.googleapis.com`… | +| [Grafana](extensions/grafana) | Search Grafana dashboards and follow firing alerts; silence an alert for an hour or a day, expire silences | `*.grafana.net`, `localhost`… | +| [Hacker News & RSS](extensions/hacker-news) | Top, new, best, Ask and Show HN stories with their comments, and your RSS and Atom feeds with unread tracking | any website | +| [Helm](extensions/helm) | Helm releases across namespaces with their chart, status and revision; values, history, rollback and uninstall, in the current kubectl context | runs `helm`, `kubectl` | +| [Home Assistant](extensions/home-assistant) | Lights, switches, covers, climate, media players, scenes, scripts and sensors of your Home Assistant: toggle, dim, set the temperature, run scenes | `localhost`, `127.0.0.1`… | +| [HTTP Client](extensions/http-client) | Send HTTP requests from the launcher and read the status, timing, headers and formatted body | any website | +| [Hugging Face](extensions/hugging-face) | Search Hugging Face models, datasets and Spaces with downloads, likes, licenses and model cards | `huggingface.co` | +| [Icon Search](extensions/icon-search) | Search Lucide, Material Symbols, Tabler, Heroicons, Phosphor and Simple Icons, and copy them as SVG, JSX or data URI | `cdn.jsdelivr.net` | +| [Image Tools](extensions/image-tools) | Resize, convert, compress, rotate and clean the metadata of pictures copied in your file manager | runs `magick`, `convert`…; reads the clipboard | +| [Internet Radio](extensions/radio) | Search 50,000 internet radio stations by name, genre or country and play them in the background with mpv, ffplay or VLC | `de1.api.radio-browser.info`, `de2.api.radio-browser.info`…; runs `systemd-run`, `systemctl`, `mpv`…; reads ~/.cache/backslash/radio, /etc/os-release | +| [Jellyfin & Plex](extensions/jellyfin) | Continue watching, next up, recently added and search on your Jellyfin and Plex servers, with posters; play in mpv or VLC, mark watched | `localhost`, `127.0.0.1`…; runs `mpv`, `vlc` | +| [Jira](extensions/jira) | Your Jira Cloud issues by status with priority, sprint and labels; change status, assign, search with text or JQL, create issues | `*.atlassian.net`, `secure.gravatar.com`… | +| [Joplin](extensions/joplin) | Search your Joplin notes by notebook, preview them with their tags, create notes and copy links | `127.0.0.1:41184` | +| [JSON Explorer](extensions/json-explorer) | Validate, browse and query the JSON in your clipboard, then copy values, paths or YAML, TOML and CSV | reads the clipboard | +| [KDE Connect](extensions/kde-connect) | Your phones through KDE Connect or GSConnect: battery, ring, send the clipboard, a file or an SMS, browse their files | runs `kdeconnect-cli`, `busctl`; reads the clipboard | +| [Keyboard Layouts](extensions/keyboard-layouts) | See the current keyboard layout and switch between layouts and input methods | runs `gsettings`, `gdbus`, `hyprctl`…; reads /usr/share/X11/xkb/rules/evdev.lst, /usr/share/X11/xkb/rules/base.lst… | +| [Kubernetes](extensions/kubernetes) | Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces | runs `kubectl`, `kitty`, `alacritty`… | +| [Licenses](extensions/licenses) | Open source licenses with their permissions, conditions and limitations; copy the text or add it to a project | `raw.githubusercontent.com`; runs `git` | +| [Linear](extensions/linear) | Your issues with their status, priority, labels and assignee; search, triage and quick issue creation | `api.linear.app`, `public.linear.app` | +| [Logseq](extensions/logseq) | Search your Logseq pages and blocks, preview them with properties and backlinks, open today's journal and capture blocks | reads ~ | +| [Mail](extensions/mail) | Unread mail of up to three IMAP accounts (Gmail, Fastmail, iCloud, Yahoo, Proton Mail Bridge or any server): preview, mark read, archive, delete | any host | +| [Media Controls](extensions/media) | Control Spotify, browsers, VLC, mpv and any MPRIS player | `i.scdn.co`; runs `busctl`; reads /tmp/backslash-media | +| [Microsoft 365](extensions/microsoft-365) | Unread Outlook mail, Microsoft To Do tasks, OneDrive files and today's meetings through Microsoft Graph | `graph.microsoft.com`, `login.microsoftonline.com` | +| [Movies & TV](extensions/movies) | Search movies and TV shows on TMDB with posters, ratings, cast, trailers and where to stream them, and keep a watchlist | `api.themoviedb.org`, `image.tmdb.org` | +| [MPD Music](extensions/mpd) | Control the Music Player Daemon: now playing with covers, queue, library search and playlists | `localhost:6600`, `127.0.0.1:6600`; runs `systemctl`; reads ~/.cache/backslash/mpd, /etc/os-release | +| [Nerd Font Icons](extensions/nerd-fonts) | Search every Nerd Font glyph by name or code point and copy the glyph, its class, code point or HTML entity | `raw.githubusercontent.com` | +| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs and VPNs in two keystrokes | runs `nmcli`, `bluetoothctl`, `pactl`… | +| [Network Tools](extensions/network-tools) | Public IP and location, local interfaces, DNS lookup, whois, ping and a speed test | `api.ipify.org`, `api6.ipify.org`…; runs `ip`, `ping`, `whois`…; reads /etc/os-release | +| [NixOS Search](extensions/nix-search) | Search nixpkgs packages and NixOS, Home Manager and nix-darwin options from search.nixos.org | `search.nixos.org` | +| [Notifications](extensions/notifications) | Do Not Disturb and the notification history for mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon | runs `gdbus`, `makoctl`, `dunstctl`… | +| [Notion](extensions/notion) | Search the pages and databases shared with your integration, read them, add text to a page, create pages in a database | `api.notion.com` | +| [Obsidian](extensions/obsidian) | Search your Obsidian notes by title, tag and text, preview them, open today's daily note and capture ideas | reads ~ | +| [Ollama Models](extensions/ollama) | See the local Ollama models and the ones loaded in memory, pull new ones with live progress and delete them | `127.0.0.1:11434`; runs `ollama`, `systemd-run`; reads /etc/os-release | +| [Package Registries](extensions/package-registries) | Search crates.io, PyPI, Go modules, RubyGems, Packagist, Maven Central, Hex, pub.dev and NuGet | `crates.io`, `pypi.org`… | +| [Packages](extensions/packages) | Search your distribution's packages and Flathub | runs `apt-cache`, `dnf`, `pacman`… | +| [PagerDuty](extensions/pagerduty) | Open PagerDuty incidents with their urgency, service and assignees; acknowledge, resolve, reassign, add notes, and see who is on call | `api.pagerduty.com`, `api.eu.pagerduty.com`… | +| [Passwords](extensions/passwords) | Copy passwords, usernames and one-time codes from pass, Bitwarden, 1Password and KeePassXC | runs `pass`, `bw`, `op`…; reads ~/.password-store | +| [PDF Tools](extensions/pdf-tools) | Merge, split, compress, rotate and unlock PDFs copied in your file manager, and turn pictures into PDFs | runs `qpdf`, `gs`…; reads the clipboard | +| [Pi-hole & AdGuard Home](extensions/pihole) | Queries, blocked share and top blocked domains of your Pi-hole v6 or AdGuard Home; pause blocking, allow or block a domain | `localhost`, `127.0.0.1`… | +| [Podcasts](extensions/podcasts) | Search Apple Podcasts, subscribe, and play new episodes in the background with mpv: pause, seek, resume where you stopped | any host; runs `systemctl`, `mpv` | +| [Ports](extensions/ports) | What listens on :3000? Dev servers and services by port, with their process and project | runs `ss`, `ps`, `readlink`… | +| [Power](extensions/power) | Switch power profiles and check your battery's charge, health, cycles and time left | runs `powerprofilesctl`, `tuned-adm`, `udevadm`… | +| [Printers](extensions/printers) | See your printers and their queues, cancel jobs, pause printers and print files | runs `lpstat`, `lpq`, `lp`…; reads /etc/os-release | +| [Privacy](extensions/privacy) | See which apps use your camera, microphone, screen and location, and stop them | runs `pw-dump`, `pactl`, `find`… | +| [Project Scripts](extensions/project-scripts) | Scripts, Make targets, just recipes and tasks of your recent projects, run in a terminal | runs `just`, `task`… | +| [Proxmox](extensions/proxmox) | Nodes, virtual machines and containers of Proxmox VE with CPU, memory and uptime; start, shut down, reboot, consoles and snapshots | `localhost`, `127.0.0.1`… | +| [QR Codes](extensions/qr-codes) | Turn text, links and Wi-Fi networks into QR codes, and scan the ones on your screen | runs `systemd-run`, `zbarimg`, `grim`…; reads ~/.cache/backslash/qr, ~/Pictures… | +| [Quick Notes](extensions/quick-notes) | Jot, find, pin and paste notes kept as plain markdown files, ready for Obsidian, your editor or sync | reads ~/Documents | +| [Raindrop.io](extensions/raindrop) | Search your Raindrop.io bookmarks with their covers, tags and collection; save links, favorite, move and delete | `api.raindrop.io`, `rdl.ink` | +| [Random](extensions/random) | Flip a coin, roll dice, draw a number, pick or shuffle from a list, get a color or ask the Magic 8-Ball | none | +| [Reddit](extensions/reddit) | Hot, new and top posts from your subreddits with scores, comments, flairs, pictures and the top comments, without an account | `www.reddit.com`, `i.redd.it`… | +| [Regex Tester](extensions/regex) | Test JavaScript regular expressions with groups, replacements, explanations and a library of common patterns | none | +| [Screenshot & Recording](extensions/screenshot) | Capture an area, a window or the whole screen, record the screen, and find your recent captures | runs `systemd-run`, `systemctl`, `grim`…; reads ~/Pictures, ~/Images… | +| [Sentry](extensions/sentry) | Unresolved issues across your projects with events, users and assignee; resolve, ignore, assign; latest event with its stack trace (sentry.io, US and EU regions) | `sentry.io`, `us.sentry.io`… | +| [Services](extensions/systemd) | Start, stop and inspect systemd services | runs `systemctl`, `journalctl` | +| [Slack](extensions/slack) | Set your Slack status from presets, pause notifications, jump to channels and search messages | `slack.com`, `avatars.slack-edge.com`… | +| [Spotify](extensions/spotify) | Search Spotify, play on any of your devices, queue songs, browse your playlists and save what's playing to your library | `api.spotify.com`, `accounts.spotify.com`… | +| [SSH Hosts](extensions/ssh) | Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager | runs `kitty`, `alacritty`, `wezterm`…; reads ~/.ssh | +| [Stack Overflow](extensions/stack-overflow) | Search Stack Overflow, Super User, Ask Ubuntu, Unix & Linux and Server Fault, and read the accepted answer | `api.stackexchange.com` | +| [Startup Apps](extensions/startup-apps) | See and change what starts when you log in: autostart apps, systemd user services and Hyprland | runs `systemctl`; reads ~/.config/autostart, /etc/xdg/autostart… | +| [Status Pages](extensions/status-pages) | Is GitHub down? Live health of the developer services you depend on, with their components and incident updates | `www.githubstatus.com`, `www.cloudflarestatus.com`… | +| [Stocks](extensions/stocks) | A stock watchlist with live quotes, day ranges, company profiles and news, from Finnhub's free API | `finnhub.io`, `static2.finnhub.io` | +| [Stripe](extensions/stripe) | Payments, customers, subscriptions and balance of your Stripe account, test or live, with refunds behind a confirmation | `api.stripe.com` | +| [Supabase](extensions/supabase) | Your Supabase projects with their region, status and Postgres version; tables, edge functions, logs and API keys; open Studio, pause and restore | `api.supabase.com` | +| [Syncthing](extensions/syncthing) | Folders and devices of Syncthing with sync progress, conflicts and last scan; rescan, pause and resume | `localhost`, `127.0.0.1`…; runs `find`, `systemctl` | +| [System Logs](extensions/system-logs) | Errors and warnings of this boot from systemd's journal, grouped by service | runs `journalctl`, `kitty`, `alacritty`… | +| [System Monitor](extensions/system-monitor) | Live CPU, memory, disk, network, GPU and battery usage, and the processes behind it | runs `grep`, `ps`, `df`…; reads /etc/os-release | +| [System Updates](extensions/updates) | Available updates from APT, DNF, Pacman, Zypper, Flatpak, Snap and firmware, applied in a terminal | runs `apt`, `dnf`, `rpm`…; reads /etc/os-release | +| [Tailscale](extensions/tailscale) | Your tailnet's devices with their IPs and state: copy, SSH, ping, exit nodes, Taildrop, connect and disconnect | runs `tailscale` | +| [Tailwind CSS](extensions/tailwind) | Search Tailwind CSS v4 utility classes with the CSS they generate, browse the color palette and open the docs | Nothing | +| [Terminal Sessions](extensions/terminal-sessions) | Attach to, create, rename and kill your tmux and zellij sessions | runs `tmux`, `zellij`, `kitty`… | +| [Terraform](extensions/terraform) | Terraform and OpenTofu projects with their workspaces, state resources and outputs; init, plan, apply, fmt and validate in your terminal | runs `terraform`, `tofu`… | +| [Text Diff](extensions/text-diff) | Compare two texts line by line and word by word: clipboard, selection or clipboard history, with a unified patch to copy | runs `wl-paste`, `xclip`…; reads the clipboard | +| [Text from Screen](extensions/ocr) | Select any part of the screen and copy the text in it, recognized offline with Tesseract | runs `systemd-run`, `tesseract`, `grim`…; reads ~/.cache/backslash/ocr, /etc/os-release | +| [Text to Speech](extensions/text-to-speech) | Read the clipboard, the selection or any text aloud with Piper, eSpeak NG, Speech Dispatcher or Festival | runs `systemd-run`, `systemctl`, `piper`…; reads ~/.cache/backslash/tts, ~/.local/share/piper… | +| [TickTick](extensions/ticktick) | Today's and upcoming TickTick tasks by project; complete, reschedule and quick-add tasks in plain language | `api.ticktick.com`, `ticktick.com` | +| [Timers](extensions/timers) | Timers and Pomodoro that ring even when Backslash is closed (systemd timers) | runs `systemd-run`, `systemctl` | +| [Todo.txt](extensions/todo-txt) | Your todo.txt tasks by due date, with priorities, +projects, @contexts, recurrence and quick add | reads ~/todo.txt, ~/done.txt… | +| [Todoist](extensions/todoist) | Today's tasks, search and quick add in natural language | `api.todoist.com` | +| [Toolchains](extensions/toolchains) | Language runtimes installed with mise, asdf, rustup, fnm, nvm, pyenv or SDKMAN!: what's in use, what's newer, install and switch in a terminal | runs `mise`, `asdf`… | +| [Trash](extensions/trash) | See what is in the trash, preview it, restore items or delete them for good | runs `gio`, `du`, `mv`…; reads ~ | +| [Trello](extensions/trello) | Your boards and cards with labels, due dates and members; move, complete, archive and create cards | `api.trello.com`, `trello-members.s3.amazonaws.com`… | +| [Tunnels](extensions/tunnels) | Share a local port on a public HTTPS URL with ngrok or a Cloudflare quick tunnel, and keep track of the open ones | `127.0.0.1:4040`, `127.0.0.1:4041`…; runs `systemctl`, `journalctl`… | +| [Twitch](extensions/twitch) | Followed channels that are live with game, viewers and uptime, top categories and channel search; watch in the browser or with streamlink | `api.twitch.tv`, `id.twitch.tv`…; runs `systemctl`, `streamlink`… | +| [Uptime & SSL](extensions/uptime) | Watch your websites: HTTP status, response time and how long until their TLS certificate expires | any host; runs `openssl` | +| [Wallpapers](extensions/wallpapers) | Browse your wallpapers, find new ones on Wallhaven and Unsplash, and set them on any desktop | `wallhaven.cc`, `w.wallhaven.cc`…; runs `gsettings`, `plasma-apply-wallpaperimage`… | +| [Weather](extensions/weather) | Current conditions, the next 24 hours and the week ahead for any city, from Open-Meteo | `geocoding-api.open-meteo.com`, `api.open-meteo.com` | +| [Web Analytics](extensions/web-analytics) | Visitors, pageviews, bounce rate, top pages and sources of your sites from Plausible, Umami and PostHog | any host | +| [Web Search](extensions/web-search) | Search the web with live suggestions, 50 !bangs and your recent searches, on the engine you prefer | `duckduckgo.com`, `suggestqueries.google.com` | +| [Wikipedia](extensions/wikipedia) | Search Wikipedia in your language, read summaries and whole articles, and discover random pages | `en.wikipedia.org`, `fr.wikipedia.org`… | +| [Window Manager](extensions/window-manager) | Snap, maximize, center, close and move the focused window between workspaces | runs `systemd-run`, `hyprctl`, `swaymsg`…; reads /etc/os-release | +| [World Clock](extensions/world-clock) | The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris” | none | +| [YouTube](extensions/youtube) | Search YouTube with thumbnails, views and durations, open videos in the browser or play them in mpv in the background, and browse channels | `www.googleapis.com`, `i.ytimg.com`; runs `systemd-run`, `systemctl`, `mpv`…; reads /etc/os-release | +| [Zotero](extensions/zotero) | Search your Zotero library by title, author or tag, copy APA citations and BibTeX, and open PDFs | `127.0.0.1:23119`; runs `sqlite3`; reads ~/Zotero, ~/.cache/backslash-zotero… | diff --git a/extensions/authenticator/backslash.toml b/extensions/authenticator/backslash.toml new file mode 100644 index 0000000..f40b8c2 --- /dev/null +++ b/extensions/authenticator/backslash.toml @@ -0,0 +1,99 @@ +[extension] +id = "authenticator" +title = "Authenticator" +description = "Two-factor (TOTP) codes for your accounts, with secrets kept in the system keyring" +version = "1.0.0" +author = "Backslash" +icon = "bs-shield" +license = "MIT" + +# Secrets never go to the extension's storage in clear: +# - secret-tool stores each one in the system keyring (libsecret: GNOME +# Keyring, KWallet, KeePassXC), under application=backslash-authenticator; +# - without a keyring, they are encrypted (AES-256-GCM, key derived from the +# "Vault passphrase" preference) before being stored. +# Scanning a QR code on screen runs in the background (systemd-run) once the +# launcher is hidden, with the first area capture tool installed (grim + slurp, +# gnome-screenshot, spectacle, maim, scrot or ImageMagick's import), then +# zbarimg; the scanned link goes straight into the keyring. Here those programs +# are only started to check that they are installed. wl-paste and xclip read a +# copied picture of a QR code. Exports of Aegis and 2FAS are imported from +# ~/Downloads or ~/Documents. +[permissions] +run = [ + "secret-tool", + "systemd-run", + "zbarimg", + "grim", + "slurp", + "gnome-screenshot", + "spectacle", + "maim", + "scrot", + "import", + "wl-paste", + "xclip", +] +read = ["~/.cache/backslash/authenticator", "~/Downloads", "~/Documents", "/etc/os-release"] +write = ["~/.cache/backslash/authenticator"] +env = [ + "HOME", + "DBUS_SESSION_BUS_ADDRESS", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] +clipboard-read = true + +[[commands]] +name = "codes" +title = "Authenticator" +description = "Your two-factor codes: Enter copies, the code refreshes live" +icon = "bs-shield" +keywords = [ + "2fa", + "totp", + "otp", + "mfa", + "authenticator", + "one-time code", + "code", + "verification", + "double authentification", + "code de vérification", + "authentification", + "google authenticator", + "aegis", +] + +[[commands]] +name = "add" +title = "Add 2FA Account" +description = "Paste an otpauth:// link or enter the secret key shown by the website" +icon = "bs-plus" +keywords = ["2fa", "totp", "otp", "add", "ajouter", "new account", "nouveau compte", "secret", "otpauth"] + +[[commands]] +name = "scan-screen" +title = "Scan 2FA QR Code on Screen" +description = "Select the QR code a website shows: the account is added to Authenticator" +icon = "bs-scan" +mode = "no-view" +keywords = ["2fa", "totp", "qr", "scan", "scanner", "screen", "écran", "add", "ajouter", "authenticator"] + +[[preferences]] +name = "passphrase" +title = "Vault passphrase" +description = "Only used when no system keyring is available: secrets are then encrypted with this passphrase in the extension's storage" +type = "secret" + +[[preferences]] +name = "showNext" +title = "Show the next code" +description = "Shows the upcoming code in the list, handy near the end of a period" +type = "boolean" +default = true diff --git a/extensions/authenticator/commands/add.ts b/extensions/authenticator/commands/add.ts new file mode 100644 index 0000000..b2b5b2c --- /dev/null +++ b/extensions/authenticator/commands/add.ts @@ -0,0 +1,12 @@ +import type { Context } from "@backslash/api"; +import { linkForm, manualForm, type Prefs } from "../ui.ts"; + +// A copied otpauth:// link opens the link form, filled in; otherwise the secret key form. +export default async (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + const query = ctx.query.trim(); + if (/^otpauth(-migration)?:\/\//i.test(query)) return linkForm(prefs, true); + const links = await linkForm(prefs, true); + const prefilled = links.fields[0]?.value; + return prefilled ? links : manualForm(prefs, true); +}; diff --git a/extensions/authenticator/commands/codes.ts b/extensions/authenticator/commands/codes.ts new file mode 100644 index 0000000..9a8f1af --- /dev/null +++ b/extensions/authenticator/commands/codes.ts @@ -0,0 +1,54 @@ +import { type Context, Icons, type Item, list, toast, updateCommandMetadata } from "@backslash/api"; +import { sortAccounts } from "../lib.ts"; +import { importScans, keyring, loadAll, type Loaded, nextTick, VaultError } from "../system.ts"; +import { addActions, addedMessage, addItems, codeItem, codesFor, type Prefs, setupItem } from "../ui.ts"; + +let count = 0; +let locked = 0; + +export default (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + return list({ + placeholder: "Search accounts…", + empty: { title: "No matching account", description: "Ctrl+K adds one", icon: Icons.shield }, + actions: addActions(prefs), + status: () => + count + ? `${count} account${count === 1 ? "" : "s"}${locked ? ` · ${locked} locked` : ""} · Enter copies the code` + : "Add your first account", + // Secrets are read when the list opens and after a change; codes tick every second. + async *items({ signal }) { + let accounts: Loaded[] = []; + let extra: Item[] = []; + let reload = true; + while (!signal.aborted) { + if (reload) { + const ring = await keyring(); + extra = []; + try { + const scanned = await importScans(prefs.passphrase); + if (scanned.result.added.length || scanned.result.duplicates || scanned.skipped.length) { + toast(addedMessage(scanned.result, scanned.skipped), { style: "success" }).catch(() => {}); + } + } catch (e) { + if (!(e instanceof VaultError)) throw e; + extra.push(await setupItem(ring, e.message)); + } + accounts = sortAccounts(await loadAll(prefs.passphrase)); + count = accounts.length; + locked = accounts.filter((a) => !a.key).length; + if (ring !== "ok" && !prefs.passphrase && !extra.length) extra.push(await setupItem(ring)); + extra.push(...addItems(prefs, ring)); + updateCommandMetadata({ subtitle: count ? `${count} account${count === 1 ? "" : "s"}` : null }).catch( + () => {}, + ); + } + const seconds = Date.now() / 1000; + const rows = await Promise.all(accounts.map(async (a) => codeItem(a, await codesFor(a, seconds), prefs))); + yield [...rows, ...extra]; + // Wake up right after the next second, so the countdown stays exact. + reload = (await nextTick(1000 - (Date.now() % 1000) + 20, signal)) === "change"; + } + }, + }); +}; diff --git a/extensions/authenticator/commands/scan-screen.ts b/extensions/authenticator/commands/scan-screen.ts new file mode 100644 index 0000000..af6d385 --- /dev/null +++ b/extensions/authenticator/commands/scan-screen.ts @@ -0,0 +1,16 @@ +import { run } from "@backslash/api"; +import { capturePackages, installCommand } from "../lib.ts"; +import { family, MissingTool, scanScreen, session } from "../system.ts"; + +// Hides the launcher right away; the background job selects, decodes and keeps +// the link (in the keyring) until Authenticator is opened again. +export default run(async () => { + try { + await scanScreen(); + } catch (e) { + if (!(e instanceof MissingTool)) throw e; + const fam = await family(); + const packages = e.packages.length ? e.packages : capturePackages(session()); + throw new Error(`${e.message}. Install it: ${fam ? installCommand(packages, fam) : packages.join(", ")}`); + } +}); diff --git a/extensions/authenticator/lib.ts b/extensions/authenticator/lib.ts new file mode 100644 index 0000000..abd12d3 --- /dev/null +++ b/extensions/authenticator/lib.ts @@ -0,0 +1,647 @@ +// TOTP (RFC 6238), base32, otpauth:// links, Google Authenticator migration +// codes, Aegis and 2FAS exports, the passphrase vault and the background scan +// script. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Base32 (RFC 4648) + +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** Decodes base32, ignoring case, spaces, dashes and padding. Throws on other characters. */ +export function base32Decode(text: string): Uint8Array { + const clean = text.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, ""); + const out: number[] = []; + let bits = 0; + let value = 0; + for (const c of clean) { + const v = ALPHABET.indexOf(c); + if (v < 0) throw new Error(`“${c}” is not a base32 character`); + value = (value << 5) | v; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return new Uint8Array(out); +} + +export function base32Encode(bytes: Uint8Array, pad = false): string { + let out = ""; + let bits = 0; + let value = 0; + for (const b of bytes) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + out += ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31]; + if (pad) { while (out.length % 8) out += "="; } + return out; +} + +/** A secret as typed or pasted, normalized ("jbsw y3dp-ehpk 3pxp" → "JBSWY3DPEHPK3PXP"); throws when invalid. */ +export function normalizeSecret(text: string): string { + const clean = text.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, ""); + if (!clean) throw new Error("The secret key is empty"); + const bytes = base32Decode(clean); + if (bytes.length < 5) throw new Error("The secret key is too short"); + return clean; +} + +// --------------------------------------------------------------------------- +// TOTP + +export type Algorithm = "SHA1" | "SHA256" | "SHA512"; +export const ALGORITHMS: Algorithm[] = ["SHA1", "SHA256", "SHA512"]; + +export interface OtpParams { + digits: number; + period: number; + algorithm: Algorithm; +} + +export const DEFAULTS: OtpParams = { digits: 6, period: 30, algorithm: "SHA1" }; + +const HASH: Record = { SHA1: "SHA-1", SHA256: "SHA-256", SHA512: "SHA-512" }; + +/** HOTP (RFC 4226) of a counter. */ +export async function hotp( + key: Uint8Array, + counter: number, + digits = 6, + algorithm: Algorithm = "SHA1", +): Promise { + const msg = new Uint8Array(8); + const view = new DataView(msg.buffer); + view.setUint32(0, Math.floor(counter / 2 ** 32)); + view.setUint32(4, counter >>> 0); + const k = await crypto.subtle.importKey( + "raw", + key as Uint8Array, + { name: "HMAC", hash: HASH[algorithm] }, + false, + ["sign"], + ); + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, msg)); + const offset = mac[mac.length - 1] & 0x0f; + const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]; + return String(bin % 10 ** digits).padStart(digits, "0"); +} + +/** TOTP (RFC 6238) at `seconds` since the epoch. */ +export function totp(key: Uint8Array, seconds: number, p: OtpParams = DEFAULTS): Promise { + return hotp(key, Math.floor(seconds / p.period), p.digits, p.algorithm); +} + +/** Seconds before the code changes. */ +export const secondsLeft = (seconds: number, period: number) => period - (Math.floor(seconds) % period); + +/** "123456" → "123 456", "12345678" → "1234 5678", "1234567" → "123 4567". */ +export function groupCode(code: string): string { + if (code.length < 6) return code; + const half = Math.floor(code.length / 2); + return `${code.slice(0, half)} ${code.slice(half)}`; +} + +// --------------------------------------------------------------------------- +// Accounts + +export interface Account extends OtpParams { + issuer: string; + account: string; + /** Base32, normalized. */ + secret: string; +} + +function algorithmOf(text: string | undefined): Algorithm { + const a = (text ?? "SHA1").toUpperCase().replace(/[-_]/g, ""); + if (a === "SHA1" || a === "SHA256" || a === "SHA512") return a; + throw new Error(`Unsupported algorithm ${text}: use SHA1, SHA256 or SHA512`); +} + +export function validParams(digits: number, period: number): void { + if (!Number.isInteger(digits) || digits < 6 || digits > 10) throw new Error("Digits must be between 6 and 10"); + if (!Number.isInteger(period) || period < 5 || period > 300) throw new Error("Period must be 5 to 300 seconds"); +} + +/** Builds an account from form fields; throws with a readable message. */ +export function accountFrom(fields: { + issuer?: string; + account?: string; + secret: string; + digits?: string | number; + period?: string | number; + algorithm?: string; +}): Account { + const digits = Number(fields.digits || 6); + const period = Number(fields.period || 30); + validParams(digits, period); + const issuer = (fields.issuer ?? "").trim(); + const account = (fields.account ?? "").trim(); + if (!issuer && !account) throw new Error("Give the account an issuer or a name"); + return { + issuer, + account, + secret: normalizeSecret(fields.secret), + digits, + period, + algorithm: algorithmOf(fields.algorithm), + }; +} + +/** `otpauth://totp/GitHub:ada?secret=…&issuer=GitHub&digits=6&period=30&algorithm=SHA1`. */ +export function parseOtpauth(uri: string): Account { + const text = uri.trim(); + const m = text.match(/^otpauth:\/\/([a-z]+)\/([^?]*)(?:\?(.*))?$/i); + if (!m) throw new Error("Not an otpauth:// link"); + const type = m[1].toLowerCase(); + if (type === "hotp") throw new Error("Counter-based (HOTP) codes aren't supported, only time-based (TOTP)"); + if (type !== "totp") throw new Error(`Unknown code type “${m[1]}”`); + const params = new URLSearchParams(m[3] ?? ""); + let label = m[2]; + try { + label = decodeURIComponent(label); + } catch { + // Keep it as it is. + } + const colon = label.indexOf(":"); + const prefix = colon >= 0 ? label.slice(0, colon).trim() : ""; + const account = (colon >= 0 ? label.slice(colon + 1) : label).trim(); + const secret = params.get("secret"); + if (!secret) throw new Error("The link has no secret"); + return accountFrom({ + issuer: params.get("issuer")?.trim() || prefix, + account, + secret, + digits: params.get("digits") ?? undefined, + period: params.get("period") ?? undefined, + algorithm: params.get("algorithm") ?? undefined, + }); +} + +export function buildOtpauth(a: Account): string { + const label = a.issuer ? `${encodeURIComponent(a.issuer)}:${encodeURIComponent(a.account)}` : encodeURIComponent( + a.account, + ); + const params = new URLSearchParams({ secret: a.secret }); + if (a.issuer) params.set("issuer", a.issuer); + params.set("algorithm", a.algorithm); + params.set("digits", String(a.digits)); + params.set("period", String(a.period)); + return `otpauth://totp/${label}?${params}`; +} + +// --------------------------------------------------------------------------- +// Google Authenticator export (otpauth-migration://offline?data=…, protobuf) + +function readVarint(buf: Uint8Array, pos: number): [number, number] { + let result = 0; + let shift = 0; + while (pos < buf.length) { + const b = buf[pos++]; + result += (b & 0x7f) * 2 ** shift; + if (!(b & 0x80)) return [result, pos]; + shift += 7; + } + throw new Error("Truncated data"); +} + +/** Fields of a protobuf message: number → values (numbers for varints, bytes otherwise). */ +function protoFields(buf: Uint8Array): Map { + const fields = new Map(); + let pos = 0; + while (pos < buf.length) { + const [key, p] = readVarint(buf, pos); + pos = p; + const field = Math.floor(key / 8); + const wire = key & 7; + let value: number | Uint8Array; + if (wire === 0) [value, pos] = readVarint(buf, pos); + else if (wire === 2) { + const [len, p2] = readVarint(buf, pos); + value = buf.subarray(p2, p2 + len); + pos = p2 + len; + } else if (wire === 1) { + value = buf.subarray(pos, pos + 8); + pos += 8; + } else if (wire === 5) { + value = buf.subarray(pos, pos + 4); + pos += 4; + } else throw new Error("Unsupported data"); + fields.set(field, [...(fields.get(field) ?? []), value]); + } + return fields; +} + +export interface ImportResult { + accounts: Account[]; + /** Entries left out, with why. */ + skipped: string[]; +} + +/** Accounts of a Google Authenticator "Transfer accounts" QR code. */ +export function parseMigration(uri: string): ImportResult { + const m = uri.trim().match(/^otpauth-migration:\/\/offline\?(.*)$/i); + if (!m) throw new Error("Not a Google Authenticator export"); + const data = new URLSearchParams(m[1]).get("data"); + if (!data) throw new Error("The export has no data"); + const raw = Uint8Array.from(atob(data.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); + const dec = new TextDecoder(); + const result: ImportResult = { accounts: [], skipped: [] }; + for (const param of protoFields(raw).get(1) ?? []) { + if (!(param instanceof Uint8Array)) continue; + const f = protoFields(param); + const str = (n: number) => { + const v = f.get(n)?.[0]; + return v instanceof Uint8Array ? dec.decode(v) : ""; + }; + const num = (n: number) => { + const v = f.get(n)?.[0]; + return typeof v === "number" ? v : 0; + }; + const secret = f.get(1)?.[0]; + let name = str(2); + let issuer = str(3); + if (!issuer && name.includes(":")) { + [issuer, name] = [name.slice(0, name.indexOf(":")), name.slice(name.indexOf(":") + 1)]; + } else if (issuer && name.startsWith(`${issuer}:`)) name = name.slice(issuer.length + 1); + const label = issuer || name || "Unnamed"; + if (num(6) === 1) { + result.skipped.push(`${label} (counter-based)`); + continue; + } + const algo = ({ 0: "SHA1", 1: "SHA1", 2: "SHA256", 3: "SHA512" } as Record)[num(4)]; + if (!algo || !(secret instanceof Uint8Array)) { + result.skipped.push(`${label} (unsupported)`); + continue; + } + result.accounts.push({ + issuer: issuer.trim(), + account: name.trim(), + secret: base32Encode(secret), + digits: num(5) === 2 ? 8 : 6, + period: 30, + algorithm: algo, + }); + } + return result; +} + +// --------------------------------------------------------------------------- +// Aegis and 2FAS exports (plain, unencrypted JSON), or a list of links + +/** Reads an Aegis or 2FAS JSON export, or text with one otpauth:// link per line. */ +export function parseExport(text: string): ImportResult { + const trimmed = text.trim(); + if (!trimmed) throw new Error("The file is empty"); + if (!trimmed.startsWith("{")) return parseLinks(trimmed); + let data: any; + try { + data = JSON.parse(trimmed); + } catch { + throw new Error("The file isn't valid JSON"); + } + const result: ImportResult = { accounts: [], skipped: [] }; + const add = (label: string, build: () => Account) => { + try { + result.accounts.push(build()); + } catch (e) { + result.skipped.push(`${label || "Unnamed"} (${(e as Error).message})`); + } + }; + if (data.db !== undefined && data.header !== undefined) { + // Aegis + if (typeof data.db === "string" || (data.header?.slots ?? null) !== null) { + throw new Error("This Aegis export is encrypted: export it again without encryption"); + } + for (const e of data.db?.entries ?? []) { + const label = e.issuer || e.name; + if (String(e.type).toLowerCase() !== "totp") { + result.skipped.push(`${label || "Unnamed"} (${e.type})`); + continue; + } + add(label, () => + accountFrom({ + issuer: e.issuer, + account: e.name, + secret: String(e.info?.secret ?? ""), + digits: e.info?.digits, + period: e.info?.period, + algorithm: e.info?.algo, + })); + } + return result; + } + if (Array.isArray(data.services)) { + // 2FAS + if (!data.services.length && data.servicesEncrypted) { + throw new Error("This 2FAS backup is encrypted: export it again without a password"); + } + const services = [...data.services].sort((a, b) => (a.order?.position ?? 0) - (b.order?.position ?? 0)); + for (const s of services) { + const otp = s.otp ?? {}; + const label = otp.issuer || s.name; + const type = String(otp.tokenType ?? "TOTP").toUpperCase(); + if (type !== "TOTP") { + result.skipped.push(`${label || "Unnamed"} (${type})`); + continue; + } + add(label, () => + accountFrom({ + issuer: otp.issuer || s.name, + account: otp.account || otp.label || "", + secret: String(s.secret ?? ""), + digits: otp.digits, + period: otp.period, + algorithm: otp.algorithm, + })); + } + return result; + } + throw new Error("Not an Aegis or 2FAS export"); +} + +/** otpauth:// and otpauth-migration:// links, one per line. */ +export function parseLinks(text: string): ImportResult { + const result: ImportResult = { accounts: [], skipped: [] }; + for (const line of text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)) { + try { + if (/^otpauth-migration:/i.test(line)) { + const r = parseMigration(line); + result.accounts.push(...r.accounts); + result.skipped.push(...r.skipped); + } else result.accounts.push(parseOtpauth(line)); + } catch (e) { + result.skipped.push(`${line.slice(0, 40)}${line.length > 40 ? "…" : ""} (${(e as Error).message})`); + } + } + if (!result.accounts.length && !result.skipped.length) throw new Error("No otpauth:// link found"); + return result; +} + +/** Same issuer, account and secret: importing twice doesn't duplicate. */ +export const sameAccount = (a: Account, b: Account) => + a.secret === b.secret && a.issuer.toLowerCase() === b.issuer.toLowerCase() && + a.account.toLowerCase() === b.account.toLowerCase(); + +// --------------------------------------------------------------------------- +// Display + +export function displayName(a: { issuer: string; account: string }): { title: string; subtitle?: string } { + if (a.issuer) return { title: a.issuer, subtitle: a.account || undefined }; + return { title: a.account }; +} + +const TINTS = ["blue", "green", "purple", "orange", "teal", "pink", "red", "yellow"] as const; + +/** A stable color per issuer. */ +export function tintFor(name: string): (typeof TINTS)[number] { + let h = 0; + for (const c of name.toLowerCase()) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return TINTS[h % TINTS.length]; +} + +export function sortAccounts(list: T[]): T[] { + const key = (a: T) => `${(a.issuer || a.account).toLowerCase()}\u0000${a.account.toLowerCase()}`; + return [...list].sort((a, b) => key(a).localeCompare(key(b))); +} + +// --------------------------------------------------------------------------- +// Vault: secrets encrypted with a passphrase, when there is no keyring + +export interface Sealed { + iv: string; + data: string; +} + +const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); +const unb64 = (text: string) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); + +export const newSalt = () => b64(crypto.getRandomValues(new Uint8Array(16))); + +export const PBKDF2_ITERATIONS = 210_000; + +/** AES-256-GCM key from the passphrase (PBKDF2-SHA256). */ +export async function vaultKey(passphrase: string, salt: string, iterations = PBKDF2_ITERATIONS): Promise { + const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, [ + "deriveKey", + ]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: unb64(salt), iterations, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +export async function seal(key: CryptoKey, text: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text)), + ); + return { iv: b64(iv), data: b64(data) }; +} + +/** Throws when the key is wrong or the data was changed. */ +export async function unseal(key: CryptoKey, sealed: Sealed): Promise { + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(sealed.iv) }, key, unb64(sealed.data)); + return new TextDecoder().decode(plain); +} + +/** Encrypted with each vault: tells a wrong passphrase apart. */ +export const VAULT_CHECK = "backslash-authenticator"; + +// --------------------------------------------------------------------------- +// Session, background scan + +export type Desktop = "gnome" | "kde" | "hyprland" | "sway" | "other"; +export interface Session { + wayland: boolean; + desktop: Desktop; +} +export type Env = Partial>; + +export const SESSION_ENV = [ + "DBUS_SESSION_BUS_ADDRESS", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] as const; + +export function detectSession(env: Env): Session { + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const current = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const desktop: Desktop = env.HYPRLAND_INSTANCE_SIGNATURE || current.includes("hyprland") + ? "hyprland" + : env.SWAYSOCK || current.includes("sway") + ? "sway" + : current.some((d) => d === "gnome" || d === "ubuntu" || d === "unity" || d === "pop") + ? "gnome" + : current.includes("kde") + ? "kde" + : "other"; + return { wayland, desktop }; +} + +export type ShotTool = "grim" | "gnome-screenshot" | "spectacle" | "maim" | "scrot" | "import"; + +/** Tools that can capture an area in this session, best first. */ +export function areaTools(s: Session): ShotTool[] { + if (s.wayland) { + if (s.desktop === "gnome") return ["gnome-screenshot"]; + if (s.desktop === "kde") return ["spectacle"]; + return ["grim", "gnome-screenshot", "spectacle"]; + } + return ["maim", "scrot", "import", "gnome-screenshot", "spectacle"]; +} + +export function capturePackages(s: Session): string[] { + if (s.wayland) { + if (s.desktop === "gnome") return ["gnome-screenshot"]; + if (s.desktop === "kde") return ["spectacle"]; + return ["grim", "slurp"]; + } + return ["maim"]; +} + +/** Single-quotes for sh. */ +export const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +export function areaCommand(tool: ShotTool, file: string): string { + const f = q(file); + switch (tool) { + case "grim": + return `geometry=$(slurp -d) || exit 0; grim -g "$geometry" ${f}`; + case "gnome-screenshot": + return `gnome-screenshot -a -f ${f}`; + case "spectacle": + return `spectacle -b -n -r -o ${f}`; + case "maim": + return `maim -s ${f}`; + case "scrot": + return `scrot -s -o ${f}`; + case "import": + return `import ${f}`; + } +} + +/** The keyring attributes of a stored secret. */ +export const KEYRING_APP = "backslash-authenticator"; +export const secretAttrs = (id: string) => ["application", KEYRING_APP, "entry", id]; +export const pendingAttrs = (id: string) => ["application", KEYRING_APP, "pending", id]; + +export interface ScanOptions { + tool: ShotTool; + /** ~/.cache/backslash/authenticator */ + cache: string; + /** Put the scanned link in the keyring (else in a private file, mode 0600). */ + keyring: boolean; +} + +/** + * Background script: select an area, decode it, hand the link to the + * extension. The picture is deleted right away; with a keyring, the link goes + * into it and only a random id is written to disk. + */ +export function scanScript(o: ScanOptions): string { + const shot = `${o.cache}/scan.png`; + const scans = `${o.cache}/scans`; + const store = o.keyring + ? `printf %s "$out" | secret-tool store --label='Backslash Authenticator: scanned account' ${ + pendingAttrs('"$id"').map((a) => (a.startsWith('"') ? a : q(a))).join(" ") + } && : > ${q(`${scans}/`)}"$id.pending"` + : `printf '%s\\n' "$out" > ${q(`${scans}/`)}"$id.uri"`; + return [ + "umask 077", + "sleep 0.4", + `mkdir -p ${q(scans)}`, + `rm -f ${q(shot)}`, + `${areaCommand(o.tool, shot)} || exit 0`, + `[ -s ${q(shot)} ] || exit 0`, + `out=$(zbarimg --raw -q ${q(shot)} 2>/dev/null)`, + `rm -f ${q(shot)}`, + `case "$out" in`, + ` otpauth://*|otpauth-migration://*)`, + ` id=$(date +%s%N)`, + ` if ${store}; then`, + ` notify-send -a Backslash -i security-high 'Two-factor account scanned' 'Open Authenticator to finish adding it' 2>/dev/null`, + " else", + ` notify-send -a Backslash 'Could not save the scanned account' 'Is the keyring unlocked?' 2>/dev/null`, + " fi ;;", + ` '') notify-send -a Backslash 'No QR code found' 'Select the whole code with a little margin around it' 2>/dev/null ;;`, + ` *) notify-send -a Backslash 'Not a two-factor QR code' 'It holds no otpauth:// link' 2>/dev/null ;;`, + "esac", + "true", + ].join("\n"); +} + +export function systemdRunArgs(unit: string, script: string, env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: scan a two-factor QR code", + "--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 PACKAGES: Record>> = { + "secret-tool": { debian: "libsecret-tools", arch: "libsecret", fedora: "libsecret", suse: "libsecret-tools" }, + zbarimg: { debian: "zbar-tools", arch: "zbar", fedora: "zbar", suse: "zbar" }, + spectacle: { debian: "kde-spectacle" }, + import: { debian: "imagemagick", arch: "imagemagick", fedora: "ImageMagick", suse: "ImageMagick" }, +}; + +const MANAGERS: Record = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +const FAMILY_NAMES: Record = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +export function installCommand(programs: string[], family: Family): string { + return `${MANAGERS[family]} ${[...new Set(programs.map((p) => PACKAGES[p]?.[family] ?? p))].join(" ")}`; +} + +export function installMarkdown(what: string, programs: string[], family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + return `${what}\n\n${ + families.map((f) => `**${FAMILY_NAMES[f]}**\n\n\`${installCommand(programs, f)}\``).join("\n\n") + }`; +} diff --git a/extensions/authenticator/lib_test.ts b/extensions/authenticator/lib_test.ts new file mode 100644 index 0000000..df28ab2 --- /dev/null +++ b/extensions/authenticator/lib_test.ts @@ -0,0 +1,355 @@ +import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "@std/assert"; +import { + accountFrom, + areaTools, + base32Decode, + base32Encode, + buildOtpauth, + detectSession, + displayName, + groupCode, + hotp, + installCommand, + normalizeSecret, + osFamily, + parseExport, + parseLinks, + parseMigration, + parseOtpauth, + sameAccount, + scanScript, + seal, + secondsLeft, + sortAccounts, + systemdRunArgs, + tintFor, + totp, + unseal, + vaultKey, +} from "./lib.ts"; + +const ascii = (s: string) => new TextEncoder().encode(s); +const text = (b: Uint8Array) => new TextDecoder().decode(b); + +Deno.test("base32: RFC 4648 test vectors", () => { + const vectors: [string, string][] = [ + ["", ""], + ["f", "MY======"], + ["fo", "MZXQ===="], + ["foo", "MZXW6==="], + ["foob", "MZXW6YQ="], + ["fooba", "MZXW6YTB"], + ["foobar", "MZXW6YTBOI======"], + ]; + for (const [plain, encoded] of vectors) { + assertEquals(text(base32Decode(encoded)), plain); + assertEquals(base32Encode(ascii(plain), true), encoded); + assertEquals(base32Encode(ascii(plain)), encoded.replace(/=+$/, "")); + } +}); + +Deno.test("base32: lenient input, strict alphabet", () => { + assertEquals(text(base32Decode("mzxw 6ytb-oi")), "foobar"); + assertEquals(normalizeSecret("jbsw y3dp ehpk 3pxp"), "JBSWY3DPEHPK3PXP"); + assertThrows(() => base32Decode("MZXW1"), Error, "not a base32 character"); + assertThrows(() => normalizeSecret(" "), Error, "empty"); + assertThrows(() => normalizeSecret("MZXW"), Error, "too short"); +}); + +Deno.test("HOTP: RFC 4226 appendix D", async () => { + const key = ascii("12345678901234567890"); + const expected = ["755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489"]; + for (let i = 0; i < expected.length; i++) assertEquals(await hotp(key, i), expected[i]); +}); + +Deno.test("TOTP: RFC 6238 appendix B, SHA-1, SHA-256 and SHA-512", async () => { + const keys = { + SHA1: ascii("12345678901234567890"), + SHA256: ascii("12345678901234567890123456789012"), + SHA512: ascii("1234567890123456789012345678901234567890123456789012345678901234"), + }; + const table: [number, string, string, string][] = [ + [59, "94287082", "46119246", "90693936"], + [1111111109, "07081804", "68084774", "25091201"], + [1111111111, "14050471", "67062674", "99943326"], + [1234567890, "89005924", "91819424", "93441116"], + [2000000000, "69279037", "90698825", "38618901"], + [20000000000, "65353130", "77737706", "47863826"], + ]; + for (const [t, sha1, sha256, sha512] of table) { + assertEquals(await totp(keys.SHA1, t, { digits: 8, period: 30, algorithm: "SHA1" }), sha1, `SHA1 @${t}`); + assertEquals(await totp(keys.SHA256, t, { digits: 8, period: 30, algorithm: "SHA256" }), sha256, `SHA256 @${t}`); + assertEquals(await totp(keys.SHA512, t, { digits: 8, period: 30, algorithm: "SHA512" }), sha512, `SHA512 @${t}`); + } + // 6 digits are the last 6 of the 8. + assertEquals(await totp(keys.SHA1, 59), "287082"); +}); + +Deno.test("countdown and grouping", () => { + assertEquals(secondsLeft(59, 30), 1); + assertEquals(secondsLeft(60, 30), 30); + assertEquals(secondsLeft(61.9, 30), 29); + assertEquals(groupCode("123456"), "123 456"); + assertEquals(groupCode("12345678"), "1234 5678"); + assertEquals(groupCode("1234567"), "123 4567"); +}); + +Deno.test("otpauth links", () => { + const a = parseOtpauth( + "otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30", + ); + assertEquals(a, { + issuer: "ACME Co", + account: "john.doe@email.com", + secret: "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", + digits: 6, + period: 30, + algorithm: "SHA1", + }); + // The issuer parameter wins over the label prefix; defaults apply. + const b = parseOtpauth("otpauth://totp/Old:ada?secret=jbswy3dpehpk3pxp&issuer=GitHub"); + assertEquals([b.issuer, b.account, b.secret, b.digits, b.period], ["GitHub", "ada", "JBSWY3DPEHPK3PXP", 6, 30]); + const c = parseOtpauth("otpauth://totp/ada@proton.me?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=8&period=60"); + assertEquals([c.issuer, c.account, c.algorithm, c.digits, c.period], ["", "ada@proton.me", "SHA256", 8, 60]); + assertThrows(() => parseOtpauth("otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP&counter=1"), Error, "HOTP"); + assertThrows(() => parseOtpauth("otpauth://totp/x?issuer=a"), Error, "no secret"); + assertThrows(() => parseOtpauth("https://example.com"), Error, "Not an otpauth"); + assertThrows(() => parseOtpauth("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&algorithm=MD5"), Error, "MD5"); + // Round trip. + assertEquals(parseOtpauth(buildOtpauth(a)), a); + assertEquals(parseOtpauth(buildOtpauth({ ...a, issuer: "" })).issuer, ""); +}); + +Deno.test("form values", () => { + const a = accountFrom({ + issuer: " GitHub ", + account: "ada", + secret: "jbsw y3dp ehpk 3pxp", + digits: "8", + period: "30", + }); + assertEquals([a.issuer, a.secret, a.digits, a.algorithm], ["GitHub", "JBSWY3DPEHPK3PXP", 8, "SHA1"]); + assertThrows(() => accountFrom({ secret: "JBSWY3DPEHPK3PXP" }), Error, "issuer or a name"); + assertThrows(() => accountFrom({ issuer: "x", secret: "JBSWY3DPEHPK3PXP", digits: "4" }), Error, "Digits"); + assertThrows(() => accountFrom({ issuer: "x", secret: "JBSWY3DPEHPK3PXP", period: "abc" }), Error, "Period"); +}); + +// A tiny protobuf writer, to build Google Authenticator exports. +function varint(n: number): number[] { + const out: number[] = []; + while (n > 127) { + out.push((n & 127) | 128); + n = Math.floor(n / 128); + } + out.push(n); + return out; +} +const bytesField = ( + field: number, + data: Uint8Array | number[], +) => [...varint(field * 8 + 2), ...varint(data.length), ...data]; +const intField = (field: number, n: number) => [...varint(field * 8), ...varint(n)]; + +Deno.test("Google Authenticator transfer QR codes", () => { + const google = [ + ...bytesField(1, ascii("12345678901234567890")), + ...bytesField(2, ascii("ada@gmail.com")), + ...bytesField(3, ascii("Google")), + ...intField(4, 1), + ...intField(5, 1), + ...intField(6, 2), + ]; + const prefixed = [ + ...bytesField(1, ascii("abcdefghij")), + ...bytesField(2, ascii("Dropbox:ada")), + ...intField(4, 2), + ...intField(5, 2), + ...intField(6, 2), + ]; + const counter = [...bytesField(1, ascii("abcdefghij")), ...bytesField(2, ascii("Old bank")), ...intField(6, 1)]; + const payload = new Uint8Array([ + ...bytesField(1, google), + ...bytesField(1, prefixed), + ...bytesField(1, counter), + ...intField(2, 1), + ...intField(3, 1), + ]); + const uri = `otpauth-migration://offline?data=${encodeURIComponent(btoa(String.fromCharCode(...payload)))}`; + const r = parseMigration(uri); + assertEquals(r.accounts, [ + { + issuer: "Google", + account: "ada@gmail.com", + secret: base32Encode(ascii("12345678901234567890")), + digits: 6, + period: 30, + algorithm: "SHA1", + }, + { + issuer: "Dropbox", + account: "ada", + secret: base32Encode(ascii("abcdefghij")), + digits: 8, + period: 30, + algorithm: "SHA256", + }, + ]); + assertEquals(r.skipped, ["Old bank (counter-based)"]); + // Also accepted among links. + assertEquals(parseLinks(`${uri}\notpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP`).accounts.length, 3); +}); + +const AEGIS = JSON.stringify({ + version: 1, + header: { slots: null, params: null }, + db: { + version: 2, + entries: [ + { + type: "totp", + uuid: "3ae6f1ad-2e65-4ed2-a953-1ec0dff2386d", + name: "ada@example.com", + issuer: "Mastodon", + note: "", + icon: null, + info: { secret: "4SJHB4GSD43FZBAI7C2HLRJGPQ", algo: "SHA1", digits: 6, period: 30 }, + }, + { + type: "totp", + uuid: "a", + name: "ada", + issuer: "Bitwarden", + info: { secret: "JBSWY3DPEHPK3PXP", algo: "SHA256", digits: 8, period: 60 }, + }, + { type: "hotp", uuid: "b", name: "x", issuer: "Bank", info: { secret: "JBSWY3DPEHPK3PXP", counter: 3 } }, + { type: "steam", uuid: "c", name: "gaben", issuer: "Steam", info: { secret: "JBSWY3DPEHPK3PXP" } }, + { type: "totp", uuid: "d", name: "bad", issuer: "Broken", info: { secret: "!!!", algo: "SHA1" } }, + ], + }, +}); + +Deno.test("Aegis plain export", () => { + const r = parseExport(AEGIS); + assertEquals(r.accounts.map((a) => [a.issuer, a.account, a.algorithm, a.digits, a.period]), [ + ["Mastodon", "ada@example.com", "SHA1", 6, 30], + ["Bitwarden", "ada", "SHA256", 8, 60], + ]); + assertEquals(r.skipped.length, 3); + assertStringIncludes(r.skipped.join(), "Bank (hotp)"); + assertStringIncludes(r.skipped.join(), "Broken"); + const encrypted = JSON.stringify({ version: 1, header: { slots: [{ type: 1 }], params: {} }, db: "b64…" }); + assertThrows(() => parseExport(encrypted), Error, "encrypted"); +}); + +Deno.test("2FAS export", () => { + const twofas = JSON.stringify({ + services: [ + { + name: "GitLab", + secret: "JBSWY3DPEHPK3PXP", + otp: { + label: "ada", + account: "ada", + issuer: "GitLab", + digits: 6, + period: 30, + algorithm: "SHA1", + tokenType: "TOTP", + }, + order: { position: 1 }, + }, + { + name: "Discord", + secret: "4SJHB4GSD43FZBAI7C2HLRJGPQ", + otp: { account: "ada#1234", digits: 6, period: 30, algorithm: "SHA1", tokenType: "TOTP" }, + order: { position: 0 }, + }, + { name: "Bank", secret: "JBSWY3DPEHPK3PXP", otp: { tokenType: "HOTP", counter: 2 }, order: { position: 2 } }, + ], + schemaVersion: 4, + appVersionCode: 5000000, + }); + const r = parseExport(twofas); + assertEquals(r.accounts.map((a) => [a.issuer, a.account]), [["Discord", "ada#1234"], ["GitLab", "ada"]]); + assertEquals(r.skipped, ["Bank (HOTP)"]); + assertThrows( + () => parseExport(JSON.stringify({ services: [], servicesEncrypted: "abc", schemaVersion: 4 })), + Error, + "encrypted", + ); + assertThrows(() => parseExport('{"hello": 1}'), Error, "Not an Aegis or 2FAS"); + assertThrows(() => parseExport("{oops"), Error, "valid JSON"); +}); + +Deno.test("plain text of links", () => { + const r = parseExport("otpauth://totp/A:a?secret=JBSWY3DPEHPK3PXP\n\nhttps://nope\n"); + assertEquals(r.accounts.length, 1); + assertEquals(r.skipped.length, 1); + assertThrows(() => parseLinks(""), Error, "No otpauth"); +}); + +Deno.test("duplicates, names, colors, order", () => { + const a = parseOtpauth("otpauth://totp/GitHub:ada?secret=JBSWY3DPEHPK3PXP"); + assert(sameAccount(a, { ...a, issuer: "github" })); + assert(!sameAccount(a, { ...a, secret: "4SJHB4GSD43FZBAI7C2HLRJGPQ" })); + assertEquals(displayName(a), { title: "GitHub", subtitle: "ada" }); + assertEquals(displayName({ issuer: "", account: "ada@x" }), { title: "ada@x" }); + assertEquals(tintFor("GitHub"), tintFor("github")); + assertEquals( + sortAccounts([{ issuer: "proton", account: "b" }, { issuer: "", account: "Amazon" }, { + issuer: "Proton", + account: "a", + }]), + [{ issuer: "", account: "Amazon" }, { issuer: "Proton", account: "a" }, { issuer: "proton", account: "b" }], + ); +}); + +Deno.test("vault: encrypted with the passphrase, a wrong one fails", async () => { + const salt = btoa("0123456789abcdef"); + const key = await vaultKey("correct horse", salt, 1000); + const sealed = await seal(key, "JBSWY3DPEHPK3PXP"); + assert(!sealed.data.includes("JBSW")); + assertEquals(await unseal(key, sealed), "JBSWY3DPEHPK3PXP"); + const wrong = await vaultKey("battery staple", salt, 1000); + await assertRejects(() => unseal(wrong, sealed)); + // A fresh IV each time. + assert((await seal(key, "x")).iv !== (await seal(key, "x")).iv); +}); + +Deno.test("scan script: the link goes to the keyring, the picture is removed", () => { + const ring = scanScript({ tool: "grim", cache: "/home/ada/.cache/backslash/authenticator", keyring: true }); + const lines = ring.split("\n"); + assertEquals(lines[0], "umask 077"); + assertStringIncludes( + ring, + `geometry=$(slurp -d) || exit 0; grim -g "$geometry" '/home/ada/.cache/backslash/authenticator/scan.png'`, + ); + assertStringIncludes( + ring, + `printf %s "$out" | secret-tool store --label='Backslash Authenticator: scanned account' 'application' 'backslash-authenticator' 'pending' "$id" && : > '/home/ada/.cache/backslash/authenticator/scans/'"$id.pending"`, + ); + // The picture is deleted before anything else happens with the link. + assert( + lines.indexOf(`rm -f '/home/ada/.cache/backslash/authenticator/scan.png'`, 5) < + lines.findIndex((l) => l.includes("secret-tool")), + ); + assert(!ring.includes(".uri")); + const file = scanScript({ tool: "maim", cache: "/c", keyring: false }); + assertStringIncludes(file, `printf '%s\\n' "$out" > '/c/scans/'"$id.uri"`); + assertStringIncludes(file, "maim -s '/c/scan.png'"); +}); + +Deno.test("background job and session", () => { + const args = systemdRunArgs("u", "echo", { DISPLAY: ":0", WAYLAND_DISPLAY: undefined }); + assertEquals(args.slice(0, 3), ["--user", "--quiet", "--unit=u"]); + assert(args.includes("--setenv=DISPLAY=:0")); + assert(!args.some((a) => a.startsWith("--setenv=WAYLAND"))); + assertEquals(args.slice(-3), ["/bin/sh", "-c", "echo"]); + assertEquals(areaTools(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "GNOME" })), [ + "gnome-screenshot", + ]); + assertEquals(areaTools(detectSession({ XDG_SESSION_TYPE: "x11" }))[0], "maim"); + assertEquals(osFamily("ID=ubuntu\nID_LIKE=debian"), "debian"); + assertEquals(installCommand(["secret-tool", "zbarimg"], "debian"), "sudo apt install libsecret-tools zbar-tools"); + assertEquals(installCommand(["secret-tool"], "arch"), "sudo pacman -S libsecret"); +}); diff --git a/extensions/authenticator/system.ts b/extensions/authenticator/system.ts new file mode 100644 index 0000000..88a90c8 --- /dev/null +++ b/extensions/authenticator/system.ts @@ -0,0 +1,459 @@ +// Where secrets live (system keyring, or the passphrase vault), the accounts +// list in storage, QR codes, and the background scan. + +import { exec, storage } from "@backslash/api"; +import { + type Account, + areaTools, + base32Decode, + detectSession, + type Env, + type Family, + type ImportResult, + newSalt, + osFamily, + parseLinks, + pendingAttrs, + sameAccount, + scanScript, + seal, + type Sealed, + secretAttrs, + type Session, + SESSION_ENV, + type ShotTool, + systemdRunArgs, + unseal, + VAULT_CHECK, + vaultKey, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key); + } catch { + return undefined; + } +}; + +export const HOME = env("HOME") ?? ""; +export const CACHE = `${HOME}/.cache/backslash/authenticator`; +export const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +export const session = (): Session => detectSession(sessionEnv()); +export const tilde = (p: string) => (HOME && p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p); + +export async function family(): Promise { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +export class MissingTool extends Error { + constructor(message: string, public packages: string[]) { + super(message); + } +} + +/** Whether a program can be started (installed and allowed). */ +export async function installed(program: string, args = ["--version"]): Promise { + try { + await new Deno.Command(program, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }).output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +// --------------------------------------------------------------------------- +// Keyring (secret-tool) + +/** Runs secret-tool; `undefined` when it isn't installed. */ +async function secretTool( + args: string[], + stdin?: string, +): Promise<{ code: number; stdout: string; stderr: string } | undefined> { + try { + const child = new Deno.Command("secret-tool", { + args, + stdin: stdin === undefined ? "null" : "piped", + stdout: "piped", + stderr: "piped", + signal: AbortSignal.timeout(20_000), + }).spawn(); + if (stdin !== undefined) { + const w = child.stdin.getWriter(); + await w.write(new TextEncoder().encode(stdin)); + await w.close(); + } + const out = await child.output(); + const dec = new TextDecoder(); + return { code: out.code, stdout: dec.decode(out.stdout).trim(), stderr: dec.decode(out.stderr).trim() }; + } catch (e) { + if (e instanceof Deno.errors.NotFound || e instanceof Deno.errors.NotCapable) return undefined; + throw e; + } +} + +export type KeyringState = "ok" | "missing" | "unavailable"; +let keyringState: Promise | undefined; + +/** Whether secrets can go to the system keyring: secret-tool installed and a Secret Service answering. */ +export function keyring(): Promise { + keyringState ??= (async () => { + if (!env("DBUS_SESSION_BUS_ADDRESS")) { + return (await secretTool(["--version"])) ? "unavailable" : "missing"; + } + const r = await secretTool(["lookup", ...secretAttrs("probe")]); + if (!r) return "missing"; + // Exit 1 with nothing on stderr: the service answered, there is just no such secret. + return r.code === 0 || (r.code === 1 && !r.stderr) ? "ok" : "unavailable"; + })(); + return keyringState; +} + +async function keyringStore(attrs: string[], label: string, secret: string): Promise { + const r = await secretTool(["store", `--label=${label}`, ...attrs], secret); + if (!r) throw new MissingTool("secret-tool is needed to use the keyring", ["secret-tool"]); + if (r.code !== 0) throw new Error(`The keyring refused the secret${r.stderr ? `: ${r.stderr}` : ""}`); +} + +async function keyringLookup(attrs: string[]): Promise { + const r = await secretTool(["lookup", ...attrs]); + return r?.code === 0 && r.stdout ? r.stdout : undefined; +} + +async function keyringClear(attrs: string[]): Promise { + await secretTool(["clear", ...attrs]); +} + +// --------------------------------------------------------------------------- +// Vault (passphrase), when there is no keyring + +interface VaultHeader { + salt: string; + check: Sealed; +} + +const keys = new Map>(); + +export class VaultError extends Error {} + +/** The vault key; creates the vault the first time. Throws VaultError on a wrong passphrase. */ +async function vault(passphrase: string | undefined): Promise { + if (!passphrase) throw new VaultError("No keyring found: set a vault passphrase to store secrets encrypted"); + let header = (await storage.get("vault")) as unknown as VaultHeader | undefined; + const cacheKey = `${passphrase}\u0000${header?.salt ?? ""}`; + if (header && keys.has(cacheKey)) return keys.get(cacheKey)!; + if (!header) { + const salt = newSalt(); + const key = await vaultKey(passphrase, salt); + header = { salt, check: await seal(key, VAULT_CHECK) }; + await storage.set("vault", header as any); + keys.set(`${passphrase}\u0000${salt}`, Promise.resolve(key)); + return key; + } + const key = await vaultKey(passphrase, header.salt); + try { + if ((await unseal(key, header.check)) !== VAULT_CHECK) throw new Error(); + } catch { + throw new VaultError("Wrong vault passphrase: it doesn't open the secrets saved before"); + } + keys.set(cacheKey, Promise.resolve(key)); + return key; +} + +// --------------------------------------------------------------------------- +// Accounts + +export interface Entry { + id: string; + issuer: string; + account: string; + digits: number; + period: number; + algorithm: Account["algorithm"]; + added: number; + store: "keyring" | "vault"; + /** Vault only: the encrypted secret. */ + sealed?: Sealed; +} + +export interface Loaded extends Entry { + /** The decoded secret, or why it couldn't be read. */ + key?: Uint8Array; + secret?: string; + error?: string; +} + +const entries = async (): Promise => ((await storage.get("entries")) ?? []) as unknown as Entry[]; +const saveEntries = (list: Entry[]) => storage.set("entries", list as any); + +/** Secrets read in this process, by entry id: read once, then codes tick every second. */ +const secrets = new Map(); + +export async function loadAll(passphrase?: string): Promise { + const list = await entries(); + let vaultKeyP: Promise | undefined; + return await Promise.all(list.map(async (e): Promise => { + try { + let secret = secrets.get(e.id); + if (!secret) { + if (e.store === "keyring") { + secret = await keyringLookup(secretAttrs(e.id)); + if (!secret) return { ...e, error: "Not found in the keyring: is it locked?" }; + } else { + vaultKeyP ??= vault(passphrase); + secret = await unseal(await vaultKeyP, e.sealed!); + } + secrets.set(e.id, secret); + } + return { ...e, secret, key: base32Decode(secret) }; + } catch (err) { + return { + ...e, + error: err instanceof VaultError ? err.message : `Can't read the secret: ${(err as Error).message}`, + }; + } + })); +} + +/** Where new secrets go: the keyring when it works, else the vault (needs the passphrase). */ +export async function storeKind(passphrase?: string): Promise<"keyring" | "vault"> { + if ((await keyring()) === "ok") return "keyring"; + if (passphrase) return "vault"; + const state = await keyring(); + throw new VaultError( + state === "missing" + ? "Install secret-tool to keep secrets in your keyring, or set a vault passphrase" + : "The keyring isn't reachable: unlock it, or set a vault passphrase", + ); +} + +export interface AddResult { + added: Entry[]; + duplicates: number; +} + +export async function addAccounts(accounts: Account[], passphrase?: string): Promise { + const kind = await storeKind(passphrase); + const list = await entries(); + const existing = await loadAll(passphrase); + const added: Entry[] = []; + let duplicates = 0; + for (const a of accounts) { + const known = [...existing, ...added.map((e) => ({ ...e, secret: secrets.get(e.id) }))]; + if (known.some((e) => e.secret && sameAccount({ ...e, secret: e.secret }, a))) { + duplicates++; + continue; + } + const id = crypto.randomUUID(); + const entry: Entry = { + id, + issuer: a.issuer, + account: a.account, + digits: a.digits, + period: a.period, + algorithm: a.algorithm, + added: Date.now(), + store: kind, + }; + if (kind === "keyring") { + await keyringStore(secretAttrs(id), `Backslash Authenticator: ${a.issuer || a.account}`, a.secret); + } else { + entry.sealed = await seal(await vault(passphrase), a.secret); + } + secrets.set(id, a.secret); + added.push(entry); + } + if (added.length) await saveEntries([...list, ...added]); + if (added.length) notifyChanged(); + return { added, duplicates }; +} + +export async function rename(id: string, issuer: string, account: string): Promise { + await saveEntries((await entries()).map((e) => (e.id === id ? { ...e, issuer, account } : e))); + notifyChanged(); +} + +export async function remove(id: string): Promise { + const list = await entries(); + const entry = list.find((e) => e.id === id); + if (entry?.store === "keyring") await keyringClear(secretAttrs(id)); + secrets.delete(id); + await saveEntries(list.filter((e) => e.id !== id)); + notifyChanged(); +} + +// --------------------------------------------------------------------------- +// Scans left by the background job + +/** Adds the accounts scanned on screen since last time. */ +export async function importScans(passphrase?: string): Promise<{ result: AddResult; skipped: string[] }> { + const dir = `${CACHE}/scans`; + const links: string[] = []; + const done: string[] = []; + const pending: string[] = []; + try { + for await (const f of Deno.readDir(dir)) { + if (!f.isFile) continue; + const path = `${dir}/${f.name}`; + if (f.name.endsWith(".uri")) { + links.push((await Deno.readTextFile(path).catch(() => "")).trim()); + done.push(path); + } else if (f.name.endsWith(".pending")) { + const id = f.name.replace(/\.pending$/, ""); + const link = await keyringLookup(pendingAttrs(id)); + // Locked keyring: keep it for the next time. + if (!link) continue; + links.push(link); + pending.push(id); + done.push(path); + } + } + } catch { + // Nothing scanned yet. + } + if (!links.length) { + for (const p of done) await Deno.remove(p).catch(() => {}); + return { result: { added: [], duplicates: 0 }, skipped: [] }; + } + const parsed = parseLinks(links.filter(Boolean).join("\n")); + const result = await addAccounts(parsed.accounts, passphrase); + // Only forget the scans once they are safely stored. + for (const id of pending) await keyringClear(pendingAttrs(id)); + for (const p of done) await Deno.remove(p).catch(() => {}); + return { result, skipped: parsed.skipped }; +} + +// --------------------------------------------------------------------------- +// QR codes + +const PROBE: Record = { import: ["-version"], slurp: ["-h"], grim: ["-h"] }; + +async function captureTool(s: Session): Promise { + for (const t of areaTools(s)) { + if (!(await installed(t, PROBE[t]))) continue; + if (t === "grim" && !(await installed("slurp", PROBE.slurp))) continue; + return t; + } + return undefined; +} + +/** Starts the background job: select an area, decode, keep the link for the next opening. */ +export async function scanScreen(): Promise { + const s = session(); + const [tool, zbar, ring] = await Promise.all([captureTool(s), installed("zbarimg"), keyring()]); + if (!zbar) throw new MissingTool("zbarimg is needed to read QR codes", ["zbarimg"]); + if (!tool) throw new MissingTool("No screenshot tool to select an area", []); + await Deno.mkdir(`${CACHE}/scans`, { recursive: true, mode: 0o700 }); + const script = scanScript({ tool, cache: CACHE, keyring: ring === "ok" }); + try { + await exec("systemd-run", systemdRunArgs(`backslash-authenticator-scan-${Date.now()}`, script, sessionEnv())); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to scan in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: scans run in the background with systemd-run --user"); + } + throw e; + } +} + +/** Decodes the QR code of a picture: its text, or undefined. */ +async function decode(path: string): Promise { + try { + return (await exec("zbarimg", ["--raw", "-q", "--", path])) || undefined; + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new MissingTool("zbarimg is needed to read QR codes", ["zbarimg"]); + if (/exited with 4/.test(String(e))) return undefined; // nothing found + throw e; + } +} + +/** The QR code in a copied picture (a screenshot), decoded. The picture is deleted right after. */ +export async function scanClipboardImage(): Promise { + await Deno.mkdir(CACHE, { recursive: true, mode: 0o700 }); + const file = `${CACHE}/clipboard-${Date.now()}.png`; + const tries: [string, string[]][] = [ + ["wl-paste", ["--no-newline", "--type", "image/png"]], + ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]], + ]; + if (!session().wayland) tries.reverse(); + let picture = false; + for (const [program, args] of tries) { + try { + const out = await new Deno.Command(program, { args, stdin: "null", stdout: "piped", stderr: "null" }).output(); + if (out.success && out.stdout.length > 8 && out.stdout[0] === 0x89 && out.stdout[1] === 0x50) { + await Deno.writeFile(file, out.stdout, { mode: 0o600 }); + picture = true; + break; + } + } catch { + // Not installed: try the next one. + } + } + if (!picture) throw new Error("No picture in the clipboard: copy a screenshot of the QR code first"); + try { + return await decode(file); + } finally { + await Deno.remove(file).catch(() => {}); + } +} + +// --------------------------------------------------------------------------- +// Import files + +export const IMPORT_FOLDERS = () => [`${HOME}/Downloads`, `${HOME}/Documents`]; + +/** Aegis / 2FAS exports and link lists in Downloads and Documents, newest first. */ +export async function exportFiles(): Promise<{ path: string; name: string; modified: number }[]> { + const found: { path: string; name: string; modified: number }[] = []; + for (const dir of IMPORT_FOLDERS()) { + try { + for await (const f of Deno.readDir(dir)) { + if (!f.isFile || !/(aegis|2fas|otp|authenticator).*\.(json|txt|2fas)$/i.test(f.name)) continue; + const st = await Deno.stat(`${dir}/${f.name}`).catch(() => undefined); + found.push({ path: `${dir}/${f.name}`, name: f.name, modified: st?.mtime?.getTime() ?? 0 }); + } + } catch { + // No such folder. + } + } + return found.sort((a, b) => b.modified - a.modified); +} + +export function expandHome(p: string): string { + return p.trim().replace(/^~(?=\/|$)/, HOME); +} + +export type { ImportResult }; + +// --------------------------------------------------------------------------- +// Live reload: the views of a command share one process. + +const changes = new EventTarget(); +export function notifyChanged(): void { + keyringState = undefined; + changes.dispatchEvent(new Event("change")); +} + +/** Resolves on the next change ("change"), after `ms` ("tick"), or when `signal` aborts. */ +export function nextTick(ms: number, signal: AbortSignal): Promise<"change" | "tick"> { + return new Promise((resolve) => { + const finish = (why: "change" | "tick") => () => { + clearTimeout(timer); + changes.removeEventListener("change", onChange); + signal.removeEventListener("abort", onTick); + resolve(why); + }; + const onChange = finish("change"); + const onTick = finish("tick"); + const timer = setTimeout(onTick, ms); + changes.addEventListener("change", onChange); + signal.addEventListener("abort", onTick); + }); +} diff --git a/extensions/authenticator/ui.ts b/extensions/authenticator/ui.ts new file mode 100644 index 0000000..11eb380 --- /dev/null +++ b/extensions/authenticator/ui.ts @@ -0,0 +1,472 @@ +// Forms and list rows shared by the commands. + +import { + type Action, + action, + clipboard, + close, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + open, + paste, + pop, + submenu, + toast, +} from "@backslash/api"; +import { + type Account, + accountFrom, + ALGORITHMS, + buildOtpauth, + capturePackages, + displayName, + groupCode, + installCommand, + installMarkdown, + parseExport, + parseLinks, + secondsLeft, + tintFor, + totp, +} from "./lib.ts"; +import { + addAccounts, + type AddResult, + expandHome, + exportFiles, + family, + type KeyringState, + type Loaded, + MissingTool, + notifyChanged, + remove, + rename, + scanClipboardImage, + scanScreen, + session, + tilde, + VaultError, +} from "./system.ts"; + +export interface Prefs { + passphrase?: string; + showNext?: boolean; +} + +/** "Added GitHub", "Added 4 accounts · 1 already there". */ +export function addedMessage(r: AddResult, skipped: string[] = []): string { + const n = r.added.length; + const what = n === 1 + ? `Added ${r.added[0].issuer || r.added[0].account}` + : n + ? `Added ${n} accounts` + : "Nothing new to add"; + return [ + what, + r.duplicates ? `${r.duplicates} already there` : "", + skipped.length ? `${skipped.length} skipped` : "", + ].filter(Boolean).join(" · "); +} + +async function save(accounts: Account[], prefs: Prefs, skipped: string[] = []): Promise { + try { + const r = await addAccounts(accounts, prefs.passphrase); + await toast(addedMessage(r, skipped), { + style: r.added.length ? "success" : "info", + message: skipped.length + ? `Skipped: ${skipped.slice(0, 4).join(", ")}${skipped.length > 4 ? "…" : ""}` + : undefined, + }); + return r; + } catch (e) { + if (e instanceof VaultError || e instanceof MissingTool) { + await toast(e.message, { style: "failure" }); + throw e; + } + throw e; + } +} + +/** After a form: back to the list, or close when the form is the command itself. */ +const done = (standalone: boolean, message: string) => (standalone ? close(message) : pop()); + +/** Issuer, account, secret, digits, period, algorithm. */ +export function manualForm(prefs: Prefs, standalone = false): FormSpec { + return form({ + title: "Add Account Manually", + submitTitle: "Add Account", + fields: [ + field.text("issuer", "Issuer", { placeholder: "GitHub, Google, Proton…" }), + field.text("account", "Account", { placeholder: "ada@example.com" }), + field.password("secret", "Secret key", { required: true, placeholder: "JBSW Y3DP EHPK 3PXP" }), + field.dropdown("digits", "Digits", [{ value: "6", title: "6 digits" }, { value: "7", title: "7 digits" }, { + value: "8", + title: "8 digits", + }], { value: "6" }), + field.text("period", "Period (seconds)", { value: "30", placeholder: "30" }), + field.dropdown("algorithm", "Algorithm", ALGORITHMS.map((a) => ({ value: a, title: a.replace("SHA", "SHA-") })), { + value: "SHA1", + }), + ], + async submit(values) { + let a: Account; + try { + a = accountFrom({ + issuer: String(values.issuer ?? ""), + account: String(values.account ?? ""), + secret: String(values.secret ?? ""), + digits: String(values.digits ?? "6"), + period: String(values.period ?? "30"), + algorithm: String(values.algorithm ?? "SHA1"), + }); + } catch (e) { + await toast((e as Error).message, { style: "failure" }); + return; + } + const r = await save([a], prefs).catch(() => undefined); + if (!r) return; + return done(standalone, addedMessage(r)); + }, + }); +} + +/** One or more otpauth:// (or otpauth-migration://) links. */ +export async function linkForm(prefs: Prefs, standalone = false): Promise { + let clip = ""; + try { + const text = (await clipboard.read()).trim(); + if (/^otpauth(-migration)?:\/\//i.test(text)) clip = text; + } catch { + // No clipboard access: an empty field. + } + return form({ + title: "Add from otpauth:// Link", + submitTitle: "Add", + fields: [ + field.textArea("links", "Links", { + required: true, + value: clip, + placeholder: "otpauth://totp/GitHub:ada?secret=…&issuer=GitHub (one per line)", + }), + ], + async submit(values) { + let parsed; + try { + parsed = parseLinks(String(values.links ?? "")); + } catch (e) { + await toast((e as Error).message, { style: "failure" }); + return; + } + if (!parsed.accounts.length) { + await toast("No valid link", { style: "failure", message: parsed.skipped.join(", ") }); + return; + } + const r = await save(parsed.accounts, prefs, parsed.skipped).catch(() => undefined); + if (!r) return; + return done(standalone, addedMessage(r, parsed.skipped)); + }, + }); +} + +/** An Aegis / 2FAS plain JSON export, or a text file of links. */ +export async function importForm(prefs: Prefs): Promise { + const files = await exportFiles(); + return form({ + title: "Import from Aegis or 2FAS", + submitTitle: "Import", + fields: [ + field.text("path", "Export file", { + required: true, + value: files[0] ? tilde(files[0].path) : "", + placeholder: "~/Downloads/aegis-export.json", + }), + field.checkbox("delete", "Delete the file after importing (it holds your secrets in clear)", { value: true }), + ], + async submit(values) { + const path = expandHome(String(values.path ?? "")); + let text: string; + try { + text = await Deno.readTextFile(path); + } catch (e) { + await toast( + e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied + ? "Put the export in ~/Downloads or ~/Documents" + : `Can't read ${tilde(path)}`, + { style: "failure" }, + ); + return; + } + let parsed; + try { + parsed = parseExport(text); + } catch (e) { + await toast((e as Error).message, { style: "failure" }); + return; + } + const r = await save(parsed.accounts, prefs, parsed.skipped).catch(() => undefined); + if (!r) return; + if (values.delete) await Deno.remove(path).catch(() => {}); + return pop(); + }, + }); +} + +function renameForm(e: Loaded): FormSpec { + return form({ + title: `Rename ${e.issuer || e.account}`, + submitTitle: "Save", + fields: [ + field.text("issuer", "Issuer", { value: e.issuer }), + field.text("account", "Account", { value: e.account }), + ], + async submit(values) { + const issuer = String(values.issuer ?? "").trim(); + const account = String(values.account ?? "").trim(); + if (!issuer && !account) { + await toast("Give the account an issuer or a name", { style: "failure" }); + return; + } + await rename(e.id, issuer, account); + return pop(); + }, + }); +} + +const scanScreenAction = (o: { section?: string } = {}) => + action("Scan QR Code on Screen", async () => { + try { + await scanScreen(); + } catch (e) { + if (!(e instanceof MissingTool)) throw e; + const fam = await family(); + const packages = e.packages.length ? e.packages : capturePackages(session()); + await toast(e.message, { + style: "failure", + message: `Install it: ${fam ? installCommand(packages, fam) : packages.join(", ")}`, + }); + return; + } + return close("Select the QR code…"); + }, { icon: Icons.scan, section: o.section }); + +const scanClipboardAction = (prefs: Prefs, o: { section?: string } = {}) => + action("Scan QR Code in Copied Picture", async () => { + let text: string | undefined; + try { + text = await scanClipboardImage(); + } catch (e) { + await toast((e as Error).message, { style: "failure" }); + return; + } + if (!text) { + await toast("No QR code found in the copied picture", { style: "failure" }); + return; + } + let parsed; + try { + parsed = parseLinks(text); + } catch { + await toast("Not a two-factor QR code", { style: "failure", message: "It holds no otpauth:// link" }); + return; + } + await save(parsed.accounts, prefs, parsed.skipped).catch(() => {}); + }, { icon: Icons.image, section: o.section }); + +/** Every way to add an account, for the action panel. */ +export function addActions(prefs: Prefs): Action[] { + return [ + action("Paste otpauth:// Link", () => linkForm(prefs), { icon: Icons.link }), + scanScreenAction(), + scanClipboardAction(prefs), + action("Enter Secret Key", () => manualForm(prefs), { icon: Icons.key }), + action("Import from Aegis or 2FAS", () => importForm(prefs), { icon: Icons.download }), + ]; +} + +/** Rows to add accounts, shown under the codes. */ +export function addItems(prefs: Prefs, ring: KeyringState): Item[] { + const where = ring === "ok" + ? "The secret goes to your system keyring." + : prefs.passphrase + ? "No keyring found: the secret is encrypted with your vault passphrase." + : ""; + const rows: [string, string, string, string, () => Action][] = [ + [ + "link", + "Paste otpauth:// Link", + Icons.link, + "The **otpauth://** link behind a QR code, from a password manager or a website's “can't scan?” option. Several links, one per line, work too, and so do Google Authenticator exports (**otpauth-migration://**).", + () => action("Paste Link", () => linkForm(prefs), { icon: Icons.link }), + ], + [ + "scan", + "Scan QR Code on Screen", + Icons.scan, + "Select the QR code the website shows. The launcher hides, you draw a rectangle around the code, and the account is added the next time you open Authenticator.", + () => scanScreenAction(), + ], + [ + "clipboard", + "Scan QR Code in Copied Picture", + Icons.image, + "Copy a screenshot of the QR code (most screenshot tools copy it), then run this.", + () => scanClipboardAction(prefs), + ], + [ + "manual", + "Enter Secret Key", + Icons.key, + "Type the setup key the website shows (letters A–Z and digits 2–7), with the issuer and account. Digits, period and algorithm are almost always 6, 30 s and SHA-1.", + () => action("Enter Secret Key", () => manualForm(prefs), { icon: Icons.key }), + ], + [ + "import", + "Import from Aegis or 2FAS", + Icons.download, + "A **plain** (unencrypted) JSON export of Aegis or 2FAS, or a text file of otpauth:// links, in ~/Downloads or ~/Documents. Delete the file once imported: it holds your secrets in clear.", + () => action("Import File", () => importForm(prefs), { icon: Icons.download }), + ], + ]; + return rows.map(([id, title, icon, about, act]) => ({ + id: `add:${id}`, + title, + icon: { source: icon, tint: "gray" }, + section: "Add Account", + keywords: ["add", "ajouter", "new", "nouveau"], + detail: { markdown: `${about}${where ? `\n\n${where}` : ""}` }, + actions: [act()], + })); +} + +/** What to do when secrets can't be stored anywhere yet. */ +export async function setupItem(ring: KeyringState, error?: string): Promise { + const fam = await family(); + const markdown = [ + "## Where your secrets go", + "", + "Authenticator keeps each secret in your **system keyring** (GNOME Keyring, KWallet, KeePassXC) through `secret-tool`, never in a plain file.", + "", + ring === "missing" + ? installMarkdown("Install `secret-tool`:", ["secret-tool"], fam) + : "The keyring isn't reachable right now: log in to a desktop session with a keyring, or unlock it.", + "", + "Without a keyring, set a **Vault passphrase** in **Settings → Extensions → Authenticator → Configure**: secrets are then encrypted (AES-256-GCM) with it before being stored.", + ].join("\n"); + return { + id: "setup", + title: error ?? (ring === "missing" ? "Install secret-tool to store secrets" : "No keyring available"), + subtitle: "or set a vault passphrase", + icon: { source: Icons.lock, tint: "orange" }, + section: "Setup", + detail: { markdown }, + actions: [action("Check Again", () => notifyChanged(), { icon: Icons.refresh })], + }; +} + +export interface Codes { + now: string; + next: string; + left: number; +} + +export async function codesFor(e: Loaded, seconds: number): Promise { + if (!e.key) return undefined; + const [now, next] = await Promise.all([totp(e.key, seconds, e), totp(e.key, seconds + e.period, e)]); + return { now, next, left: secondsLeft(seconds, e.period) }; +} + +export function codeItem(e: Loaded, c: Codes | undefined, prefs: Prefs): Item { + const { title, subtitle } = displayName(e); + const tint = tintFor(e.issuer || e.account); + const danger = [ + action("Rename", () => renameForm(e), { icon: Icons.text, section: "Account", shortcut: "r" }), + ...(e.secret + ? [ + copy( + buildOtpauth({ ...e, secret: e.secret }), + "Copy otpauth:// Link", + { icon: Icons.link, concealed: true, section: "Account" }, + ), + copy(e.secret, "Copy Secret Key", { icon: Icons.key, concealed: true, section: "Account" }), + ] + : []), + action("Delete Account", async () => { + await remove(e.id); + await toast(`Deleted ${title}`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${title}? You won't be able to sign in with it without another 2FA method`, + shortcut: "Delete", + section: "Danger", + }), + ]; + if (!c) { + return { + id: e.id, + title, + subtitle, + icon: { source: Icons.alert, tint: "orange" }, + section: "Accounts", + accessories: [{ text: "Locked", icon: Icons.lock, color: "orange" }], + detail: { + markdown: `**${e.error ?? "The secret can't be read"}**`, + metadata: [meta.text("Stored in", e.store === "keyring" ? "System keyring" : "Vault (passphrase)", { + icon: Icons.lock, + })], + }, + actions: [action("Try Again", () => notifyChanged(), { icon: Icons.refresh }), ...danger], + }; + } + const urgent = c.left <= 5; + const code = groupCode(c.now); + const next = groupCode(c.next); + return { + id: e.id, + title, + subtitle, + icon: { source: Icons.shield, tint }, + section: "Accounts", + keywords: [e.account, e.issuer, "code"].filter(Boolean), + accessories: [ + { tag: code, color: urgent ? ("red" as const) : tint }, + ...(prefs.showNext !== false && urgent ? [{ text: `next ${next}`, color: "gray" as const }] : []), + { text: `${c.left}s`, icon: Icons.timer, color: urgent ? ("red" as const) : ("gray" as const) }, + ], + detail: { + stats: [ + { label: "code", value: code, color: urgent ? "red" : tint }, + ...(prefs.showNext !== false ? [{ label: "next", value: next }] : []), + { label: "seconds left", value: c.left, color: urgent ? "red" : undefined }, + ], + metadata: [ + meta.progress("Valid for", c.left / e.period, `${c.left} s of ${e.period}`), + meta.separator(), + ...(e.issuer ? [meta.text("Issuer", e.issuer, { icon: Icons.globe })] : []), + ...(e.account ? [meta.text("Account", e.account, { icon: Icons.person })] : []), + meta.tags("Parameters", [ + `${e.digits} digits`, + `${e.period} s`, + { text: e.algorithm.replace("SHA", "SHA-"), color: e.algorithm === "SHA1" ? undefined : "purple" }, + ]), + meta.text("Stored in", e.store === "keyring" ? "System keyring" : "Vault (encrypted)", { icon: Icons.lock }), + meta.text("Added", new Date(e.added).toLocaleDateString([], { dateStyle: "medium" }), { + icon: Icons.calendar, + }), + ], + }, + actions: [ + copy(c.now, "Copy Code", { concealed: true }), + paste(c.now, "Paste Code", { concealed: true }), + copy(c.next, "Copy Next Code", { concealed: true, shortcut: "n", icon: Icons.next, section: "Copy" }), + ...danger, + submenu("Add Account", addActions(prefs), { icon: Icons.plus, shortcut: "n", section: "Add" }), + open("https://2fa.directory/", "Find Sites Supporting 2FA", { icon: Icons.external, section: "Add" }), + ], + }; +} diff --git a/extensions/bitbucket/api.ts b/extensions/bitbucket/api.ts new file mode 100644 index 0000000..2158a46 --- /dev/null +++ b/extensions/bitbucket/api.ts @@ -0,0 +1,160 @@ +// Bitbucket Cloud REST API 2.0 with an Atlassian API token (or a legacy app password). + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { basicAuth, type Repo } from "./lib.ts"; + +export const API = "https://api.bitbucket.org/2.0"; +export const TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"; +export const DOCS_URL = "https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/"; + +export class AuthError extends Error {} + +export interface Auth { + header: string; +} + +export function auth(preferences: Record): Auth { + const user = String(preferences.email ?? "").trim(); + const secret = String(preferences.token ?? "").trim(); + if (!user || !secret) throw new AuthError("Add your Atlassian email and an API token"); + return { header: basicAuth(user, secret) }; +} + +/** A path under /2.0, or a full `next` URL from a paginated answer. */ +export async function bitbucket(a: Auth, path: string, signal?: AbortSignal): Promise { + const res = await fetch(path.startsWith("https://") ? path : `${API}${path}`, { + signal, + headers: { Authorization: a.header, Accept: "application/json" }, + }); + if (res.status === 401) throw new AuthError("Bitbucket rejected the email or API token"); + if (res.status === 403) { + const body = await res.json().catch(() => ({})) as { error?: { message?: string; detail?: string } }; + const why = body.error?.detail || body.error?.message || ""; + if (/scope|privilege|permission/i.test(why)) throw new AuthError(`The API token lacks a scope: ${why}`); + throw new Error(`Bitbucket refused the request${why ? `: ${why}` : ""}`); + } + if (res.status === 429) throw new Error("Bitbucket is rate limiting: try again in a minute"); + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: { message?: string } }; + throw new Error(`Bitbucket answered ${res.status}${body.error?.message ? `: ${body.error.message}` : ""}`); + } + return await res.json() as T; +} + +export interface Page { + values: T[]; + next?: string; + size?: number; +} + +/** Up to `max` values, following `next`. */ +export async function all(a: Auth, path: string, max: number, signal?: AbortSignal): Promise { + const out: T[] = []; + let next: string | undefined = path; + while (next && out.length < max) { + const page: Page = await bitbucket>(a, next, signal); + out.push(...page.values); + next = page.next; + } + return out.slice(0, max); +} + +export interface Me { + uuid: string; + account_id?: string; + display_name: string; + nickname?: string; + links?: { avatar?: { href: string } }; +} + +export async function me(a: Auth, signal?: AbortSignal): Promise { + return await bitbucket(a, "/user", signal); +} + +/** Workspace slugs: from the preference, else every workspace you're a member of. */ +export async function workspaces(a: Auth, preference: unknown, signal?: AbortSignal): Promise { + const chosen = String(preference ?? "").split(/[\s,]+/).map((s) => s.trim().toLowerCase()).filter(Boolean); + if (chosen.length) return chosen; + const cached = await storage.get<{ at: number; slugs: string[] }>("workspaces").catch(() => undefined); + if (cached && Date.now() - cached.at < 6 * 3_600_000 && cached.slugs.length) return cached.slugs; + let slugs: string[]; + try { + const access = await all<{ workspace: { slug: string } }>(a, "/user/workspaces?pagelen=100", 100, signal); + slugs = access.map((w) => w.workspace.slug); + } catch (e) { + if (e instanceof AuthError) throw e; + // Older endpoint, being retired by Atlassian. + const list = await all<{ slug: string }>(a, "/workspaces?role=member&pagelen=100", 100, signal); + slugs = list.map((w) => w.slug); + } + await storage.set("workspaces", { at: Date.now(), slugs }).catch(() => {}); + return slugs; +} + +/** The most recently updated repositories of the workspaces, `max` each. */ +export async function recentRepos(a: Auth, slugs: string[], max: number, signal?: AbortSignal): Promise { + const lists = await Promise.all( + slugs.map((ws) => + all( + a, + `/repositories/${encodeURIComponent(ws)}?sort=-updated_on&pagelen=${Math.min(max, 100)}`, + max, + signal, + ) + .catch((e) => { + if (e instanceof AuthError) throw e; + return [] as Repo[]; + }) + ), + ); + return lists.flat(); +} + +/** The row shown instead of results until the credentials work. */ +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Bitbucket: Atlassian API token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Bitbucket", + "", + "1. Open **Atlassian account → Security → API tokens** (Enter opens it).", + "2. Choose **Create API token with scopes**, pick **Bitbucket**, and these scopes:", + " - `read:user:bitbucket`, `read:workspace:bitbucket`", + " - `read:repository:bitbucket`, `read:pullrequest:bitbucket`", + " - `read:issue:bitbucket` (for issues)", + "3. In **Settings → Extensions → Bitbucket → Configure**, enter your **Atlassian account email** and paste the token.", + "", + "An older **app password** still works while Atlassian accepts it: enter your Bitbucket **username** instead of the email.", + ].join("\n"), + }, + actions: [ + open(TOKEN_URL, "Create API Token", { icon: Icons.key }), + open(DOCS_URL, "About API Tokens", { icon: Icons.book }), + ], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} + +export async function* guardedSteps(load: () => AsyncGenerator): AsyncGenerator { + 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/bitbucket/backslash.toml b/extensions/bitbucket/backslash.toml new file mode 100644 index 0000000..5e89bb5 --- /dev/null +++ b/extensions/bitbucket/backslash.toml @@ -0,0 +1,57 @@ +[extension] +id = "bitbucket" +title = "Bitbucket" +description = "Bitbucket Cloud pull requests to review or opened by you, with builds and approvals, your issues and repository search" +version = "1.0.0" +author = "Backslash" +icon = "bs-repo" +license = "MIT" + +# The REST API, and where Bitbucket serves avatars from (Gravatar, falling back to +# Atlassian's initials pictures). +[permissions] +net = [ + "api.bitbucket.org", + "bitbucket.org", + "secure.gravatar.com", + "avatar-management--avatars.us-west-2.prod.public.atl-paas.net", +] + +[[commands]] +name = "pull-requests" +title = "My Bitbucket Pull Requests" +description = "To review or opened by you: builds, approvals, branches" +icon = "bs-pr-open" +keywords = ["bitbucket", "pr", "pull request", "review", "atlassian", "builds", "pipelines", "relecture", "demande de fusion"] + +[[commands]] +name = "issues" +title = "My Bitbucket Issues" +description = "Open issues assigned to you or reported by you, in repositories with an issue tracker" +icon = "bs-issue-open" +keywords = ["bitbucket", "issues", "bugs", "tickets", "tasks", "problème", "anomalie"] + +[[commands]] +name = "repositories" +title = "Search Bitbucket Repositories" +description = "Recent repositories of your workspaces, or search them as you type" +icon = "bs-repo" +keywords = ["bitbucket", "repo", "git", "code", "clone", "dépôt"] + +[[preferences]] +name = "email" +title = "Atlassian account email" +description = "The email you sign in to Bitbucket with. With a legacy app password, your Bitbucket username instead" +required = true + +[[preferences]] +name = "token" +title = "API token" +description = "id.atlassian.com → Security → Create API token with scopes → Bitbucket: read scopes for user, workspace, repository, pull request and issue" +type = "secret" +required = true + +[[preferences]] +name = "workspaces" +title = "Workspaces" +description = "Optional, comma-separated workspace slugs (bitbucket.org/). Empty: all the workspaces you belong to" diff --git a/extensions/bitbucket/commands/issues.ts b/extensions/bitbucket/commands/issues.ts new file mode 100644 index 0000000..7fa33a2 --- /dev/null +++ b/extensions/bitbucket/commands/issues.ts @@ -0,0 +1,65 @@ +import { Icons, type Item, list } from "@backslash/api"; +import { all, auth, countStatus, guarded, me, recentRepos, workspaces } from "../api.ts"; +import { type Issue, issueItem, q } from "../lib.ts"; + +const OPEN = '(state="new" OR state="open" OR state="on hold")'; + +let trackers = 0; + +export default list({ + placeholder: "Filter issues by title, repository, kind, priority…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "reported", title: "Reported by me" }, + ], + }, + empty: { + title: "No open issues", + description: "Only repositories with Bitbucket's issue tracker turned on are searched.", + icon: Icons.issue, + }, + status: (items) => + items[0]?.id === "setup" || items[0]?.id === "no-tracker" + ? (items[0].id === "setup" ? "Not connected" : "No issue tracker") + : `${countStatus(items, "issue")} · ${trackers} tracker${trackers === 1 ? "" : "s"}`, + items: ({ preferences, signal, filter }) => + guarded(async (): Promise => { + const a = auth(preferences); + const [user, slugs] = await Promise.all([me(a, signal), workspaces(a, preferences.workspaces, signal)]); + const repos = (await recentRepos(a, slugs, 50, signal)).filter((r) => r.has_issues); + trackers = repos.length; + if (!repos.length) { + return [{ + id: "no-tracker", + title: "None of your recent repositories has an issue tracker", + subtitle: "Repository settings → Issue tracker", + icon: { source: Icons.info, tint: "blue" }, + detail: { + markdown: + "Bitbucket's own issue tracker is off by default; most teams use **Jira** instead.\n\nTo use it, open a repository's **Repository settings → Features → Issue tracker**.", + }, + actions: [], + }]; + } + const who = filter === "reported" ? q("reporter.uuid", user.uuid) : q("assignee.uuid", user.uuid); + const filterQ = encodeURIComponent(`${who} AND ${OPEN}`); + const lists = await Promise.all(repos.map((r) => + all(a, `/repositories/${r.full_name}/issues?q=${filterQ}&sort=-updated_on&pagelen=50`, 50, signal) + .catch(() => [] as Issue[]) + )); + const issues = lists.flat(); + // Repositories in order of their latest issue, issues newest first. + const latest = new Map(); + for (const i of issues) { + const r = i.repository.full_name; + if ((latest.get(r) ?? "") < i.updated_on) { + latest.set(r, i.updated_on); + } + } + return issues.sort((x, y) => + latest.get(y.repository.full_name)!.localeCompare(latest.get(x.repository.full_name)!) || + x.repository.full_name.localeCompare(y.repository.full_name) || y.updated_on.localeCompare(x.updated_on) + ).map(issueItem); + }), +}); diff --git a/extensions/bitbucket/commands/pull-requests.ts b/extensions/bitbucket/commands/pull-requests.ts new file mode 100644 index 0000000..98e6b2d --- /dev/null +++ b/extensions/bitbucket/commands/pull-requests.ts @@ -0,0 +1,62 @@ +import { list, updateCommandMetadata } from "@backslash/api"; +import { all, auth, bitbucket, countStatus, guardedSteps, me, recentRepos, workspaces } from "../api.ts"; +import { type BuildStatus, prItem, type Pull, type PullRequest, q, uniquePulls } from "../lib.ts"; + +/** Reviewers, approvals and builds take two calls per pull request: only the first ones. */ +const ENRICH = 25; +/** Pull requests to review are looked up in the most recently updated repositories. */ +const REPOS = 25; + +async function enrich(a: ReturnType, pr: PullRequest, signal: AbortSignal): Promise { + const base = `/repositories/${pr.destination.repository.full_name}/pullrequests/${pr.id}`; + const [full, statuses] = await Promise.all([ + bitbucket(a, base, signal).catch(() => pr), + all(a, `${base}/statuses?pagelen=25`, 25, signal).catch(() => undefined), + ]); + return { ...pr, ...full, statuses }; +} + +export default list({ + placeholder: "Filter pull requests by title, repository, author, branch…", + filter: { + options: [ + { value: "review", title: "To review" }, + { value: "mine", title: "Opened by 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 a = auth(preferences); + const [user, slugs] = await Promise.all([me(a, signal), workspaces(a, preferences.workspaces, signal)]); + const key = filter === "mine" ? "mine" : "review"; + let found: PullRequest[]; + if (key === "mine") { + const lists = await Promise.all(slugs.map((ws) => + all( + a, + `/workspaces/${encodeURIComponent(ws)}/pullrequests/${encodeURIComponent(user.uuid)}?state=OPEN&pagelen=50`, + 100, + signal, + ).catch(() => [] as PullRequest[]) + )); + found = lists.flat(); + } else { + const repos = await recentRepos(a, slugs, REPOS, signal); + const filter = encodeURIComponent(`${q("reviewers.uuid", user.uuid)} AND state="OPEN"`); + const lists = await Promise.all(repos.map((r) => + all(a, `/repositories/${r.full_name}/pullrequests?q=${filter}&pagelen=50`, 50, signal) + .catch(() => [] as PullRequest[]) + )); + found = lists.flat(); + updateCommandMetadata({ subtitle: found.length ? `${found.length} to review` : null }).catch(() => {}); + } + const prs = uniquePulls(found); + const items = (list: Pull[]) => + list.map((pr) => prItem(pr, pr.destination.repository.full_name)); + yield items(prs); + const enriched = await Promise.all(prs.map((pr, i) => (i < ENRICH ? enrich(a, pr, signal) : pr))); + yield items(enriched); + }), +}); diff --git a/extensions/bitbucket/commands/repositories.ts b/extensions/bitbucket/commands/repositories.ts new file mode 100644 index 0000000..46c4d26 --- /dev/null +++ b/extensions/bitbucket/commands/repositories.ts @@ -0,0 +1,34 @@ +import { type Item, list } from "@backslash/api"; +import { all, auth, guarded, recentRepos, workspaces } from "../api.ts"; +import { q, type Repo, repoItem } from "../lib.ts"; + +export default list({ + placeholder: "Search repositories of your workspaces…", + empty: { title: "No repository found", icon: "bs-repo" }, + status: (items: Item[]) => + items[0]?.id === "setup" ? "Not connected" : `${items.length} repositor${items.length === 1 ? "y" : "ies"}`, + search: (query, { preferences, signal }) => + guarded(async () => { + const a = auth(preferences); + const slugs = await workspaces(a, preferences.workspaces, signal); + const text = query.trim(); + let repos: Repo[]; + if (!text) { + repos = await recentRepos(a, slugs, 30, signal); + } else { + const filter = encodeURIComponent( + `${q("name", text).replace("=", "~")} OR ${q("description", text).replace("=", "~")}`, + ); + const lists = await Promise.all(slugs.map((ws) => + all(a, `/repositories/${encodeURIComponent(ws)}?q=${filter}&sort=-updated_on&pagelen=30`, 30, signal) + .catch(() => [] as Repo[]) + )); + repos = lists.flat(); + } + const multi = slugs.length > 1; + return repos.sort((x, y) => + y.updated_on.localeCompare(x.updated_on) + ) + .map((r) => repoItem(r, multi ? (r.workspace?.name ?? r.full_name.split("/")[0]) : undefined)); + }), +}); diff --git a/extensions/bitbucket/lib.ts b/extensions/bitbucket/lib.ts new file mode 100644 index 0000000..9731c46 --- /dev/null +++ b/extensions/bitbucket/lib.ts @@ -0,0 +1,436 @@ +// Bitbucket 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 Account { + uuid?: string; + account_id?: string; + display_name: string; + nickname?: string; + links?: { avatar?: { href: string } }; +} + +export interface Participant { + user: Account; + role: "REVIEWER" | "PARTICIPANT"; + approved: boolean; + state?: "approved" | "changes_requested" | null; +} + +/** `GET /repositories/{workspace}/{repo}/pullrequests[/{id}]`. */ +export interface PullRequest { + id: number; + title: string; + description?: string; + state: "OPEN" | "MERGED" | "DECLINED" | "SUPERSEDED"; + draft?: boolean; + author: Account; + source: { branch: { name: string }; repository?: { full_name: string }; commit?: { hash: string } }; + destination: { branch: { name: string }; repository: { full_name: string } }; + comment_count?: number; + task_count?: number; + created_on: string; + updated_on: string; + links: { html: { href: string } }; + reviewers?: Account[]; + /** Only in the single pull request. */ + participants?: Participant[]; +} + +/** `GET …/pullrequests/{id}/statuses`. */ +export interface BuildStatus { + key: string; + name?: string; + state: "SUCCESSFUL" | "FAILED" | "INPROGRESS" | "STOPPED"; + url?: string; + updated_on?: string; +} + +export interface Repo { + uuid: string; + full_name: string; + name: string; + slug?: string; + description?: string; + is_private: boolean; + language?: string; + updated_on: string; + size?: number; + has_issues?: boolean; + mainbranch?: { name: string } | null; + links: { html: { href: string }; clone?: { name: string; href: string }[]; avatar?: { href: string } }; + workspace?: { slug: string; name?: string }; + project?: { key: string; name: string } | null; + parent?: { full_name: string } | null; +} + +export interface Issue { + id: number; + title: string; + state: string; + kind: "bug" | "enhancement" | "proposal" | "task"; + priority: "trivial" | "minor" | "major" | "critical" | "blocker"; + assignee?: Account | null; + reporter?: Account | null; + content?: { raw?: string }; + votes?: number; + created_on: string; + updated_on: string; + links: { html: { href: string } }; + repository: { full_name: string }; +} + +// --- Formatting ------------------------------------------------------------------- + +/** `Basic …` for `user:secret`, UTF-8 safe. */ +export function basicAuth(user: string, secret: string): string { + const bytes = new TextEncoder().encode(`${user}:${secret}`); + return `Basic ${btoa(String.fromCharCode(...bytes))}`; +} + +/** "today", "yesterday", "3d", "25 Oct", "Mar 2024". */ +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 const avatar = (a?: Account | null) => a?.links?.avatar?.href; + +/** `x = "a"` for Bitbucket's `q` filter, quotes escaped. */ +export const q = (field: string, value: string) => `${field}="${value.replace(/["\\]/g, "\\$&")}"`; + +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; +} + +// --- Pull requests ---------------------------------------------------------------- + +export interface Pull extends PullRequest { + statuses?: BuildStatus[]; +} + +export function prIcon(pr: PullRequest): { source: string; tint: Color } { + if (pr.state === "MERGED") return { source: Icons.merged, tint: "purple" }; + if (pr.state === "DECLINED" || pr.state === "SUPERSEDED") return { source: Icons.pullRequestClosed, tint: "red" }; + if (pr.draft) return { source: Icons.pullRequestDraft, tint: "gray" }; + return { source: Icons.pullRequest, tint: "green" }; +} + +/** All builds of the source commit, as one: failed beats running beats passed. */ +export function builds(statuses: BuildStatus[] | undefined): { icon: string; color: Color; text: string } | undefined { + if (!statuses?.length) return undefined; + const count = (s: BuildStatus["state"]) => statuses.filter((b) => b.state === s).length; + const n = statuses.length; + if (count("FAILED")) return { icon: Icons.cross, color: "red", text: `${count("FAILED")} of ${n} builds failed` }; + if (count("INPROGRESS")) return { icon: Icons.dot, color: "yellow", text: `${count("INPROGRESS")} of ${n} running` }; + if (count("STOPPED") === n) return { icon: Icons.stop, color: "gray", text: "Builds stopped" }; + return { icon: Icons.check, color: "green", text: n === 1 ? "Build passed" : `${n} builds passed` }; +} + +/** Approvals and change requests of the reviewers (and participants who approved). */ +export function reviews(pr: PullRequest) { + const people = pr.participants ?? []; + const approved = people.filter((p) => p.approved || p.state === "approved"); + const changes = people.filter((p) => p.state === "changes_requested"); + const reviewers = people.filter((p) => p.role === "REVIEWER"); + const total = Math.max(reviewers.length, pr.reviewers?.length ?? 0); + const reviewersApproved = reviewers.filter((p) => p.approved || p.state === "approved").length; + return { approved, changes, total, reviewersApproved }; +} + +const stateText = (pr: PullRequest) => + pr.state === "OPEN" ? (pr.draft ? "Draft" : "Open") : pr.state[0] + pr.state.slice(1).toLowerCase(); + +export function prMetadata(pr: Pull): Metadata[] { + const s = prIcon(pr); + const b = builds(pr.statuses); + const r = reviews(pr); + const lines: Metadata[] = [ + meta.text("Status", stateText(pr), { icon: s.source, color: s.tint }), + meta.person("Author", pr.author.display_name, avatar(pr.author)), + meta.text("Branch", `${pr.source.branch.name} → ${pr.destination.branch.name}`, { icon: Icons.branch }), + ]; + if (b) lines.push(meta.text("Builds", b.text, { icon: b.icon, color: b.color })); + for (const st of pr.statuses ?? []) { + const one = builds([st])!; + lines.push(meta.text(` ${st.name || st.key}`, st.state[0] + st.state.slice(1).toLowerCase(), { + icon: one.icon, + color: one.color, + })); + } + if (pr.participants) { + lines.push(meta.text("Approvals", String(r.approved.length), { + icon: Icons.check, + color: r.approved.length ? "green" : undefined, + })); + if (r.total) { + lines.push( + meta.progress("Reviewers", r.reviewersApproved / r.total, `${r.reviewersApproved} of ${r.total} approved`), + ); + } + if (r.approved.length) lines.push(meta.tags("Approved by", r.approved.map((p) => p.user.display_name))); + if (r.changes.length) { + lines.push(meta.tags("Changes requested", r.changes.map((p) => ({ text: p.user.display_name, color: "red" })))); + } + } + const waiting = (pr.reviewers ?? []).filter((rv) => !r.approved.some((p) => p.user.uuid === rv.uuid)); + if (waiting.length) lines.push(meta.tags("Waiting on", waiting.map((w) => w.display_name))); + lines.push(meta.separator()); + lines.push(meta.text("Repository", pr.destination.repository.full_name, { icon: Icons.repository })); + if (pr.comment_count) lines.push(meta.text("Comments", pr.comment_count, { icon: Icons.comment })); + if (pr.task_count) lines.push(meta.text("Open tasks", pr.task_count, { icon: Icons.squareCheck })); + lines.push(meta.text("Opened", ago(pr.created_on))); + lines.push(meta.text("Updated", ago(pr.updated_on))); + return lines; +} + +export function prActions(pr: Pull, page = false): Action[] { + const url = pr.links.html.href; + const repo = pr.destination.repository.full_name; + return [ + open(url, "Open in Browser"), + ...(page ? [] : [action("Show Details", () => prPage(pr), { icon: Icons.info })]), + open(`${url}/diff`, "Open Diff", { icon: Icons.code, shortcut: "f" }), + open(`https://bitbucket.org/${repo}/pull-requests/`, "Open Repository Pull Requests", { + icon: Icons.repository, + }), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(pr.source.branch.name, "Copy Branch Name", { icon: Icons.branch, section: "Copy" }), + copy( + `git fetch origin ${pr.source.branch.name} && git switch ${pr.source.branch.name}`, + "Copy Checkout Command", + { icon: Icons.terminal, section: "Copy" }, + ), + copy(`[${repo}#${pr.id}: ${pr.title}](${url})`, "Copy as Markdown Link", { section: "Copy" }), + ]; +} + +export function prPage(pr: Pull): DetailSpec { + return detail({ + title: `#${pr.id}`, + markdown: `# ${pr.title}\n\n${pr.description?.trim() || "_No description._"}`, + metadata: prMetadata(pr), + status: `${pr.destination.repository.full_name} #${pr.id}`, + actions: prActions(pr, true), + }); +} + +export function prItem(pr: Pull, section?: string): Item { + const b = builds(pr.statuses); + const r = reviews(pr); + const repo = pr.destination.repository.full_name; + return { + id: `${repo}#${pr.id}`, + title: pr.title, + subtitle: section === repo ? `#${pr.id}` : `${repo.split("/")[1]} #${pr.id}`, + icon: prIcon(pr), + section, + keywords: [repo, String(pr.id), pr.author.display_name, pr.source.branch.name], + accessories: [ + ...(b ? [{ icon: { source: b.icon, tint: b.color }, tooltip: b.text }] : []), + ...(r.changes.length ? [{ tag: "Changes requested", color: "red" as Color }] : []), + ...(pr.participants && r.approved.length + ? [{ + icon: { source: Icons.check, tint: "green" as Color }, + text: String(r.approved.length), + tooltip: `Approved by ${r.approved.map((p) => p.user.display_name).join(", ")}`, + }] + : []), + ...(pr.comment_count ? [{ icon: Icons.comment, text: String(pr.comment_count), tooltip: "Comments" }] : []), + { avatar: avatar(pr.author) ?? pr.author.display_name, tooltip: pr.author.display_name }, + { text: ago(pr.updated_on), tooltip: "Updated" }, + ], + detail: { markdown: excerpt(pr.description), metadata: prMetadata(pr) }, + actions: prActions(pr), + }; +} + +/** Most recently updated first, without the same pull request twice. */ +export function uniquePulls(prs: PullRequest[]): PullRequest[] { + const seen = new Map(); + for (const pr of prs) seen.set(`${pr.destination.repository.full_name}#${pr.id}`, pr); + return [...seen.values()].sort((a, b) => b.updated_on.localeCompare(a.updated_on)); +} + +// --- Repositories ----------------------------------------------------------------- + +const LANGUAGES: Record = { + java: "orange", + javascript: "yellow", + typescript: "blue", + python: "blue", + go: "teal", + rust: "orange", + php: "purple", + ruby: "red", + kotlin: "purple", + "c#": "green", + "c++": "pink", + c: "gray", + swift: "orange", + scala: "red", + shell: "green", + html: "red", +}; + +export const languageColor = (l?: string): Color => LANGUAGES[(l ?? "").toLowerCase()] ?? "gray"; + +const title = (s: string) => s ? s[0].toUpperCase() + s.slice(1) : s; + +export function size(bytes?: number): string | undefined { + if (!bytes) return undefined; + if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} KB`; + if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; + return `${(bytes / 1_000_000_000).toFixed(1)} GB`; +} + +export function repoItem(r: Repo, section?: string): Item { + const clone = (name: string) => r.links.clone?.find((c) => c.name === name)?.href; + const ssh = clone("ssh") ?? `git@bitbucket.org:${r.full_name}.git`; + const https = (clone("https") ?? `https://bitbucket.org/${r.full_name}.git`).replace( + /^https:\/\/[^@/]+@/, + "https://", + ); + const url = r.links.html.href; + return { + id: r.uuid, + title: r.full_name, + subtitle: r.description || undefined, + icon: { source: r.is_private ? Icons.lock : Icons.repository, tint: languageColor(r.language) }, + section, + keywords: [r.language ?? "", r.project?.name ?? "", r.project?.key ?? ""], + accessories: [ + ...(r.project ? [{ tag: r.project.key, tooltip: r.project.name }] : []), + ...(r.language + ? [{ icon: { source: Icons.dot, tint: languageColor(r.language) }, text: title(r.language) }] + : []), + { text: ago(r.updated_on), tooltip: "Updated" }, + ], + detail: { + markdown: r.description || undefined, + metadata: [ + meta.text("Workspace", r.workspace?.name ?? r.full_name.split("/")[0], { icon: Icons.apps }), + ...(r.project ? [meta.text("Project", `${r.project.name} (${r.project.key})`, { icon: Icons.project })] : []), + ...(r.language + ? [meta.text("Language", title(r.language), { icon: Icons.dot, color: languageColor(r.language) })] + : []), + ...(r.mainbranch ? [meta.text("Main branch", r.mainbranch.name, { icon: Icons.branch })] : []), + meta.text("Visibility", r.is_private ? "Private" : "Public", { + icon: r.is_private ? Icons.lock : Icons.globe, + color: r.is_private ? "orange" : "blue", + }), + ...(r.parent ? [meta.text("Fork of", r.parent.full_name, { icon: Icons.branch })] : []), + ...(size(r.size) ? [meta.text("Size", size(r.size)!)] : []), + meta.separator(), + meta.text("Updated", ago(r.updated_on), { icon: Icons.clock }), + ], + }, + actions: [ + open(url, "Open in Browser"), + open(`${url}/pull-requests/`, "Open Pull Requests", { icon: Icons.pullRequest, shortcut: "p" }), + open(`${url}/pipelines`, "Open Pipelines", { icon: Icons.activity }), + open(`${url}/branches/`, "Open Branches", { icon: Icons.branch }), + ...(r.has_issues ? [open(`${url}/issues`, "Open Issues", { icon: Icons.issue, shortcut: "i" })] : []), + copy(`git clone ${ssh}`, "Copy git clone (SSH)", { + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(`git clone ${https}`, "Copy git clone (HTTPS)", { icon: Icons.terminal, section: "Copy" }), + copy(url, "Copy URL", { section: "Copy" }), + copy(r.full_name, "Copy Name", { section: "Copy" }), + ], + }; +} + +// --- Issues ----------------------------------------------------------------------- + +const PRIORITIES: Record = { + blocker: { icon: Icons.urgent, color: "red" }, + critical: { icon: Icons.high, color: "orange" }, + major: { icon: Icons.medium, color: "yellow" }, + minor: { icon: Icons.low, color: "blue" }, + trivial: { icon: Icons.noPriority, color: "gray" }, +}; + +const KINDS: Record = { + bug: { icon: Icons.bug, color: "red" }, + enhancement: { icon: Icons.sparkle, color: "blue" }, + proposal: { icon: Icons.lightbulb, color: "yellow" }, + task: { icon: Icons.squareCheck, color: "green" }, +}; + +export const OPEN_STATES = ["new", "open", "on hold"]; + +export function issueIcon(i: Issue): { source: string; tint: Color } { + if (!OPEN_STATES.includes(i.state)) return { source: Icons.issueClosed, tint: "purple" }; + const k = KINDS[i.kind] ?? KINDS.task; + return { source: k.icon, tint: k.color }; +} + +export function issueItem(i: Issue): Item { + const p = PRIORITIES[i.priority] ?? PRIORITIES.trivial; + const url = i.links.html.href; + const repo = i.repository.full_name; + const metadata: Metadata[] = [ + meta.text("Status", title(i.state), { icon: issueIcon(i).source, color: issueIcon(i).tint }), + meta.text("Kind", title(i.kind), { icon: (KINDS[i.kind] ?? KINDS.task).icon, color: KINDS[i.kind]?.color }), + meta.text("Priority", title(i.priority), { icon: p.icon, color: p.color }), + ...(i.assignee ? [meta.person("Assignee", i.assignee.display_name, avatar(i.assignee))] : []), + ...(i.reporter ? [meta.person("Reporter", i.reporter.display_name, avatar(i.reporter))] : []), + ...(i.votes ? [meta.text("Votes", i.votes, { icon: Icons.arrowUp })] : []), + meta.separator(), + meta.text("Repository", repo, { icon: Icons.repository }), + meta.text("Opened", ago(i.created_on)), + meta.text("Updated", ago(i.updated_on)), + ]; + return { + id: `${repo}#${i.id}`, + title: i.title, + subtitle: `#${i.id}`, + icon: issueIcon(i), + section: repo, + keywords: [repo, String(i.id), i.kind, i.priority, i.state], + accessories: [ + { icon: { source: p.icon, tint: p.color }, tooltip: `${title(i.priority)} priority` }, + ...(i.state !== "new" && i.state !== "open" ? [{ tag: title(i.state) }] : []), + ...(i.reporter + ? [{ avatar: avatar(i.reporter) ?? i.reporter.display_name, tooltip: i.reporter.display_name }] + : []), + { text: ago(i.updated_on), tooltip: "Updated" }, + ], + detail: { markdown: excerpt(i.content?.raw), metadata }, + actions: [ + open(url, "Open in Browser"), + action("Show Details", () => + detail({ + title: `#${i.id}`, + markdown: `# ${i.title}\n\n${i.content?.raw?.trim() || "_No description._"}`, + metadata, + status: `${repo} #${i.id}`, + actions: [open(url, "Open in Browser"), copy(url, "Copy URL")], + }), { icon: Icons.info }), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${repo}#${i.id}: ${i.title}](${url})`, "Copy as Markdown Link", { section: "Copy" }), + ], + }; +} diff --git a/extensions/bitbucket/lib_test.ts b/extensions/bitbucket/lib_test.ts new file mode 100644 index 0000000..800c608 --- /dev/null +++ b/extensions/bitbucket/lib_test.ts @@ -0,0 +1,176 @@ +import { assertEquals } from "@std/assert"; +import { + type Account, + basicAuth, + builds, + type Issue, + issueIcon, + issueItem, + prIcon, + prItem, + prMetadata, + type Pull, + q, + type Repo, + repoItem, + reviews, + uniquePulls, +} from "./lib.ts"; + +// Shaped like the Bitbucket Cloud 2.0 API. +const ada: Account = { + uuid: "{3f1b2c4d-0000-4000-8000-1a2b3c4d5e6f}", + account_id: "557058:ada", + display_name: "Ada Lovelace", + nickname: "ada", + links: { avatar: { href: "https://secure.gravatar.com/avatar/abc?d=https%3A%2F%2Favatar-management" } }, +}; +const bob: Account = { uuid: "{bob}", display_name: "Bob Martin", links: { avatar: { href: "https://x/bob" } } }; +const eve: Account = { uuid: "{eve}", display_name: "Eve Moneypenny" }; + +const pr: Pull = { + id: 42, + title: "Retry webhooks with backoff", + description: "Retries failed deliveries 5 times.", + state: "OPEN", + draft: false, + author: ada, + source: { branch: { name: "feature/retry" }, repository: { full_name: "acme/hooks" }, commit: { hash: "9f8e7d6" } }, + destination: { branch: { name: "main" }, repository: { full_name: "acme/hooks" } }, + comment_count: 3, + task_count: 1, + created_on: "2026-10-26T08:00:00.000000+00:00", + updated_on: new Date().toISOString(), + links: { html: { href: "https://bitbucket.org/acme/hooks/pull-requests/42" } }, + reviewers: [bob, eve], + participants: [ + { user: bob, role: "REVIEWER", approved: true, state: "approved" }, + { user: eve, role: "REVIEWER", approved: false, state: "changes_requested" }, + { user: ada, role: "PARTICIPANT", approved: false, state: null }, + ], + statuses: [ + { key: "build", name: "Pipeline #118", state: "SUCCESSFUL" }, + { key: "sonar", name: "SonarCloud", state: "FAILED" }, + ], +}; + +Deno.test("auth header and queries", () => { + assertEquals(basicAuth("ada@example.com", "ATATT3x"), `Basic ${btoa("ada@example.com:ATATT3x")}`); + assertEquals(basicAuth("zoé", "pä"), "Basic em/DqTpww6Q="); + assertEquals(q("reviewers.uuid", ada.uuid!), 'reviewers.uuid="{3f1b2c4d-0000-4000-8000-1a2b3c4d5e6f}"'); + assertEquals(q("name", 'a"b'), 'name="a\\"b"'); +}); + +Deno.test("builds and reviews", () => { + assertEquals(builds(pr.statuses)?.text, "1 of 2 builds failed"); + assertEquals(builds([{ key: "a", state: "INPROGRESS" }, { key: "b", state: "SUCCESSFUL" }])?.color, "yellow"); + assertEquals(builds([{ key: "a", state: "SUCCESSFUL" }])?.text, "Build passed"); + assertEquals(builds([]), undefined); + const r = reviews(pr); + assertEquals([r.approved.length, r.changes.length, r.total, r.reviewersApproved], [1, 1, 2, 1]); + assertEquals(prIcon(pr), { source: "bs-pr-open", tint: "green" }); + assertEquals(prIcon({ ...pr, state: "MERGED" }).source, "bs-pr-merged"); + assertEquals(prIcon({ ...pr, draft: true }).tint, "gray"); +}); + +Deno.test("pull request item and panel", () => { + const item = prItem(pr, "acme/hooks"); + assertEquals([item.id, item.subtitle], ["acme/hooks#42", "#42"]); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text ?? a.tooltip), [ + "1 of 2 builds failed", + "Changes requested", + "1", + "3", + "Ada Lovelace", + "today", + ]); + const lines = prMetadata(pr).map((m) => [m.label, m.value ?? m.tags?.map((t) => t.text).join(", ")]); + assertEquals(lines.slice(0, 9), [ + ["Status", "Open"], + ["Author", "Ada Lovelace"], + ["Branch", "feature/retry → main"], + ["Builds", "1 of 2 builds failed"], + [" Pipeline #118", "Successful"], + [" SonarCloud", "Failed"], + ["Approvals", "1"], + ["Reviewers", "1 of 2 approved"], + ["Approved by", "Bob Martin"], + ]); + assertEquals(lines.slice(9, 11), [["Changes requested", "Eve Moneypenny"], ["Waiting on", "Eve Moneypenny"]]); + // From the list, before the details load: no approvals line. + const bare = prItem({ ...pr, participants: undefined, statuses: undefined }, "other/repo"); + assertEquals(bare.subtitle, "hooks #42"); + assertEquals(bare.accessories?.length, 3); + assertEquals( + (bare.actions?.find((a) => a.title === "Copy Checkout Command") as { text: string }).text, + [ + "git fetch origin feature/retry && git switch feature/retry", + ][0], + ); +}); + +Deno.test("unique pull requests, newest first", () => { + const a = { ...pr, id: 1, updated_on: "2026-10-01T00:00:00Z" }; + const b = { ...pr, id: 2, updated_on: "2026-10-03T00:00:00Z" }; + assertEquals(uniquePulls([a, b, a]).map((p) => p.id), [2, 1]); +}); + +const repo: Repo = { + uuid: "{r1}", + full_name: "acme/hooks", + name: "hooks", + description: "Webhook delivery service", + is_private: true, + language: "go", + updated_on: "2026-10-20T10:00:00Z", + size: 4_200_000, + has_issues: true, + mainbranch: { name: "main" }, + links: { + html: { href: "https://bitbucket.org/acme/hooks" }, + clone: [ + { name: "https", href: "https://ada@bitbucket.org/acme/hooks.git" }, + { name: "ssh", href: "git@bitbucket.org:acme/hooks.git" }, + ], + }, + workspace: { slug: "acme", name: "Acme Corp" }, + project: { key: "PLAT", name: "Platform" }, +}; + +Deno.test("repository item", () => { + const item = repoItem(repo, "Acme Corp"); + assertEquals(item.icon, { source: "bs-lock", tint: "teal" }); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text), ["PLAT", "Go", item.accessories![2].text]); + const clone = (t: string) => (item.actions?.find((a) => a.title === t) as { text: string }).text; + assertEquals(clone("Copy git clone (SSH)"), "git clone git@bitbucket.org:acme/hooks.git"); + assertEquals(clone("Copy git clone (HTTPS)"), "git clone https://bitbucket.org/acme/hooks.git"); + assertEquals(item.actions?.some((a) => a.title === "Open Issues"), true); + assertEquals(item.detail?.metadata?.find((m) => m.label === "Size")?.value, "4.2 MB"); +}); + +Deno.test("issue item", () => { + const issue: Issue = { + id: 7, + title: "Retries ignore Retry-After", + state: "open", + kind: "bug", + priority: "critical", + assignee: ada, + reporter: bob, + content: { raw: "Seen on **staging**." }, + votes: 2, + created_on: "2026-10-20T10:00:00Z", + updated_on: "2026-10-27T10:00:00Z", + links: { html: { href: "https://bitbucket.org/acme/hooks/issues/7" } }, + repository: { full_name: "acme/hooks" }, + }; + const item = issueItem(issue); + assertEquals(item.icon, { source: "bs-bug", tint: "red" }); + assertEquals(item.accessories?.[0], { + icon: { source: "bs-priority-high", tint: "orange" }, + tooltip: "Critical priority", + }); + assertEquals(item.detail?.markdown, "Seen on **staging**."); + assertEquals(issueIcon({ ...issue, state: "resolved" }).source, "bs-issue-closed"); + assertEquals(issueItem({ ...issue, state: "on hold" }).accessories?.[1].tag, "On hold"); +}); diff --git a/extensions/can-i-use/backslash.toml b/extensions/can-i-use/backslash.toml new file mode 100644 index 0000000..1ce50e5 --- /dev/null +++ b/extensions/can-i-use/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "can-i-use" +title = "Can I Use" +description = "Browser support for web platform features from caniuse.com, with versions, partial support and notes" +version = "1.0.0" +author = "Backslash" +icon = "bs-globe" +license = "MIT" + +[permissions] +# caniuse's full data file, downloaded once a week from its GitHub repository. +net = ["raw.githubusercontent.com"] +# The trimmed copy is cached there. +read = ["~/.cache/backslash/can-i-use"] +write = ["~/.cache/backslash/can-i-use"] +env = ["HOME"] + +[[commands]] +name = "search" +title = "Can I Use" +description = "Global support, first supporting version per browser, prefixes, flags and notes" +icon = "bs-globe" +keywords = [ + "caniuse", + "can i use", + "browser", + "support", + "compatibility", + "css", + "html", + "javascript", + "web", + "feature", + "mdn", + "navigateur", + "compatibilité", + "prise en charge", +] diff --git a/extensions/can-i-use/commands/search.ts b/extensions/can-i-use/commands/search.ts new file mode 100644 index 0000000..2c5dd5c --- /dev/null +++ b/extensions/can-i-use/commands/search.ts @@ -0,0 +1,135 @@ +import { type Action, action, copy, Icons, type Item, list, meta, open, refresh } from "@backslash/api"; +import { download, readCache } from "../data.ts"; +import { + BROWSERS, + caniuseUrl, + type Dataset, + type Feature, + group, + isFresh, + markdown, + mdnLink, + percent, + shortSupport, + STATUS_SHORT, + supportTags, + usageColor, +} from "../lib.ts"; + +const host = (url: string) => url.replace(/^https?:\/\/(www\.)?/, "").split("/")[0]; + +function summary(f: Feature): string { + return BROWSERS.map((b) => { + const tags = supportTags(f.support[b.id]); + return `${b.name}: ${tags.filter((t) => !t.text.startsWith("note")).map((t) => t.text).join(", ")}`; + }).join("\n"); +} + +function featureItem(f: Feature, data: Dataset): Item { + const url = caniuseUrl(f.id); + const mdn = mdnLink(f); + const total = f.usageY + f.usageA; + const others = f.links.filter((l) => l !== mdn).slice(0, 6); + const actions: Action[] = [ + open(url, "Open on caniuse.com"), + copy(url, "Copy caniuse.com Link", { icon: Icons.link }), + ...(mdn ? [open(mdn.url, "Open MDN Docs", { icon: Icons.book, shortcut: "m" })] : []), + ...(f.spec ? [open(f.spec, "Open Specification", { icon: Icons.file, shortcut: "s" })] : []), + ...others.map((l) => open(l.url, l.title.length > 60 ? `${l.title.slice(0, 59)}…` : l.title, { section: "Links" })), + copy(f.title, "Copy Feature Name", { section: "Copy" }), + copy(summary(f), "Copy Browser Support", { section: "Copy", shortcut: "c" }), + copy(f.id, "Copy Feature ID", { section: "Copy" }), + ]; + return { + id: f.id, + title: f.title, + subtitle: f.categories.join(", "), + icon: { source: Icons.globe, tint: usageColor(total) }, + keywords: [f.id, ...f.keywords, ...f.categories], + accessories: [ + ...(["chrome", "firefox", "safari"] as const).map((id) => { + const s = shortSupport(f.support[id]); + return { text: s.text, color: s.color, tooltip: BROWSERS.find((b) => b.id === id)!.name }; + }), + { tag: percent(f.usageY), color: usageColor(f.usageY), tooltip: "Global support" }, + ], + detail: { + stats: [ + { label: "Global support", value: percent(f.usageY), color: usageColor(f.usageY) }, + ...(f.usageA ? [{ label: "Partial", value: percent(f.usageA), color: "yellow" as const }] : []), + { label: "Status", value: STATUS_SHORT[f.status] ?? f.status }, + ], + markdown: markdown(f), + metadata: [ + ...BROWSERS.map((b) => meta.tags(b.name, supportTags(f.support[b.id]))), + meta.separator(), + meta.text("Status", data.statuses[f.status] ?? f.status, { icon: Icons.flag }), + meta.tags("Categories", f.categories), + ...(f.spec ? [meta.link("Specification", host(f.spec), f.spec)] : []), + ...(mdn ? [meta.link("MDN", mdn.title.replace(/^MDN Web Docs - /, ""), mdn.url)] : []), + meta.link("Can I Use", `caniuse.com/${f.id}`, url), + ], + }, + actions, + }; +} + +let status = ""; +let force = false; + +const dateOf = (unixSeconds: number) => new Date(unixSeconds * 1000).toLocaleDateString("en", { dateStyle: "medium" }); + +export default list({ + placeholder: "Search web platform features: grid, :has, fetch, webp…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "css", title: "CSS" }, + { value: "html", title: "HTML" }, + { value: "js", title: "JavaScript" }, + { value: "other", title: "Other" }, + ], + }, + empty: { title: "No feature found", description: "Try another name, or search caniuse.com.", icon: Icons.globe }, + status: (items) => status.replace("{n}", String(items.length)), + actions: [ + action("Refresh Data", () => { + force = true; + return refresh(); + }, { icon: Icons.refresh, shortcut: "r" }), + open("https://caniuse.com", "Open caniuse.com", { icon: Icons.globe }), + ], + async *items({ filter, signal }) { + const shown = (data: Dataset) => { + status = `{n} features · caniuse data of ${dateOf(data.updated)}`; + return data.features.filter((f) => !filter || filter === "all" || group(f) === filter) + .map((f) => featureItem(f, data)); + }; + const cached = await readCache(); + if (cached) yield shown(cached); + if (isFresh(cached) && !force) return; + force = false; + // Without a cache, the launcher's spinner shows until the download is done. + try { + yield shown(await download(signal)); + } catch (e) { + if (signal.aborted) throw e; + const message = e instanceof Error ? e.message : String(e); + if (cached) { + status = `{n} features · couldn't refresh (${message}) · data of ${dateOf(cached.updated)}`; + return; + } + status = "The caniuse data can't be downloaded"; + yield [{ + id: "error", + title: "Can't download the caniuse data", + subtitle: message, + icon: { source: Icons.wifiOff, tint: "red" }, + actions: [ + action("Try Again", () => refresh(), { icon: Icons.refresh }), + open("https://caniuse.com", "Open caniuse.com"), + ], + }]; + } + }, +}); diff --git a/extensions/can-i-use/data.ts b/extensions/can-i-use/data.ts new file mode 100644 index 0000000..c51c54e --- /dev/null +++ b/extensions/can-i-use/data.ts @@ -0,0 +1,32 @@ +// Downloading caniuse's data once a week, trimmed, cached in ~/.cache/backslash/can-i-use. + +import { DATA_URL, type Dataset, trim } from "./lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +export const CACHE_DIR = `${HOME}/.cache/backslash/can-i-use`; +const CACHE_FILE = `${CACHE_DIR}/features.json`; + +export async function readCache(): Promise { + try { + const d = JSON.parse(await Deno.readTextFile(CACHE_FILE)) as Dataset; + return Array.isArray(d.features) ? d : undefined; + } catch { + return undefined; + } +} + +/** Downloads the full data (about 4.8 MB), keeps what we show (about 1 MB) and caches it. */ +export async function download(signal?: AbortSignal): Promise { + const res = await fetch(DATA_URL, { signal }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the caniuse data`); + const data = trim(await res.json()); + if (!data.features.length) throw new Error("The caniuse data has no features: try again later"); + try { + await Deno.mkdir(CACHE_DIR, { recursive: true }); + await Deno.writeTextFile(`${CACHE_FILE}.tmp`, JSON.stringify(data)); + await Deno.rename(`${CACHE_FILE}.tmp`, CACHE_FILE); + } catch (e) { + console.error("Can't write the caniuse cache:", e); + } + return data; +} diff --git a/extensions/can-i-use/lib.ts b/extensions/can-i-use/lib.ts new file mode 100644 index 0000000..9306e2c --- /dev/null +++ b/extensions/can-i-use/lib.ts @@ -0,0 +1,260 @@ +// Can I Use: trimming caniuse's full data to what the extension shows, and support summaries. +// Pure, tested in lib_test.ts. + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export const DATA_URL = "https://raw.githubusercontent.com/Fyrd/caniuse/main/fulldata-json/data-2.0.json"; +/** A week. */ +export const TTL_MS = 7 * 24 * 3600 * 1000; + +export const BROWSERS = [ + { id: "chrome", name: "Chrome" }, + { id: "edge", name: "Edge" }, + { id: "firefox", name: "Firefox" }, + { id: "safari", name: "Safari" }, + { id: "ios_saf", name: "iOS Safari" }, + { id: "samsung", name: "Samsung Internet" }, + { id: "and_chr", name: "Chrome Android" }, +] as const; + +export type BrowserId = typeof BROWSERS[number]["id"]; + +/** y = supported, a = partial, n = not, p = polyfill, u = unknown. */ +export type Level = "y" | "a" | "n" | "p" | "u"; + +export interface Support { + /** The current released version. */ + current: string; + level: Level; + /** Needs a vendor prefix (in the current version). */ + prefix: boolean; + /** Behind a flag (in the current version). */ + flag: boolean; + /** Note numbers on the current version. */ + notes: number[]; + /** First version of the unbroken full, unprefixed support up to now. */ + since?: string; + /** First version of the unbroken full support, prefixed or not, when earlier than `since`. */ + prefixedSince?: string; + /** First version of the unbroken partial-or-full support, when earlier than both. */ + partialSince?: string; + /** caniuse only tracks the current version (Chrome Android): "since" means nothing. */ + onlyCurrent?: boolean; +} + +export interface Feature { + id: string; + title: string; + description: string; + spec: string; + status: string; + categories: string[]; + keywords: string[]; + notes: string; + notesByNum: Record; + links: { url: string; title: string }[]; + /** Global usage share with full support, in %. */ + usageY: number; + /** With partial support, in %. */ + usageA: number; + support: Partial>; +} + +export interface Dataset { + /** When caniuse last updated the data (unix seconds). */ + updated: number; + /** When we downloaded it (ms). */ + fetched: number; + statuses: Record; + features: Feature[]; +} + +/** `"a x #1 #3"` → its parts. */ +export function parseCell(cell: string | undefined): { level: Level; prefix: boolean; flag: boolean; notes: number[] } { + const parts = (cell ?? "u").trim().split(/\s+/); + const level = (["y", "a", "n", "p", "u"].includes(parts[0]) ? parts[0] : "u") as Level; + return { + level, + prefix: parts.includes("x"), + flag: parts.includes("d"), + notes: parts.filter((p) => /^#\d+$/.test(p)).map((p) => Number(p.slice(1))), + }; +} + +interface AgentVersion { + version: string; + era: number; +} + +/** Support in one browser, from its released versions (oldest first) and the feature's stats. */ +export function summarize(versions: AgentVersion[], stats: Record | undefined): Support | undefined { + if (!stats) return undefined; + const released = versions.filter((v) => v.era <= 0 && v.version in stats); + if (!released.length) return undefined; + const cells = released.map((v) => ({ version: v.version, ...parseCell(stats[v.version]) })); + const now = cells[cells.length - 1]; + /** First version of the trailing run matching `ok`. */ + const runStart = (ok: (c: typeof cells[number]) => boolean) => { + let i = cells.length; + while (i > 0 && ok(cells[i - 1])) i--; + return i < cells.length ? cells[i].version : undefined; + }; + const since = runStart((c) => c.level === "y" && !c.prefix); + const prefixedSince = runStart((c) => c.level === "y"); + const partialSince = runStart((c) => c.level === "y" || c.level === "a"); + return { + current: now.version, + level: now.level, + prefix: now.prefix, + flag: now.flag, + notes: now.notes, + ...(since ? { since } : {}), + ...(prefixedSince && prefixedSince !== since ? { prefixedSince } : {}), + ...(partialSince && partialSince !== prefixedSince ? { partialSince } : {}), + ...(released.length === 1 ? { onlyCurrent: true } : {}), + }; +} + +/** caniuse descriptions and notes have some HTML: keep the markdown subset the launcher shows. */ +export function cleanText(s: string | undefined): string { + return (s ?? "") + .replace(/(.*?)<\/code>/gi, "`$1`") + .replace(/]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)") + .replace(//gi, "\n") + .replace(/<\/?(em|i)>/gi, "*") + .replace(/<\/?(strong|b)>/gi, "**") + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") + .trim(); +} + +/** Keeps only what the extension shows: about a sixth of the 4.8 MB download. */ +export function trim(raw: any, fetched = Date.now()): Dataset { + const agents = raw?.agents ?? {}; + const features: Feature[] = Object.entries(raw?.data ?? {}).map(([id, f]: [string, any]) => { + const support: Partial> = {}; + for (const b of BROWSERS) { + const s = summarize(agents[b.id]?.version_list ?? [], f.stats?.[b.id]); + if (s) support[b.id] = s; + } + return { + id, + title: cleanText(f.title), + description: cleanText(f.description), + spec: f.spec ?? "", + status: f.status ?? "other", + categories: f.categories ?? [], + keywords: String(f.keywords ?? "").split(",").map((k: string) => k.trim()).filter(Boolean), + notes: cleanText(f.notes), + notesByNum: Object.fromEntries( + Object.entries(f.notes_by_num ?? {}).map(([n, t]) => [n, cleanText(String(t))]), + ), + links: (f.links ?? []).map((l: any) => ({ url: String(l.url), title: cleanText(String(l.title ?? l.url)) })), + usageY: Number(f.usage_perc_y ?? 0), + usageA: Number(f.usage_perc_a ?? 0), + support, + }; + }).sort((a, b) => a.title.localeCompare(b.title, "en", { sensitivity: "base" })); + return { updated: Number(raw?.updated ?? 0), fetched, statuses: raw?.statuses ?? {}, features }; +} + +export const isFresh = (d: Dataset | undefined, now = Date.now()) => !!d && now - d.fetched < TTL_MS; + +// --------------------------------------------------------------------------- +// Presentation + +export interface Tag { + text: string; + color?: Tint; +} + +/** Colored tags for one browser: `121+`, `partial 105`, `prefix 9`, `flag`, `No`. */ +export function supportTags(s: Support | undefined): Tag[] { + if (!s) return [{ text: "No data", color: "gray" }]; + const tags: Tag[] = []; + const notes = s.notes.map((n) => ({ text: `note ${n}`, color: "gray" as const })); + switch (s.level) { + case "y": + if (s.onlyCurrent) tags.push({ text: s.prefix ? "Yes, prefixed" : "Yes", color: s.prefix ? "orange" : "green" }); + else if (s.since) tags.push({ text: `${s.since}+`, color: "green" }); + else if (s.prefixedSince) tags.push({ text: `${s.prefixedSince}+ prefixed`, color: "orange" }); + if (s.since && s.prefixedSince) tags.push({ text: `prefixed ${s.prefixedSince}`, color: "orange" }); + if (s.partialSince) tags.push({ text: `partial ${s.partialSince}`, color: "yellow" }); + break; + case "a": + tags.push({ text: s.onlyCurrent ? "Partial" : `Partial ${s.partialSince ?? s.current}+`, color: "yellow" }); + if (s.prefix) tags.push({ text: "prefixed", color: "orange" }); + break; + case "n": + tags.push({ text: "No", color: "red" }); + if (s.flag) tags.push({ text: "behind a flag", color: "purple" }); + break; + case "p": + tags.push({ text: "Polyfill", color: "purple" }); + break; + default: + tags.push({ text: "Unknown", color: "gray" }); + } + return [...tags, ...notes]; +} + +/** One word for an accessory: ✓ 121, ◐ 105, ✕. */ +export function shortSupport(s: Support | undefined): { text: string; color: Tint } { + if (!s) return { text: "?", color: "gray" }; + if (s.level === "y") { + return { + text: s.onlyCurrent ? "yes" : s.since ?? s.prefixedSince ?? s.current, + color: s.prefix ? "orange" : "green", + }; + } + if (s.level === "a") return { text: s.partialSince ?? s.current, color: "yellow" }; + if (s.level === "n") return { text: s.flag ? "flag" : "no", color: s.flag ? "purple" : "red" }; + return { text: s.level === "p" ? "polyfill" : "?", color: "gray" }; +} + +export function usageColor(percent: number): Tint { + if (percent >= 95) return "green"; + if (percent >= 85) return "teal"; + if (percent >= 70) return "yellow"; + if (percent >= 40) return "orange"; + return "red"; +} + +export const percent = (n: number) => `${n >= 10 ? n.toFixed(1) : n.toFixed(2)} %`.replace(/\.0 %$/, " %"); + +/** Short labels for the spec status. */ +export const STATUS_SHORT: Record = { + rec: "REC", + pr: "PR", + cr: "CR", + wd: "WD", + ls: "Living", + other: "Other", + unoff: "Unofficial", +}; + +/** The filter chip a feature belongs to. */ +export function group(f: Feature): "css" | "html" | "js" | "other" { + const c = f.categories; + if (c.some((x) => x.startsWith("CSS"))) return "css"; + if (c.includes("HTML5")) return "html"; + if (c.some((x) => x === "JS" || x === "JS API" || x === "DOM")) return "js"; + return "other"; +} + +export const caniuseUrl = (id: string) => `https://caniuse.com/${id}`; + +export const mdnLink = (f: Feature) => f.links.find((l) => l.url.includes("developer.mozilla.org")); + +export function markdown(f: Feature): string { + const nums = Object.entries(f.notesByNum); + return [ + f.description, + ...(f.notes ? ["", "**Notes**", "", f.notes] : []), + ...(nums.length ? ["", ...nums.map(([n, t]) => `${n}. ${t}`)] : []), + ].join("\n"); +} diff --git a/extensions/can-i-use/lib_test.ts b/extensions/can-i-use/lib_test.ts new file mode 100644 index 0000000..db53f65 --- /dev/null +++ b/extensions/can-i-use/lib_test.ts @@ -0,0 +1,149 @@ +import { assert, assertEquals } from "@std/assert"; +import { + caniuseUrl, + cleanText, + type Dataset, + group, + isFresh, + markdown, + mdnLink, + parseCell, + percent, + shortSupport, + summarize, + supportTags, + trim, + TTL_MS, + usageColor, +} from "./lib.ts"; + +// Six real features from caniuse's data-2.0.json (October 2026), with the seven browsers we show. +const RAW = JSON.parse(await Deno.readTextFile(new URL("./testdata/caniuse.json", import.meta.url))); +const DATA: Dataset = trim(RAW, 1000); +const feature = (id: string) => DATA.features.find((f) => f.id === id)!; + +Deno.test("parseCell reads support cells", () => { + assertEquals(parseCell("y"), { level: "y", prefix: false, flag: false, notes: [] }); + assertEquals(parseCell("a x #1 #3"), { level: "a", prefix: true, flag: false, notes: [1, 3] }); + assertEquals(parseCell("n d #2"), { level: "n", prefix: false, flag: true, notes: [2] }); + assertEquals(parseCell(undefined).level, "u"); + assertEquals(parseCell("zz").level, "u"); +}); + +Deno.test("summarize finds the first version of unbroken support", () => { + const versions = ["1", "2", "3", "4", "5", "6"].map((version, i) => ({ version, era: i - 4 })); + // Version 6 is a future one (era 1): ignored. + assertEquals(summarize(versions, { "1": "a #1", "2": "y x", "3": "n", "4": "y x", "5": "y", "6": "n" }), { + current: "5", + level: "y", + prefix: false, + flag: false, + notes: [], + since: "5", + prefixedSince: "4", + }); + assertEquals(summarize(versions, { "1": "n", "2": "a", "3": "a #2", "4": "a x #2", "5": "a #2" }), { + current: "5", + level: "a", + prefix: false, + flag: false, + notes: [2], + partialSince: "2", + }); + assertEquals(summarize(versions, undefined), undefined); + assertEquals(summarize([{ version: "156", era: 0 }], { "156": "y" })?.onlyCurrent, true); +}); + +Deno.test("trim keeps the features and their support", () => { + assertEquals(DATA.fetched, 1000); + assertEquals(DATA.updated, 1792647503); + assertEquals(DATA.statuses.wd, "W3C Working Draft"); + // Sorted by title. + assertEquals(DATA.features.map((f) => f.id), [ + "css-has", + "css-backdrop-filter", + "css-boxdecorationbreak", + "flexbox", + "css-line-clamp", + "imagecapture", + ]); + const has = feature("css-has"); + assertEquals(has.title, ":has() CSS relational pseudo-class"); + assertEquals(has.status, "wd"); + assertEquals(has.categories, ["CSS"]); + assertEquals(has.usageY, 95.91); + assertEquals(has.support.firefox, { + current: "158", + level: "y", + prefix: false, + flag: false, + notes: [], + since: "121", + }); + assertEquals(has.support.safari?.since, "15.4"); + assert(has.links.some((l) => l.url.includes("developer.mozilla.org"))); + // Small: about 2 KB a feature. + assert(JSON.stringify(DATA).length < 6 * 2500); +}); + +Deno.test("supportTags color each browser", () => { + const flex = feature("flexbox").support; + assertEquals(supportTags(flex.chrome), [ + { text: "29+", color: "green" }, + { text: "prefixed 21", color: "orange" }, + { text: "partial 4", color: "yellow" }, + ]); + assertEquals(supportTags(flex.and_chr), [{ text: "Yes", color: "green" }]); + assertEquals(supportTags(feature("css-line-clamp").support.firefox), [{ text: "68+ prefixed", color: "orange" }]); + assertEquals(supportTags(feature("css-boxdecorationbreak").support.safari), [ + { text: "Partial 6.1+", color: "yellow" }, + { text: "prefixed", color: "orange" }, + { text: "note 1", color: "gray" }, + ]); + assertEquals(supportTags(feature("imagecapture").support.firefox), [ + { text: "No", color: "red" }, + { text: "behind a flag", color: "purple" }, + { text: "note 1", color: "gray" }, + ]); + assertEquals(supportTags(undefined), [{ text: "No data", color: "gray" }]); +}); + +Deno.test("shortSupport, usage colors and percent", () => { + const ic = feature("imagecapture").support; + assertEquals(shortSupport(ic.chrome), { text: "59", color: "green" }); + assertEquals(shortSupport(ic.firefox), { text: "flag", color: "purple" }); + assertEquals(shortSupport(ic.safari), { text: "no", color: "red" }); + assertEquals(shortSupport(feature("css-line-clamp").support.safari), { text: "5", color: "orange" }); + assertEquals(shortSupport(feature("css-boxdecorationbreak").support.safari), { text: "6.1", color: "yellow" }); + assertEquals([usageColor(97), usageColor(90), usageColor(75), usageColor(50), usageColor(10)], [ + "green", + "teal", + "yellow", + "orange", + "red", + ]); + assertEquals(percent(95.91), "95.9 %"); + assertEquals(percent(0.21), "0.21 %"); + assertEquals(percent(100), "100 %"); +}); + +Deno.test("cleanText, groups, links and markdown", () => { + assertEquals( + cleanText('Use a:has(img), see this & that
ok'), + "Use `a:has(img)`, see [this](https://x.y) & *that*\nok", + ); + assertEquals(group(feature("flexbox")), "css"); + assertEquals(group(feature("imagecapture")), "js"); + assertEquals(caniuseUrl("flexbox"), "https://caniuse.com/flexbox"); + assertEquals(mdnLink(feature("css-has"))?.url, "https://developer.mozilla.org/en-US/docs/Web/CSS/:has"); + const md = markdown(feature("flexbox")); + assert(md.startsWith("Method of positioning elements")); + assert(md.includes("**Notes**")); + assert(md.includes("1. Only supports the [old flexbox]")); +}); + +Deno.test("isFresh uses a one-week TTL", () => { + assert(isFresh(DATA, 1000 + TTL_MS - 1)); + assert(!isFresh(DATA, 1000 + TTL_MS)); + assert(!isFresh(undefined)); +}); diff --git a/extensions/can-i-use/testdata/caniuse.json b/extensions/can-i-use/testdata/caniuse.json new file mode 100644 index 0000000..770ea9a --- /dev/null +++ b/extensions/can-i-use/testdata/caniuse.json @@ -0,0 +1,4121 @@ +{ + "agents": { + "chrome": { + "browser": "Chrome", + "version_list": [ + { "version": "4", "era": -151 }, + { "version": "5", "era": -150 }, + { "version": "6", "era": -149 }, + { "version": "7", "era": -148 }, + { "version": "8", "era": -147 }, + { "version": "9", "era": -146 }, + { "version": "10", "era": -145 }, + { "version": "11", "era": -144 }, + { "version": "12", "era": -143 }, + { "version": "13", "era": -142 }, + { "version": "14", "era": -141 }, + { "version": "15", "era": -140 }, + { "version": "16", "era": -139 }, + { "version": "17", "era": -138 }, + { "version": "18", "era": -137 }, + { "version": "19", "era": -136 }, + { "version": "20", "era": -135 }, + { "version": "21", "era": -134 }, + { "version": "22", "era": -133 }, + { "version": "23", "era": -132 }, + { "version": "24", "era": -131 }, + { "version": "25", "era": -130 }, + { "version": "26", "era": -129 }, + { "version": "27", "era": -128 }, + { "version": "28", "era": -127 }, + { "version": "29", "era": -126 }, + { "version": "30", "era": -125 }, + { "version": "31", "era": -124 }, + { "version": "32", "era": -123 }, + { "version": "33", "era": -122 }, + { "version": "34", "era": -121 }, + { "version": "35", "era": -120 }, + { "version": "36", "era": -119 }, + { "version": "37", "era": -118 }, + { "version": "38", "era": -117 }, + { "version": "39", "era": -116 }, + { "version": "40", "era": -115 }, + { "version": "41", "era": -114 }, + { "version": "42", "era": -113 }, + { "version": "43", "era": -112 }, + { "version": "44", "era": -111 }, + { "version": "45", "era": -110 }, + { "version": "46", "era": -109 }, + { "version": "47", "era": -108 }, + { "version": "48", "era": -107 }, + { "version": "49", "era": -106 }, + { "version": "50", "era": -105 }, + { "version": "51", "era": -104 }, + { "version": "52", "era": -103 }, + { "version": "53", "era": -102 }, + { "version": "54", "era": -101 }, + { "version": "55", "era": -100 }, + { "version": "56", "era": -99 }, + { "version": "57", "era": -98 }, + { "version": "58", "era": -97 }, + { "version": "59", "era": -96 }, + { "version": "60", "era": -95 }, + { "version": "61", "era": -94 }, + { "version": "62", "era": -93 }, + { "version": "63", "era": -92 }, + { "version": "64", "era": -91 }, + { "version": "65", "era": -90 }, + { "version": "66", "era": -89 }, + { "version": "67", "era": -88 }, + { "version": "68", "era": -87 }, + { "version": "69", "era": -86 }, + { "version": "70", "era": -85 }, + { "version": "71", "era": -84 }, + { "version": "72", "era": -83 }, + { "version": "73", "era": -82 }, + { "version": "74", "era": -81 }, + { "version": "75", "era": -80 }, + { "version": "76", "era": -79 }, + { "version": "77", "era": -78 }, + { "version": "78", "era": -77 }, + { "version": "79", "era": -76 }, + { "version": "80", "era": -75 }, + { "version": "81", "era": -74 }, + { "version": "83", "era": -73 }, + { "version": "84", "era": -72 }, + { "version": "85", "era": -71 }, + { "version": "86", "era": -70 }, + { "version": "87", "era": -69 }, + { "version": "88", "era": -68 }, + { "version": "89", "era": -67 }, + { "version": "90", "era": -66 }, + { "version": "91", "era": -65 }, + { "version": "92", "era": -64 }, + { "version": "93", "era": -63 }, + { "version": "94", "era": -62 }, + { "version": "95", "era": -61 }, + { "version": "96", "era": -60 }, + { "version": "97", "era": -59 }, + { "version": "98", "era": -58 }, + { "version": "99", "era": -57 }, + { "version": "100", "era": -56 }, + { "version": "101", "era": -55 }, + { "version": "102", "era": -54 }, + { "version": "103", "era": -53 }, + { "version": "104", "era": -52 }, + { "version": "105", "era": -51 }, + { "version": "106", "era": -50 }, + { "version": "107", "era": -49 }, + { "version": "108", "era": -48 }, + { "version": "109", "era": -47 }, + { "version": "110", "era": -46 }, + { "version": "111", "era": -45 }, + { "version": "112", "era": -44 }, + { "version": "113", "era": -43 }, + { "version": "114", "era": -42 }, + { "version": "115", "era": -41 }, + { "version": "116", "era": -40 }, + { "version": "117", "era": -39 }, + { "version": "118", "era": -38 }, + { "version": "119", "era": -37 }, + { "version": "120", "era": -36 }, + { "version": "121", "era": -35 }, + { "version": "122", "era": -34 }, + { "version": "123", "era": -33 }, + { "version": "124", "era": -32 }, + { "version": "125", "era": -31 }, + { "version": "126", "era": -30 }, + { "version": "127", "era": -29 }, + { "version": "128", "era": -28 }, + { "version": "129", "era": -27 }, + { "version": "130", "era": -26 }, + { "version": "131", "era": -25 }, + { "version": "132", "era": -24 }, + { "version": "133", "era": -23 }, + { "version": "134", "era": -22 }, + { "version": "135", "era": -21 }, + { "version": "136", "era": -20 }, + { "version": "137", "era": -19 }, + { "version": "138", "era": -18 }, + { "version": "139", "era": -17 }, + { "version": "140", "era": -16 }, + { "version": "141", "era": -15 }, + { "version": "142", "era": -14 }, + { "version": "143", "era": -13 }, + { "version": "144", "era": -12 }, + { "version": "145", "era": -11 }, + { "version": "146", "era": -10 }, + { "version": "147", "era": -9 }, + { "version": "148", "era": -8 }, + { "version": "149", "era": -7 }, + { "version": "150", "era": -6 }, + { "version": "151", "era": -5 }, + { "version": "152", "era": -4 }, + { "version": "153", "era": -3 }, + { "version": "154", "era": -2 }, + { "version": "155", "era": -1 }, + { "version": "156", "era": 0 }, + { "version": "157", "era": 1 }, + { "version": "158", "era": 2 }, + { "version": "159", "era": 3 } + ] + }, + "edge": { + "browser": "Edge", + "version_list": [ + { "version": "12", "era": -82 }, + { "version": "13", "era": -81 }, + { "version": "14", "era": -80 }, + { "version": "15", "era": -79 }, + { "version": "16", "era": -78 }, + { "version": "17", "era": -77 }, + { "version": "18", "era": -76 }, + { "version": "79", "era": -75 }, + { "version": "80", "era": -74 }, + { "version": "81", "era": -73 }, + { "version": "83", "era": -72 }, + { "version": "84", "era": -71 }, + { "version": "85", "era": -70 }, + { "version": "86", "era": -69 }, + { "version": "87", "era": -68 }, + { "version": "88", "era": -67 }, + { "version": "89", "era": -66 }, + { "version": "90", "era": -65 }, + { "version": "91", "era": -64 }, + { "version": "92", "era": -63 }, + { "version": "93", "era": -62 }, + { "version": "94", "era": -61 }, + { "version": "95", "era": -60 }, + { "version": "96", "era": -59 }, + { "version": "97", "era": -58 }, + { "version": "98", "era": -57 }, + { "version": "99", "era": -56 }, + { "version": "100", "era": -55 }, + { "version": "101", "era": -54 }, + { "version": "102", "era": -53 }, + { "version": "103", "era": -52 }, + { "version": "104", "era": -51 }, + { "version": "105", "era": -50 }, + { "version": "106", "era": -49 }, + { "version": "107", "era": -48 }, + { "version": "108", "era": -47 }, + { "version": "109", "era": -46 }, + { "version": "110", "era": -45 }, + { "version": "111", "era": -44 }, + { "version": "112", "era": -43 }, + { "version": "113", "era": -42 }, + { "version": "114", "era": -41 }, + { "version": "115", "era": -40 }, + { "version": "116", "era": -39 }, + { "version": "117", "era": -38 }, + { "version": "118", "era": -37 }, + { "version": "119", "era": -36 }, + { "version": "120", "era": -35 }, + { "version": "121", "era": -34 }, + { "version": "122", "era": -33 }, + { "version": "123", "era": -32 }, + { "version": "124", "era": -31 }, + { "version": "125", "era": -30 }, + { "version": "126", "era": -29 }, + { "version": "127", "era": -28 }, + { "version": "128", "era": -27 }, + { "version": "129", "era": -26 }, + { "version": "130", "era": -25 }, + { "version": "131", "era": -24 }, + { "version": "132", "era": -23 }, + { "version": "133", "era": -22 }, + { "version": "134", "era": -21 }, + { "version": "135", "era": -20 }, + { "version": "136", "era": -19 }, + { "version": "137", "era": -18 }, + { "version": "138", "era": -17 }, + { "version": "139", "era": -16 }, + { "version": "140", "era": -15 }, + { "version": "141", "era": -14 }, + { "version": "142", "era": -13 }, + { "version": "143", "era": -12 }, + { "version": "144", "era": -11 }, + { "version": "145", "era": -10 }, + { "version": "146", "era": -9 }, + { "version": "147", "era": -8 }, + { "version": "148", "era": -7 }, + { "version": "149", "era": -6 }, + { "version": "150", "era": -5 }, + { "version": "151", "era": -4 }, + { "version": "152", "era": -3 }, + { "version": "153", "era": -2 }, + { "version": "154", "era": -1 }, + { "version": "155", "era": 0 } + ] + }, + "firefox": { + "browser": "Firefox", + "version_list": [ + { "version": "2", "era": -158 }, + { "version": "3", "era": -157 }, + { "version": "3.5", "era": -156 }, + { "version": "3.6", "era": -155 }, + { "version": "4", "era": -154 }, + { "version": "5", "era": -153 }, + { "version": "6", "era": -152 }, + { "version": "7", "era": -151 }, + { "version": "8", "era": -150 }, + { "version": "9", "era": -149 }, + { "version": "10", "era": -148 }, + { "version": "11", "era": -147 }, + { "version": "12", "era": -146 }, + { "version": "13", "era": -145 }, + { "version": "14", "era": -144 }, + { "version": "15", "era": -143 }, + { "version": "16", "era": -142 }, + { "version": "17", "era": -141 }, + { "version": "18", "era": -140 }, + { "version": "19", "era": -139 }, + { "version": "20", "era": -138 }, + { "version": "21", "era": -137 }, + { "version": "22", "era": -136 }, + { "version": "23", "era": -135 }, + { "version": "24", "era": -134 }, + { "version": "25", "era": -133 }, + { "version": "26", "era": -132 }, + { "version": "27", "era": -131 }, + { "version": "28", "era": -130 }, + { "version": "29", "era": -129 }, + { "version": "30", "era": -128 }, + { "version": "31", "era": -127 }, + { "version": "32", "era": -126 }, + { "version": "33", "era": -125 }, + { "version": "34", "era": -124 }, + { "version": "35", "era": -123 }, + { "version": "36", "era": -122 }, + { "version": "37", "era": -121 }, + { "version": "38", "era": -120 }, + { "version": "39", "era": -119 }, + { "version": "40", "era": -118 }, + { "version": "41", "era": -117 }, + { "version": "42", "era": -116 }, + { "version": "43", "era": -115 }, + { "version": "44", "era": -114 }, + { "version": "45", "era": -113 }, + { "version": "46", "era": -112 }, + { "version": "47", "era": -111 }, + { "version": "48", "era": -110 }, + { "version": "49", "era": -109 }, + { "version": "50", "era": -108 }, + { "version": "51", "era": -107 }, + { "version": "52", "era": -106 }, + { "version": "53", "era": -105 }, + { "version": "54", "era": -104 }, + { "version": "55", "era": -103 }, + { "version": "56", "era": -102 }, + { "version": "57", "era": -101 }, + { "version": "58", "era": -100 }, + { "version": "59", "era": -99 }, + { "version": "60", "era": -98 }, + { "version": "61", "era": -97 }, + { "version": "62", "era": -96 }, + { "version": "63", "era": -95 }, + { "version": "64", "era": -94 }, + { "version": "65", "era": -93 }, + { "version": "66", "era": -92 }, + { "version": "67", "era": -91 }, + { "version": "68", "era": -90 }, + { "version": "69", "era": -89 }, + { "version": "70", "era": -88 }, + { "version": "71", "era": -87 }, + { "version": "72", "era": -86 }, + { "version": "73", "era": -85 }, + { "version": "74", "era": -84 }, + { "version": "75", "era": -83 }, + { "version": "76", "era": -82 }, + { "version": "77", "era": -81 }, + { "version": "78", "era": -80 }, + { "version": "79", "era": -79 }, + { "version": "80", "era": -78 }, + { "version": "81", "era": -77 }, + { "version": "82", "era": -76 }, + { "version": "83", "era": -75 }, + { "version": "84", "era": -74 }, + { "version": "85", "era": -73 }, + { "version": "86", "era": -72 }, + { "version": "87", "era": -71 }, + { "version": "88", "era": -70 }, + { "version": "89", "era": -69 }, + { "version": "90", "era": -68 }, + { "version": "91", "era": -67 }, + { "version": "92", "era": -66 }, + { "version": "93", "era": -65 }, + { "version": "94", "era": -64 }, + { "version": "95", "era": -63 }, + { "version": "96", "era": -62 }, + { "version": "97", "era": -61 }, + { "version": "98", "era": -60 }, + { "version": "99", "era": -59 }, + { "version": "100", "era": -58 }, + { "version": "101", "era": -57 }, + { "version": "102", "era": -56 }, + { "version": "103", "era": -55 }, + { "version": "104", "era": -54 }, + { "version": "105", "era": -53 }, + { "version": "106", "era": -52 }, + { "version": "107", "era": -51 }, + { "version": "108", "era": -50 }, + { "version": "109", "era": -49 }, + { "version": "110", "era": -48 }, + { "version": "111", "era": -47 }, + { "version": "112", "era": -46 }, + { "version": "113", "era": -45 }, + { "version": "114", "era": -44 }, + { "version": "115", "era": -43 }, + { "version": "116", "era": -42 }, + { "version": "117", "era": -41 }, + { "version": "118", "era": -40 }, + { "version": "119", "era": -39 }, + { "version": "120", "era": -38 }, + { "version": "121", "era": -37 }, + { "version": "122", "era": -36 }, + { "version": "123", "era": -35 }, + { "version": "124", "era": -34 }, + { "version": "125", "era": -33 }, + { "version": "126", "era": -32 }, + { "version": "127", "era": -31 }, + { "version": "128", "era": -30 }, + { "version": "129", "era": -29 }, + { "version": "130", "era": -28 }, + { "version": "131", "era": -27 }, + { "version": "132", "era": -26 }, + { "version": "133", "era": -25 }, + { "version": "134", "era": -24 }, + { "version": "135", "era": -23 }, + { "version": "136", "era": -22 }, + { "version": "137", "era": -21 }, + { "version": "138", "era": -20 }, + { "version": "139", "era": -19 }, + { "version": "140", "era": -18 }, + { "version": "141", "era": -17 }, + { "version": "142", "era": -16 }, + { "version": "143", "era": -15 }, + { "version": "144", "era": -14 }, + { "version": "145", "era": -13 }, + { "version": "146", "era": -12 }, + { "version": "147", "era": -11 }, + { "version": "148", "era": -10 }, + { "version": "149", "era": -9 }, + { "version": "150", "era": -8 }, + { "version": "151", "era": -7 }, + { "version": "152", "era": -6 }, + { "version": "153", "era": -5 }, + { "version": "154", "era": -4 }, + { "version": "155", "era": -3 }, + { "version": "156", "era": -2 }, + { "version": "157", "era": -1 }, + { "version": "158", "era": 0 }, + { "version": "159", "era": 1 }, + { "version": "160", "era": 2 }, + { "version": "161", "era": 3 } + ] + }, + "safari": { + "browser": "Safari", + "version_list": [ + { "version": "3.1", "era": -55 }, + { "version": "3.2", "era": -54 }, + { "version": "4", "era": -53 }, + { "version": "5", "era": -52 }, + { "version": "5.1", "era": -51 }, + { "version": "6", "era": -50 }, + { "version": "6.1", "era": -49 }, + { "version": "7", "era": -48 }, + { "version": "7.1", "era": -47 }, + { "version": "8", "era": -46 }, + { "version": "9", "era": -45 }, + { "version": "9.1", "era": -44 }, + { "version": "10", "era": -43 }, + { "version": "10.1", "era": -42 }, + { "version": "11", "era": -41 }, + { "version": "11.1", "era": -40 }, + { "version": "12", "era": -39 }, + { "version": "12.1", "era": -38 }, + { "version": "13", "era": -37 }, + { "version": "13.1", "era": -36 }, + { "version": "14", "era": -35 }, + { "version": "14.1", "era": -34 }, + { "version": "15", "era": -33 }, + { "version": "15.1", "era": -32 }, + { "version": "15.2-15.3", "era": -31 }, + { "version": "15.4", "era": -30 }, + { "version": "15.5", "era": -29 }, + { "version": "15.6", "era": -28 }, + { "version": "16.0", "era": -27 }, + { "version": "16.1", "era": -26 }, + { "version": "16.2", "era": -25 }, + { "version": "16.3", "era": -24 }, + { "version": "16.4", "era": -23 }, + { "version": "16.5", "era": -22 }, + { "version": "16.6", "era": -21 }, + { "version": "17.0", "era": -20 }, + { "version": "17.1", "era": -19 }, + { "version": "17.2", "era": -18 }, + { "version": "17.3", "era": -17 }, + { "version": "17.4", "era": -16 }, + { "version": "17.5", "era": -15 }, + { "version": "17.6", "era": -14 }, + { "version": "18.0", "era": -13 }, + { "version": "18.1", "era": -12 }, + { "version": "18.2", "era": -11 }, + { "version": "18.3", "era": -10 }, + { "version": "18.4", "era": -9 }, + { "version": "18.5-18.7", "era": -8 }, + { "version": "26.0", "era": -7 }, + { "version": "26.1", "era": -6 }, + { "version": "26.2", "era": -5 }, + { "version": "26.3", "era": -4 }, + { "version": "26.4", "era": -3 }, + { "version": "26.5", "era": -2 }, + { "version": "26.6", "era": -1 }, + { "version": "27", "era": 0 }, + { "version": "27.1", "era": 1 }, + { "version": "27.2", "era": 2 }, + { "version": "TP", "era": 3 } + ] + }, + "ios_saf": { + "browser": "Safari on iOS", + "version_list": [ + { "version": "3.2", "era": -54 }, + { "version": "4.0-4.1", "era": -53 }, + { "version": "4.2-4.3", "era": -52 }, + { "version": "5.0-5.1", "era": -51 }, + { "version": "6.0-6.1", "era": -50 }, + { "version": "7.0-7.1", "era": -49 }, + { "version": "8", "era": -48 }, + { "version": "8.1-8.4", "era": -47 }, + { "version": "9.0-9.2", "era": -46 }, + { "version": "9.3", "era": -45 }, + { "version": "10.0-10.2", "era": -44 }, + { "version": "10.3", "era": -43 }, + { "version": "11.0-11.2", "era": -42 }, + { "version": "11.3-11.4", "era": -41 }, + { "version": "12.0-12.1", "era": -40 }, + { "version": "12.2-12.5", "era": -39 }, + { "version": "13.0-13.1", "era": -38 }, + { "version": "13.2", "era": -37 }, + { "version": "13.3", "era": -36 }, + { "version": "13.4-13.7", "era": -35 }, + { "version": "14.0-14.4", "era": -34 }, + { "version": "14.5-14.8", "era": -33 }, + { "version": "15.0-15.1", "era": -32 }, + { "version": "15.2-15.3", "era": -31 }, + { "version": "15.4", "era": -30 }, + { "version": "15.5", "era": -29 }, + { "version": "15.6-15.8", "era": -28 }, + { "version": "16.0", "era": -27 }, + { "version": "16.1", "era": -26 }, + { "version": "16.2", "era": -25 }, + { "version": "16.3", "era": -24 }, + { "version": "16.4", "era": -23 }, + { "version": "16.5", "era": -22 }, + { "version": "16.6-16.7", "era": -21 }, + { "version": "17.0", "era": -20 }, + { "version": "17.1", "era": -19 }, + { "version": "17.2", "era": -18 }, + { "version": "17.3", "era": -17 }, + { "version": "17.4", "era": -16 }, + { "version": "17.5", "era": -15 }, + { "version": "17.6-17.7", "era": -14 }, + { "version": "18.0", "era": -13 }, + { "version": "18.1", "era": -12 }, + { "version": "18.2", "era": -11 }, + { "version": "18.3", "era": -10 }, + { "version": "18.4", "era": -9 }, + { "version": "18.5-18.7", "era": -8 }, + { "version": "26.0", "era": -7 }, + { "version": "26.1", "era": -6 }, + { "version": "26.2", "era": -5 }, + { "version": "26.3", "era": -4 }, + { "version": "26.4", "era": -3 }, + { "version": "26.5", "era": -2 }, + { "version": "26.6", "era": -1 }, + { "version": "27.0", "era": 0 }, + { "version": "27.1", "era": 1 }, + { "version": "27.2", "era": 2 } + ] + }, + "samsung": { + "browser": "Samsung Internet", + "version_list": [ + { "version": "4", "era": -26 }, + { "version": "5.0-5.4", "era": -25 }, + { "version": "6.2-6.4", "era": -24 }, + { "version": "7.2-7.4", "era": -23 }, + { "version": "8.2", "era": -22 }, + { "version": "9.2", "era": -21 }, + { "version": "10.1", "era": -20 }, + { "version": "11.1-11.2", "era": -19 }, + { "version": "12.0", "era": -18 }, + { "version": "13.0", "era": -17 }, + { "version": "14.0", "era": -16 }, + { "version": "15.0", "era": -15 }, + { "version": "16.0", "era": -14 }, + { "version": "17.0", "era": -13 }, + { "version": "18.0", "era": -12 }, + { "version": "19.0", "era": -11 }, + { "version": "20", "era": -10 }, + { "version": "21", "era": -9 }, + { "version": "22", "era": -8 }, + { "version": "23", "era": -7 }, + { "version": "24", "era": -6 }, + { "version": "25", "era": -5 }, + { "version": "26", "era": -4 }, + { "version": "27", "era": -3 }, + { "version": "28", "era": -2 }, + { "version": "29", "era": -1 }, + { "version": "30", "era": 0 } + ] + }, + "and_chr": { "browser": "Chrome for Android", "version_list": [{ "version": "156", "era": 0 }] } + }, + "statuses": { + "rec": "W3C Recommendation", + "pr": "W3C Proposed Recommendation", + "cr": "W3C Candidate Recommendation", + "wd": "W3C Working Draft", + "ls": "WHATWG Living Standard", + "other": "Other", + "unoff": "Unofficial / Note" + }, + "updated": 1792647503, + "data": { + "css-has": { + "title": ":has() CSS relational pseudo-class", + "description": "Select elements containing specific content. For example, `a:has(img)` selects all `` elements that contain an `` child.", + "spec": "https://w3c.github.io/csswg-drafts/selectors-4/#relational", + "status": "wd", + "links": [ + { "url": "https://developer.mozilla.org/en-US/docs/Web/CSS/:has", "title": "MDN Web Docs - :has" }, + { "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=418039", "title": "Firefox support bug" }, + { + "url": "https://bugs.chromium.org/p/chromium/issues/detail?id=669058", + "title": "Chrome bug to track implementation" + }, + { "url": "https://bugs.webkit.org/show_bug.cgi?id=227702", "title": "WebKit support bug" }, + { + "url": "https://webkit.org/blog/13096/css-has-pseudo-class/", + "title": "Using :has() as a CSS Parent Selector and much more" + } + ], + "categories": ["CSS"], + "stats": { + "chrome": { + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n", + "36": "n", + "37": "n", + "38": "n", + "39": "n", + "40": "n", + "41": "n", + "42": "n", + "43": "n", + "44": "n", + "45": "n", + "46": "n", + "47": "n", + "48": "n", + "49": "n", + "50": "n", + "51": "n", + "52": "n", + "53": "n", + "54": "n", + "55": "n", + "56": "n", + "57": "n", + "58": "n", + "59": "n", + "60": "n", + "61": "n", + "62": "n", + "63": "n", + "64": "n", + "65": "n", + "66": "n", + "67": "n", + "68": "n", + "69": "n", + "70": "n", + "71": "n", + "72": "n", + "73": "n", + "74": "n", + "75": "n", + "76": "n", + "77": "n", + "78": "n", + "79": "n", + "80": "n", + "81": "n", + "83": "n", + "84": "n", + "85": "n", + "86": "n", + "87": "n", + "88": "n", + "89": "n", + "90": "n", + "91": "n", + "92": "n", + "93": "n", + "94": "n", + "95": "n", + "96": "n", + "97": "n", + "98": "n", + "99": "n", + "100": "n", + "101": "n d #1", + "102": "n d #1", + "103": "n d #1", + "104": "n d #1", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y" + }, + "edge": { + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "79": "n", + "80": "n", + "81": "n", + "83": "n", + "84": "n", + "85": "n", + "86": "n", + "87": "n", + "88": "n", + "89": "n", + "90": "n", + "91": "n", + "92": "n", + "93": "n", + "94": "n", + "95": "n", + "96": "n", + "97": "n", + "98": "n", + "99": "n", + "100": "n", + "101": "n", + "102": "n", + "103": "n", + "104": "n", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y" + }, + "firefox": { + "2": "n", + "3": "n", + "3.5": "n", + "3.6": "n", + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n", + "36": "n", + "37": "n", + "38": "n", + "39": "n", + "40": "n", + "41": "n", + "42": "n", + "43": "n", + "44": "n", + "45": "n", + "46": "n", + "47": "n", + "48": "n", + "49": "n", + "50": "n", + "51": "n", + "52": "n", + "53": "n", + "54": "n", + "55": "n", + "56": "n", + "57": "n", + "58": "n", + "59": "n", + "60": "n", + "61": "n", + "62": "n", + "63": "n", + "64": "n", + "65": "n", + "66": "n", + "67": "n", + "68": "n", + "69": "n", + "70": "n", + "71": "n", + "72": "n", + "73": "n", + "74": "n", + "75": "n", + "76": "n", + "77": "n", + "78": "n", + "79": "n", + "80": "n", + "81": "n", + "82": "n", + "83": "n", + "84": "n", + "85": "n", + "86": "n", + "87": "n", + "88": "n", + "89": "n", + "90": "n", + "91": "n", + "92": "n", + "93": "n", + "94": "n", + "95": "n", + "96": "n", + "97": "n", + "98": "n", + "99": "n", + "100": "n", + "101": "n", + "102": "n", + "103": "n d #2", + "104": "n d #2", + "105": "n d #2", + "106": "n d #2", + "107": "n d #2", + "108": "n d #2", + "109": "n d #2", + "110": "n d #2", + "111": "n d #2", + "112": "n d #2", + "113": "n d #2", + "114": "n d #2", + "115": "n d #2", + "116": "n d #2", + "117": "n d #2", + "118": "n d #2", + "119": "n d #2", + "120": "n d #2", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y", + "160": "y", + "161": "y" + }, + "safari": { + "3.1": "n", + "3.2": "n", + "4": "n", + "5": "n", + "5.1": "n", + "6": "n", + "6.1": "n", + "7": "n", + "7.1": "n", + "8": "n", + "9": "n", + "9.1": "n", + "10": "n", + "10.1": "n", + "11": "n", + "11.1": "n", + "12": "n", + "12.1": "n", + "13": "n", + "13.1": "n", + "14": "n", + "14.1": "n", + "15": "n", + "15.1": "n", + "15.2-15.3": "n", + "15.4": "y", + "15.5": "y", + "15.6": "y", + "16.0": "y", + "16.1": "y", + "16.2": "y", + "16.3": "y", + "16.4": "y", + "16.5": "y", + "16.6": "y", + "17.0": "y", + "17.1": "y", + "17.2": "y", + "17.3": "y", + "17.4": "y", + "17.5": "y", + "17.6": "y", + "18.0": "y", + "18.1": "y", + "18.2": "y", + "18.3": "y", + "18.4": "y", + "18.5-18.7": "y", + "26.0": "y", + "26.1": "y", + "26.2": "y", + "26.3": "y", + "26.4": "y", + "26.5": "y", + "26.6": "y", + "27": "y", + "27.1": "y", + "27.2": "y", + "TP": "y" + }, + "ios_saf": { + "3.2": "n", + "4.0-4.1": "n", + "4.2-4.3": "n", + "5.0-5.1": "n", + "6.0-6.1": "n", + "7.0-7.1": "n", + "8": "n", + "8.1-8.4": "n", + "9.0-9.2": "n", + "9.3": "n", + "10.0-10.2": "n", + "10.3": "n", + "11.0-11.2": "n", + "11.3-11.4": "n", + "12.0-12.1": "n", + "12.2-12.5": "n", + "13.0-13.1": "n", + "13.2": "n", + "13.3": "n", + "13.4-13.7": "n", + "14.0-14.4": "n", + "14.5-14.8": "n", + "15.0-15.1": "n", + "15.2-15.3": "n", + "15.4": "y", + "15.5": "y", + "15.6-15.8": "y", + "16.0": "y", + "16.1": "y", + "16.2": "y", + "16.3": "y", + "16.4": "y", + "16.5": "y", + "16.6-16.7": "y", + "17.0": "y", + "17.1": "y", + "17.2": "y", + "17.3": "y", + "17.4": "y", + "17.5": "y", + "17.6-17.7": "y", + "18.0": "y", + "18.1": "y", + "18.2": "y", + "18.3": "y", + "18.4": "y", + "18.5-18.7": "y", + "26.0": "y", + "26.1": "y", + "26.2": "y", + "26.3": "y", + "26.4": "y", + "26.5": "y", + "26.6": "y", + "27.0": "y", + "27.1": "y", + "27.2": "y" + }, + "samsung": { + "4": "n", + "5.0-5.4": "n", + "6.2-6.4": "n", + "7.2-7.4": "n", + "8.2": "n", + "9.2": "n", + "10.1": "n", + "11.1-11.2": "n", + "12.0": "n", + "13.0": "n", + "14.0": "n", + "15.0": "n", + "16.0": "n", + "17.0": "n", + "18.0": "n", + "19.0": "n", + "20": "y", + "21": "y", + "22": "y", + "23": "y", + "24": "y", + "25": "y", + "26": "y", + "27": "y", + "28": "y", + "29": "y", + "30": "y" + }, + "and_chr": { "156": "y" } + }, + "notes": "", + "notes_by_num": { + "1": "Can be enabled via the [Experimental Web Platform features](chrome://flags/#enable-experimental-web-platform-features) flag", + "2": "Supported in Firefox behind the `layout.css.has-selector.enabled` flag, which is enabled by default since 119 (only Nightly)" + }, + "usage_perc_y": 95.91, + "usage_perc_a": 0, + "ucprefix": false, + "parent": "", + "keywords": "children,parent,selector", + "chrome_id": "5794378545102848" + }, + "flexbox": { + "title": "CSS Flexible Box Layout Module", + "description": "Method of positioning elements in horizontal or vertical stacks. Support includes all properties prefixed with `flex`, as well as `display: flex`, `display: inline-flex`, `align-content`, `align-items`, `align-self`, `justify-content` and `order`.", + "spec": "https://www.w3.org/TR/css3-flexbox/", + "status": "cr", + "links": [ + { "url": "https://bennettfeely.com/flexplorer/", "title": "Flexbox CSS generator" }, + { + "url": "https://www.adobe.com/devnet/html5/articles/working-with-flexbox-the-new-spec.html", + "title": "Article on using the latest spec" + }, + { + "url": "https://dev.opera.com/articles/view/advanced-cross-browser-flexbox/", + "title": "Tutorial on cross-browser support" + }, + { + "url": "https://philipwalton.github.io/solved-by-flexbox/", + "title": "Examples on how to solve common layout problems with flexbox" + }, + { "url": "https://css-tricks.com/snippets/css/a-guide-to-flexbox/", "title": "A Complete Guide to Flexbox" }, + { "url": "https://the-echoplex.net/flexyboxes/", "title": "Flexbox playground and code generator" }, + { "url": "https://github.com/philipwalton/flexbugs", "title": "Flexbugs: Repo for flexbox bugs" }, + { "url": "https://github.com/10up/flexibility/", "title": "10up Open Sources IE 8 and 9 Support for Flexbox" }, + { "url": "https://github.com/vadimyer/ecligrid", "title": "Ecligrid - Mobile first flexbox grid system" }, + { + "url": "https://mastery.games/post/the-difference-between-width-and-flex-basis/", + "title": "The Difference Between Width and Flex-Basis" + } + ], + "categories": ["CSS3"], + "stats": { + "chrome": { + "4": "a x #1", + "5": "a x #1", + "6": "a x #1", + "7": "a x #1", + "8": "a x #1", + "9": "a x #1", + "10": "a x #1", + "11": "a x #1", + "12": "a x #1", + "13": "a x #1", + "14": "a x #1", + "15": "a x #1", + "16": "a x #1", + "17": "a x #1", + "18": "a x #1", + "19": "a x #1", + "20": "a x #1", + "21": "y x", + "22": "y x", + "23": "y x", + "24": "y x", + "25": "y x", + "26": "y x", + "27": "y x", + "28": "y x", + "29": "y", + "30": "y", + "31": "y", + "32": "y", + "33": "y", + "34": "y", + "35": "y", + "36": "y", + "37": "y", + "38": "y", + "39": "y", + "40": "y", + "41": "y", + "42": "y", + "43": "y", + "44": "y", + "45": "y", + "46": "y", + "47": "y", + "48": "y", + "49": "y", + "50": "y", + "51": "y", + "52": "y", + "53": "y", + "54": "y", + "55": "y", + "56": "y", + "57": "y", + "58": "y", + "59": "y", + "60": "y", + "61": "y", + "62": "y", + "63": "y", + "64": "y", + "65": "y", + "66": "y", + "67": "y", + "68": "y", + "69": "y", + "70": "y", + "71": "y", + "72": "y", + "73": "y", + "74": "y", + "75": "y", + "76": "y", + "77": "y", + "78": "y", + "79": "y", + "80": "y", + "81": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y" + }, + "edge": { + "12": "y", + "13": "y", + "14": "y", + "15": "y", + "16": "y", + "17": "y", + "18": "y", + "79": "y", + "80": "y", + "81": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y" + }, + "firefox": { + "2": "a x #1", + "3": "a x #1", + "3.5": "a x #1", + "3.6": "a x #1", + "4": "a x #1", + "5": "a x #1", + "6": "a x #1", + "7": "a x #1", + "8": "a x #1", + "9": "a x #1", + "10": "a x #1", + "11": "a x #1", + "12": "a x #1", + "13": "a x #1", + "14": "a x #1", + "15": "a x #1", + "16": "a x #1", + "17": "a x #1", + "18": "a x #1", + "19": "a x #1", + "20": "a x #1", + "21": "a x #1", + "22": "a #3", + "23": "a #3", + "24": "a #3", + "25": "a #3", + "26": "a #3", + "27": "a #3", + "28": "y", + "29": "y", + "30": "y", + "31": "y", + "32": "y", + "33": "y", + "34": "y", + "35": "y", + "36": "y", + "37": "y", + "38": "y", + "39": "y", + "40": "y", + "41": "y", + "42": "y", + "43": "y", + "44": "y", + "45": "y", + "46": "y", + "47": "y", + "48": "y", + "49": "y", + "50": "y", + "51": "y", + "52": "y", + "53": "y", + "54": "y", + "55": "y", + "56": "y", + "57": "y", + "58": "y", + "59": "y", + "60": "y", + "61": "y", + "62": "y", + "63": "y", + "64": "y", + "65": "y", + "66": "y", + "67": "y", + "68": "y", + "69": "y", + "70": "y", + "71": "y", + "72": "y", + "73": "y", + "74": "y", + "75": "y", + "76": "y", + "77": "y", + "78": "y", + "79": "y", + "80": "y", + "81": "y", + "82": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y", + "160": "y", + "161": "y" + }, + "safari": { + "3.1": "a x #1", + "3.2": "a x #1", + "4": "a x #1", + "5": "a x #1", + "5.1": "a x #1", + "6": "a x #1", + "6.1": "y x", + "7": "y x", + "7.1": "y x", + "8": "y x", + "9": "y", + "9.1": "y", + "10": "y", + "10.1": "y", + "11": "y", + "11.1": "y", + "12": "y", + "12.1": "y", + "13": "y", + "13.1": "y", + "14": "y", + "14.1": "y", + "15": "y", + "15.1": "y", + "15.2-15.3": "y", + "15.4": "y", + "15.5": "y", + "15.6": "y", + "16.0": "y", + "16.1": "y", + "16.2": "y", + "16.3": "y", + "16.4": "y", + "16.5": "y", + "16.6": "y", + "17.0": "y", + "17.1": "y", + "17.2": "y", + "17.3": "y", + "17.4": "y", + "17.5": "y", + "17.6": "y", + "18.0": "y", + "18.1": "y", + "18.2": "y", + "18.3": "y", + "18.4": "y", + "18.5-18.7": "y", + "26.0": "y", + "26.1": "y", + "26.2": "y", + "26.3": "y", + "26.4": "y", + "26.5": "y", + "26.6": "y", + "27": "y", + "27.1": "y", + "27.2": "y", + "TP": "y" + }, + "ios_saf": { + "3.2": "a x #1", + "4.0-4.1": "a x #1", + "4.2-4.3": "a x #1", + "5.0-5.1": "a x #1", + "6.0-6.1": "a x #1", + "7.0-7.1": "y x", + "8": "y x", + "8.1-8.4": "y x", + "9.0-9.2": "y", + "9.3": "y", + "10.0-10.2": "y", + "10.3": "y", + "11.0-11.2": "y", + "11.3-11.4": "y", + "12.0-12.1": "y", + "12.2-12.5": "y", + "13.0-13.1": "y", + "13.2": "y", + "13.3": "y", + "13.4-13.7": "y", + "14.0-14.4": "y", + "14.5-14.8": "y", + "15.0-15.1": "y", + "15.2-15.3": "y", + "15.4": "y", + "15.5": "y", + "15.6-15.8": "y", + "16.0": "y", + "16.1": "y", + "16.2": "y", + "16.3": "y", + "16.4": "y", + "16.5": "y", + "16.6-16.7": "y", + "17.0": "y", + "17.1": "y", + "17.2": "y", + "17.3": "y", + "17.4": "y", + "17.5": "y", + "17.6-17.7": "y", + "18.0": "y", + "18.1": "y", + "18.2": "y", + "18.3": "y", + "18.4": "y", + "18.5-18.7": "y", + "26.0": "y", + "26.1": "y", + "26.2": "y", + "26.3": "y", + "26.4": "y", + "26.5": "y", + "26.6": "y", + "27.0": "y", + "27.1": "y", + "27.2": "y" + }, + "samsung": { + "4": "y", + "5.0-5.4": "y", + "6.2-6.4": "y", + "7.2-7.4": "y", + "8.2": "y", + "9.2": "y", + "10.1": "y", + "11.1-11.2": "y", + "12.0": "y", + "13.0": "y", + "14.0": "y", + "15.0": "y", + "16.0": "y", + "17.0": "y", + "18.0": "y", + "19.0": "y", + "20": "y", + "21": "y", + "22": "y", + "23": "y", + "24": "y", + "25": "y", + "26": "y", + "27": "y", + "28": "y", + "29": "y", + "30": "y" + }, + "and_chr": { "156": "y" } + }, + "notes": "Most partial support refers to supporting an [older version](https://www.w3.org/TR/2009/WD-css3-flexbox-20090723/) of the specification or an [older syntax](https://www.w3.org/TR/2012/WD-css3-flexbox-20120322/).", + "notes_by_num": { + "1": "Only supports the [old flexbox](https://www.w3.org/TR/2009/WD-css3-flexbox-20090723) specification and does not support wrapping.", + "2": "Only supports the [2012 syntax](https://www.w3.org/TR/2012/WD-css3-flexbox-20120322/)", + "3": "Does not support flex-wrap, flex-flow or align-content properties", + "4": "Partial support is due to large amount of bugs present (see known issues)" + }, + "usage_perc_y": 97.28, + "usage_perc_a": 0.21, + "ucprefix": false, + "parent": "", + "keywords": "flex-box,displayflex,flex-direction,flex-wrap,flex-flow,flex-grow,flex-shrink,flex-basis,display:flex,justify-content:space-between,justify-content:space-around,align-content:space-between,align-content:space-around,space-between", + "chrome_id": "4837301406400512" + }, + "css-backdrop-filter": { + "title": "CSS Backdrop Filter", + "description": "Method of applying filter effects (like blur, grayscale or hue) to content/elements below the target element.", + "spec": "https://drafts.fxtf.org/filter-effects-2/#BackdropFilterProperty", + "status": "unoff", + "links": [ + { + "url": "https://product.voxmedia.com/til/2015/2/17/8053347/css-ios-transparency-with-webkit-backdrop-filter", + "title": "Blog post" + }, + { + "url": "https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter", + "title": "MDN Web Docs - CSS backdrop filter" + }, + { + "url": "https://bugs.webkit.org/show_bug.cgi?id=224899", + "title": "WebKit bug to unprefix `-webkit-backdrop-filter`" + } + ], + "categories": ["CSS", "CSS3"], + "stats": { + "chrome": { + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n", + "36": "n", + "37": "n", + "38": "n", + "39": "n", + "40": "n", + "41": "n", + "42": "n", + "43": "n", + "44": "n", + "45": "n", + "46": "n", + "47": "n d #1", + "48": "n d #1", + "49": "n d #1", + "50": "n d #1", + "51": "n d #1", + "52": "n d #1", + "53": "n d #1", + "54": "n d #1", + "55": "n d #1", + "56": "n d #1", + "57": "n d #1", + "58": "n d #1", + "59": "n d #1", + "60": "n d #1", + "61": "n d #1", + "62": "n d #1", + "63": "n d #1", + "64": "n d #1", + "65": "n d #1", + "66": "n d #1", + "67": "n d #1", + "68": "n d #1", + "69": "n d #1", + "70": "n d #1", + "71": "n d #1", + "72": "n d #1", + "73": "n d #1", + "74": "n d #1", + "75": "n d #1", + "76": "y", + "77": "y", + "78": "y", + "79": "y", + "80": "y", + "81": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y" + }, + "edge": { + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "y #2", + "18": "y #2", + "79": "y", + "80": "y", + "81": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y" + }, + "firefox": { + "2": "n", + "3": "n", + "3.5": "n", + "3.6": "n", + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n", + "36": "n", + "37": "n", + "38": "n", + "39": "n", + "40": "n", + "41": "n", + "42": "n", + "43": "n", + "44": "n", + "45": "n", + "46": "n", + "47": "n", + "48": "n", + "49": "n", + "50": "n", + "51": "n", + "52": "n", + "53": "n", + "54": "n", + "55": "n", + "56": "n", + "57": "n", + "58": "n", + "59": "n", + "60": "n", + "61": "n", + "62": "n", + "63": "n", + "64": "n", + "65": "n", + "66": "n", + "67": "n", + "68": "n", + "69": "n", + "70": "n d #3", + "71": "n d #3", + "72": "n d #3", + "73": "n d #3", + "74": "n d #3", + "75": "n d #3", + "76": "n d #3", + "77": "n d #3", + "78": "n d #3", + "79": "n d #3", + "80": "n d #3", + "81": "n d #3", + "82": "n d #3", + "83": "n d #3", + "84": "n d #3", + "85": "n d #3", + "86": "n d #3", + "87": "n d #3", + "88": "n d #3", + "89": "n d #3", + "90": "n d #3", + "91": "n d #3", + "92": "n d #3", + "93": "n d #3", + "94": "n d #3", + "95": "n d #3", + "96": "n d #3", + "97": "n d #3", + "98": "n d #3", + "99": "n d #3", + "100": "n d #3", + "101": "n d #3", + "102": "n d #3", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y", + "160": "y", + "161": "y" + }, + "safari": { + "3.1": "n", + "3.2": "n", + "4": "n", + "5": "n", + "5.1": "n", + "6": "n", + "6.1": "n", + "7": "n", + "7.1": "n", + "8": "n", + "9": "y x", + "9.1": "y x", + "10": "y x", + "10.1": "y x", + "11": "y x", + "11.1": "y x", + "12": "y x", + "12.1": "y x", + "13": "y x", + "13.1": "y x", + "14": "y x", + "14.1": "y x", + "15": "y x", + "15.1": "y x", + "15.2-15.3": "y x", + "15.4": "y x", + "15.5": "y x", + "15.6": "y x", + "16.0": "y x", + "16.1": "y x", + "16.2": "y x", + "16.3": "y x", + "16.4": "y x", + "16.5": "y x", + "16.6": "y x", + "17.0": "y x", + "17.1": "y x", + "17.2": "y x", + "17.3": "y x", + "17.4": "y x", + "17.5": "y x", + "17.6": "y x", + "18.0": "y", + "18.1": "y", + "18.2": "y", + "18.3": "y", + "18.4": "y", + "18.5-18.7": "y", + "26.0": "y", + "26.1": "y", + "26.2": "y", + "26.3": "y", + "26.4": "y", + "26.5": "y", + "26.6": "y", + "27": "y", + "27.1": "y", + "27.2": "y", + "TP": "y" + }, + "ios_saf": { + "3.2": "n", + "4.0-4.1": "n", + "4.2-4.3": "n", + "5.0-5.1": "n", + "6.0-6.1": "n", + "7.0-7.1": "n", + "8": "n", + "8.1-8.4": "n", + "9.0-9.2": "y x", + "9.3": "y x", + "10.0-10.2": "y x", + "10.3": "y x", + "11.0-11.2": "y x", + "11.3-11.4": "y x", + "12.0-12.1": "y x", + "12.2-12.5": "y x", + "13.0-13.1": "y x", + "13.2": "y x", + "13.3": "y x", + "13.4-13.7": "y x", + "14.0-14.4": "y x", + "14.5-14.8": "y x", + "15.0-15.1": "y x", + "15.2-15.3": "y x", + "15.4": "y x", + "15.5": "y x", + "15.6-15.8": "y x", + "16.0": "y x", + "16.1": "y x", + "16.2": "y x", + "16.3": "y x", + "16.4": "y x", + "16.5": "y x", + "16.6-16.7": "y x", + "17.0": "y x", + "17.1": "y x", + "17.2": "y x", + "17.3": "y x", + "17.4": "y x", + "17.5": "y x", + "17.6-17.7": "y x", + "18.0": "y", + "18.1": "y", + "18.2": "y", + "18.3": "y", + "18.4": "y", + "18.5-18.7": "y", + "26.0": "y", + "26.1": "y", + "26.2": "y", + "26.3": "y", + "26.4": "y", + "26.5": "y", + "26.6": "y", + "27.0": "y", + "27.1": "y", + "27.2": "y" + }, + "samsung": { + "4": "n", + "5.0-5.4": "n d #1", + "6.2-6.4": "n d #1", + "7.2-7.4": "n d #1", + "8.2": "n d #1", + "9.2": "n d #1", + "10.1": "n d #1", + "11.1-11.2": "n d #1", + "12.0": "y", + "13.0": "y", + "14.0": "y", + "15.0": "y", + "16.0": "y", + "17.0": "y", + "18.0": "y", + "19.0": "y", + "20": "y", + "21": "y", + "22": "y", + "23": "y", + "24": "y", + "25": "y", + "26": "y", + "27": "y", + "28": "y", + "29": "y", + "30": "y" + }, + "and_chr": { "156": "y" } + }, + "notes": "", + "notes_by_num": { + "1": "Can be enabled via the \"Experimental Web Platform Features\" flag", + "2": "Currently only supported with the `-webkit-` prefix (not -ms-)", + "3": "Can be enabled by setting the `layout.css.backdrop-filter.enabled` and `gfx.webrender.all` preference to `true` in about:config." + }, + "usage_perc_y": 97.08, + "usage_perc_a": 0, + "ucprefix": false, + "parent": "", + "keywords": "blue,hue-rotate,invert,saturate,filter", + "chrome_id": "5679432723333120" + }, + "css-boxdecorationbreak": { + "title": "CSS box-decoration-break", + "description": "Controls whether the box's margins, borders, padding, and other decorations wrap the broken edges of the box fragments (when the box is split by a break (page/column/region/line).", + "spec": "https://www.w3.org/TR/css3-break/#break-decoration", + "status": "cr", + "links": [ + { + "url": "https://developer.mozilla.org/en-US/docs/Web/CSS/box-decoration-break", + "title": "MDN Web Docs - CSS box-decoration-break" + }, + { "url": "https://jsbin.com/xojoro/edit?css,output", "title": "Demo of effect on box border" }, + { + "url": "https://bugs.chromium.org/p/chromium/issues/detail?id=682224", + "title": "Chromium bug to unprefix `-webkit-box-decoration-break`" + }, + { + "url": "https://github.com/WebKit/standards-positions/issues/366", + "title": "WebKit position request to support box-decoration-break everywhere without prefix" + } + ], + "categories": ["CSS3"], + "stats": { + "chrome": { + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "a x #1", + "23": "a x #1", + "24": "a x #1", + "25": "a x #1", + "26": "a x #1", + "27": "a x #1", + "28": "a x #1", + "29": "a x #1", + "30": "a x #1", + "31": "a x #1", + "32": "a x #1", + "33": "a x #1", + "34": "a x #1", + "35": "a x #1", + "36": "a x #1", + "37": "a x #1", + "38": "a x #1", + "39": "a x #1", + "40": "a x #1", + "41": "a x #1", + "42": "a x #1", + "43": "a x #1", + "44": "a x #1", + "45": "a x #1", + "46": "a x #1", + "47": "a x #1", + "48": "a x #1", + "49": "a x #1", + "50": "a x #1", + "51": "a x #1", + "52": "a x #1", + "53": "a x #1", + "54": "a x #1", + "55": "a x #1", + "56": "a x #1", + "57": "a x #1", + "58": "a x #1", + "59": "a x #1", + "60": "a x #1", + "61": "a x #1", + "62": "a x #1", + "63": "a x #1", + "64": "a x #1", + "65": "a x #1", + "66": "a x #1", + "67": "a x #1", + "68": "a x #1", + "69": "a x #1", + "70": "a x #1", + "71": "a x #1", + "72": "a x #1", + "73": "a x #1", + "74": "a x #1", + "75": "a x #1", + "76": "a x #1", + "77": "a x #1", + "78": "a x #1", + "79": "a x #1", + "80": "a x #1", + "81": "a x #1", + "83": "a x #1", + "84": "a x #1", + "85": "a x #1", + "86": "a x #1", + "87": "a x #1", + "88": "a x #1", + "89": "a x #1", + "90": "a x #1", + "91": "a x #1", + "92": "a x #1", + "93": "a x #1", + "94": "a x #1", + "95": "a x #1", + "96": "a x #1", + "97": "a x #1", + "98": "a x #1", + "99": "a x #1", + "100": "a x #1", + "101": "a x #1", + "102": "a x #1", + "103": "a x #1", + "104": "a x #1", + "105": "a x #1", + "106": "a x #1", + "107": "a x #1", + "108": "a x #1", + "109": "a x #1", + "110": "a x #1", + "111": "a x #1", + "112": "a x #1", + "113": "a x #1", + "114": "a x #1", + "115": "a x #1", + "116": "a x #1", + "117": "a x #1", + "118": "a x #1", + "119": "a x #1", + "120": "a x #1", + "121": "a x #1", + "122": "a x #1", + "123": "a x #1", + "124": "a x #1", + "125": "a x #1", + "126": "a x #1", + "127": "a x #1", + "128": "a x #1", + "129": "a x #1", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y" + }, + "edge": { + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "79": "a x #1", + "80": "a x #1", + "81": "a x #1", + "83": "a x #1", + "84": "a x #1", + "85": "a x #1", + "86": "a x #1", + "87": "a x #1", + "88": "a x #1", + "89": "a x #1", + "90": "a x #1", + "91": "a x #1", + "92": "a x #1", + "93": "a x #1", + "94": "a x #1", + "95": "a x #1", + "96": "a x #1", + "97": "a x #1", + "98": "a x #1", + "99": "a x #1", + "100": "a x #1", + "101": "a x #1", + "102": "a x #1", + "103": "a x #1", + "104": "a x #1", + "105": "a x #1", + "106": "a x #1", + "107": "a x #1", + "108": "a x #1", + "109": "a x #1", + "110": "a x #1", + "111": "a x #1", + "112": "a x #1", + "113": "a x #1", + "114": "a x #1", + "115": "a x #1", + "116": "a x #1", + "117": "a x #1", + "118": "a x #1", + "119": "a x #1", + "120": "a x #1", + "121": "a x #1", + "122": "a x #1", + "123": "a x #1", + "124": "a x #1", + "125": "a x #1", + "126": "a x #1", + "127": "a x #1", + "128": "a x #1", + "129": "a x #1", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y" + }, + "firefox": { + "2": "n", + "3": "n", + "3.5": "n", + "3.6": "n", + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "y", + "33": "y", + "34": "y", + "35": "y", + "36": "y", + "37": "y", + "38": "y", + "39": "y", + "40": "y", + "41": "y", + "42": "y", + "43": "y", + "44": "y", + "45": "y", + "46": "y", + "47": "y", + "48": "y", + "49": "y", + "50": "y", + "51": "y", + "52": "y", + "53": "y", + "54": "y", + "55": "y", + "56": "y", + "57": "y", + "58": "y", + "59": "y", + "60": "y", + "61": "y", + "62": "y", + "63": "y", + "64": "y", + "65": "y", + "66": "y", + "67": "y", + "68": "y", + "69": "y", + "70": "y", + "71": "y", + "72": "y", + "73": "y", + "74": "y", + "75": "y", + "76": "y", + "77": "y", + "78": "y", + "79": "y", + "80": "y", + "81": "y", + "82": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y", + "160": "y", + "161": "y" + }, + "safari": { + "3.1": "n", + "3.2": "n", + "4": "n", + "5": "n", + "5.1": "n", + "6": "n", + "6.1": "a x #1", + "7": "a x #1", + "7.1": "a x #1", + "8": "a x #1", + "9": "a x #1", + "9.1": "a x #1", + "10": "a x #1", + "10.1": "a x #1", + "11": "a x #1", + "11.1": "a x #1", + "12": "a x #1", + "12.1": "a x #1", + "13": "a x #1", + "13.1": "a x #1", + "14": "a x #1", + "14.1": "a x #1", + "15": "a x #1", + "15.1": "a x #1", + "15.2-15.3": "a x #1", + "15.4": "a x #1", + "15.5": "a x #1", + "15.6": "a x #1", + "16.0": "a x #1", + "16.1": "a x #1", + "16.2": "a x #1", + "16.3": "a x #1", + "16.4": "a x #1", + "16.5": "a x #1", + "16.6": "a x #1", + "17.0": "a x #1", + "17.1": "a x #1", + "17.2": "a x #1", + "17.3": "a x #1", + "17.4": "a x #1", + "17.5": "a x #1", + "17.6": "a x #1", + "18.0": "a x #1", + "18.1": "a x #1", + "18.2": "a x #1", + "18.3": "a x #1", + "18.4": "a x #1", + "18.5-18.7": "a x #1", + "26.0": "a x #1", + "26.1": "a x #1", + "26.2": "a x #1", + "26.3": "a x #1", + "26.4": "a x #1", + "26.5": "a x #1", + "26.6": "a x #1", + "27": "a x #1", + "27.1": "a x #1", + "27.2": "a x #1", + "TP": "a x #1" + }, + "ios_saf": { + "3.2": "n", + "4.0-4.1": "n", + "4.2-4.3": "n", + "5.0-5.1": "n", + "6.0-6.1": "n", + "7.0-7.1": "a x #1", + "8": "a x #1", + "8.1-8.4": "a x #1", + "9.0-9.2": "a x #1", + "9.3": "a x #1", + "10.0-10.2": "a x #1", + "10.3": "a x #1", + "11.0-11.2": "a x #1", + "11.3-11.4": "a x #1", + "12.0-12.1": "a x #1", + "12.2-12.5": "a x #1", + "13.0-13.1": "a x #1", + "13.2": "a x #1", + "13.3": "a x #1", + "13.4-13.7": "a x #1", + "14.0-14.4": "a x #1", + "14.5-14.8": "a x #1", + "15.0-15.1": "a x #1", + "15.2-15.3": "a x #1", + "15.4": "a x #1", + "15.5": "a x #1", + "15.6-15.8": "a x #1", + "16.0": "a x #1", + "16.1": "a x #1", + "16.2": "a x #1", + "16.3": "a x #1", + "16.4": "a x #1", + "16.5": "a x #1", + "16.6-16.7": "a x #1", + "17.0": "a x #1", + "17.1": "a x #1", + "17.2": "a x #1", + "17.3": "a x #1", + "17.4": "a x #1", + "17.5": "a x #1", + "17.6-17.7": "a x #1", + "18.0": "a x #1", + "18.1": "a x #1", + "18.2": "a x #1", + "18.3": "a x #1", + "18.4": "a x #1", + "18.5-18.7": "a x #1", + "26.0": "a x #1", + "26.1": "a x #1", + "26.2": "a x #1", + "26.3": "a x #1", + "26.4": "a x #1", + "26.5": "a x #1", + "26.6": "a x #1", + "27.0": "a x #1", + "27.1": "a x #1", + "27.2": "a x #1" + }, + "samsung": { + "4": "a x #1", + "5.0-5.4": "a x #1", + "6.2-6.4": "a x #1", + "7.2-7.4": "a x #1", + "8.2": "a x #1", + "9.2": "a x #1", + "10.1": "a x #1", + "11.1-11.2": "a x #1", + "12.0": "a x #1", + "13.0": "a x #1", + "14.0": "a x #1", + "15.0": "a x #1", + "16.0": "a x #1", + "17.0": "a x #1", + "18.0": "a x #1", + "19.0": "a x #1", + "20": "a x #1", + "21": "a x #1", + "22": "a x #1", + "23": "a x #1", + "24": "a x #1", + "25": "a x #1", + "26": "a x #1", + "27": "a x #1", + "28": "a x #1", + "29": "a x #1", + "30": "a x #1" + }, + "and_chr": { "156": "y" } + }, + "notes": "", + "notes_by_num": { + "1": "Partial support refers to working for inline elements but not across column or page breaks." + }, + "usage_perc_y": 72.47, + "usage_perc_a": 24.8, + "ucprefix": false, + "parent": "", + "keywords": "box-decoration,box decoration,break", + "chrome_id": "5162398704205824" + }, + "imagecapture": { + "title": "ImageCapture API", + "description": "The Image Capture API provides access to the Video Camera for taking photos while configuring picture-specific settings such as e.g. zoom or auto focus metering area.", + "spec": "https://w3c.github.io/mediacapture-image/#imagecaptureapi", + "status": "wd", + "links": [ + { "url": "https://codepen.io/miguelao/pen/ZOkOQw", "title": "Minimal code pen" }, + { "url": "https://rawgit.com/Miguelao/demos/master/imagecapture.html", "title": "Extended demo" }, + { "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=888177", "title": "Firefox tracking bug" } + ], + "categories": ["DOM", "JS API"], + "stats": { + "chrome": { + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n", + "36": "n", + "37": "n", + "38": "n", + "39": "n", + "40": "n", + "41": "n", + "42": "n", + "43": "n", + "44": "n", + "45": "n", + "46": "n", + "47": "n", + "48": "n", + "49": "n", + "50": "n", + "51": "n", + "52": "n", + "53": "n d #2", + "54": "n d #2", + "55": "n d #2", + "56": "n d #2", + "57": "n d #2", + "58": "n d #2", + "59": "y", + "60": "y", + "61": "y", + "62": "y", + "63": "y", + "64": "y", + "65": "y", + "66": "y", + "67": "y", + "68": "y", + "69": "y", + "70": "y", + "71": "y", + "72": "y", + "73": "y", + "74": "y", + "75": "y", + "76": "y", + "77": "y", + "78": "y", + "79": "y", + "80": "y", + "81": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y", + "156": "y", + "157": "y", + "158": "y", + "159": "y" + }, + "edge": { + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "79": "y", + "80": "y", + "81": "y", + "83": "y", + "84": "y", + "85": "y", + "86": "y", + "87": "y", + "88": "y", + "89": "y", + "90": "y", + "91": "y", + "92": "y", + "93": "y", + "94": "y", + "95": "y", + "96": "y", + "97": "y", + "98": "y", + "99": "y", + "100": "y", + "101": "y", + "102": "y", + "103": "y", + "104": "y", + "105": "y", + "106": "y", + "107": "y", + "108": "y", + "109": "y", + "110": "y", + "111": "y", + "112": "y", + "113": "y", + "114": "y", + "115": "y", + "116": "y", + "117": "y", + "118": "y", + "119": "y", + "120": "y", + "121": "y", + "122": "y", + "123": "y", + "124": "y", + "125": "y", + "126": "y", + "127": "y", + "128": "y", + "129": "y", + "130": "y", + "131": "y", + "132": "y", + "133": "y", + "134": "y", + "135": "y", + "136": "y", + "137": "y", + "138": "y", + "139": "y", + "140": "y", + "141": "y", + "142": "y", + "143": "y", + "144": "y", + "145": "y", + "146": "y", + "147": "y", + "148": "y", + "149": "y", + "150": "y", + "151": "y", + "152": "y", + "153": "y", + "154": "y", + "155": "y" + }, + "firefox": { + "2": "n", + "3": "n", + "3.5": "n", + "3.6": "n", + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n d #1", + "36": "n d #1", + "37": "n d #1", + "38": "n d #1", + "39": "n d #1", + "40": "n d #1", + "41": "n d #1", + "42": "n d #1", + "43": "n d #1", + "44": "n d #1", + "45": "n d #1", + "46": "n d #1", + "47": "n d #1", + "48": "n d #1", + "49": "n d #1", + "50": "n d #1", + "51": "n d #1", + "52": "n d #1", + "53": "n d #1", + "54": "n d #1", + "55": "n d #1", + "56": "n d #1", + "57": "n d #1", + "58": "n d #1", + "59": "n d #1", + "60": "n d #1", + "61": "n d #1", + "62": "n d #1", + "63": "n d #1", + "64": "n d #1", + "65": "n d #1", + "66": "n d #1", + "67": "n d #1", + "68": "n d #1", + "69": "n d #1", + "70": "n d #1", + "71": "n d #1", + "72": "n d #1", + "73": "n d #1", + "74": "n d #1", + "75": "n d #1", + "76": "n d #1", + "77": "n d #1", + "78": "n d #1", + "79": "n d #1", + "80": "n d #1", + "81": "n d #1", + "82": "n d #1", + "83": "n d #1", + "84": "n d #1", + "85": "n d #1", + "86": "n d #1", + "87": "n d #1", + "88": "n d #1", + "89": "n d #1", + "90": "n d #1", + "91": "n d #1", + "92": "n d #1", + "93": "n d #1", + "94": "n d #1", + "95": "n d #1", + "96": "n d #1", + "97": "n d #1", + "98": "n d #1", + "99": "n d #1", + "100": "n d #1", + "101": "n d #1", + "102": "n d #1", + "103": "n d #1", + "104": "n d #1", + "105": "n d #1", + "106": "n d #1", + "107": "n d #1", + "108": "n d #1", + "109": "n d #1", + "110": "n d #1", + "111": "n d #1", + "112": "n d #1", + "113": "n d #1", + "114": "n d #1", + "115": "n d #1", + "116": "n d #1", + "117": "n d #1", + "118": "n d #1", + "119": "n d #1", + "120": "n d #1", + "121": "n d #1", + "122": "n d #1", + "123": "n d #1", + "124": "n d #1", + "125": "n d #1", + "126": "n d #1", + "127": "n d #1", + "128": "n d #1", + "129": "n d #1", + "130": "n d #1", + "131": "n d #1", + "132": "n d #1", + "133": "n d #1", + "134": "n d #1", + "135": "n d #1", + "136": "n d #1", + "137": "n d #1", + "138": "n d #1", + "139": "n d #1", + "140": "n d #1", + "141": "n d #1", + "142": "n d #1", + "143": "n d #1", + "144": "n d #1", + "145": "n d #1", + "146": "n d #1", + "147": "n d #1", + "148": "n d #1", + "149": "n d #1", + "150": "n d #1", + "151": "n d #1", + "152": "n d #1", + "153": "n d #1", + "154": "n d #1", + "155": "n d #1", + "156": "n d #1", + "157": "n d #1", + "158": "n d #1", + "159": "n d #1", + "160": "n d #1", + "161": "n d #1" + }, + "safari": { + "3.1": "n", + "3.2": "n", + "4": "n", + "5": "n", + "5.1": "n", + "6": "n", + "6.1": "n", + "7": "n", + "7.1": "n", + "8": "n", + "9": "n", + "9.1": "n", + "10": "n", + "10.1": "n", + "11": "n", + "11.1": "n", + "12": "n", + "12.1": "n", + "13": "n", + "13.1": "n", + "14": "n", + "14.1": "n", + "15": "n", + "15.1": "n", + "15.2-15.3": "n", + "15.4": "n", + "15.5": "n", + "15.6": "n", + "16.0": "n", + "16.1": "n", + "16.2": "n", + "16.3": "n", + "16.4": "n", + "16.5": "n", + "16.6": "n", + "17.0": "n", + "17.1": "n", + "17.2": "n", + "17.3": "n", + "17.4": "n", + "17.5": "n", + "17.6": "n", + "18.0": "n", + "18.1": "n", + "18.2": "n", + "18.3": "n", + "18.4": "n", + "18.5-18.7": "n", + "26.0": "n", + "26.1": "n", + "26.2": "n", + "26.3": "n", + "26.4": "n", + "26.5": "n", + "26.6": "n", + "27": "n", + "27.1": "n", + "27.2": "n", + "TP": "a #3" + }, + "ios_saf": { + "3.2": "n", + "4.0-4.1": "n", + "4.2-4.3": "n", + "5.0-5.1": "n", + "6.0-6.1": "n", + "7.0-7.1": "n", + "8": "n", + "8.1-8.4": "n", + "9.0-9.2": "n", + "9.3": "n", + "10.0-10.2": "n", + "10.3": "n", + "11.0-11.2": "n", + "11.3-11.4": "n", + "12.0-12.1": "n", + "12.2-12.5": "n", + "13.0-13.1": "n", + "13.2": "n", + "13.3": "n", + "13.4-13.7": "n", + "14.0-14.4": "n", + "14.5-14.8": "n", + "15.0-15.1": "n", + "15.2-15.3": "n", + "15.4": "n", + "15.5": "n", + "15.6-15.8": "n", + "16.0": "n", + "16.1": "n", + "16.2": "n", + "16.3": "n", + "16.4": "n", + "16.5": "n", + "16.6-16.7": "n", + "17.0": "n", + "17.1": "n", + "17.2": "n", + "17.3": "n", + "17.4": "n", + "17.5": "n", + "17.6-17.7": "n", + "18.0": "n", + "18.1": "n", + "18.2": "n", + "18.3": "n", + "18.4": "n", + "18.5-18.7": "n", + "26.0": "n", + "26.1": "n", + "26.2": "n", + "26.3": "n", + "26.4": "n", + "26.5": "n", + "26.6": "n", + "27.0": "n", + "27.1": "n", + "27.2": "n" + }, + "samsung": { + "4": "n", + "5.0-5.4": "y", + "6.2-6.4": "y", + "7.2-7.4": "y", + "8.2": "y", + "9.2": "y", + "10.1": "y", + "11.1-11.2": "y", + "12.0": "y", + "13.0": "y", + "14.0": "y", + "15.0": "y", + "16.0": "y", + "17.0": "y", + "18.0": "y", + "19.0": "y", + "20": "y", + "21": "y", + "22": "y", + "23": "y", + "24": "y", + "25": "y", + "26": "y", + "27": "y", + "28": "y", + "29": "y", + "30": "y" + }, + "and_chr": { "156": "y" } + }, + "notes": "Firefox supports the `takePhoto()` method only (when flag is enabled).", + "notes_by_num": { + "1": "Can be enabled via the about:config entry dom.imagecapture.enabled.", + "2": "Can be enabled via the Experimental Web Platform Features flag.", + "3": "Partial support refers to not supporting `grabFrame()`." + }, + "usage_perc_y": 76.64, + "usage_perc_a": 0, + "ucprefix": false, + "parent": "stream", + "keywords": "camera,device,photos,media stream,mediastream,Image Capture API", + "chrome_id": "" + }, + "css-line-clamp": { + "title": "CSS line-clamp", + "description": "CSS property that will contain text to a given amount of lines when used in combination with `display: -webkit-box`. It will end with ellipsis when `text-overflow: ellipsis` is included.", + "spec": "https://w3c.github.io/csswg-drafts/css-overflow-3/#propdef--webkit-line-clamp", + "status": "wd", + "links": [ + { "url": "https://css-tricks.com/line-clampin/", "title": "CSS Tricks article" }, + { "url": "https://developer.mozilla.org/en-US/docs/Web/CSS/line-clamp", "title": "MDN Web Docs - line-clamp" } + ], + "categories": ["CSS"], + "stats": { + "chrome": { + "4": "u", + "5": "u", + "6": "u", + "7": "u", + "8": "u", + "9": "u", + "10": "u", + "11": "u", + "12": "u", + "13": "u", + "14": "y x", + "15": "y x", + "16": "y x", + "17": "y x", + "18": "y x", + "19": "y x", + "20": "y x", + "21": "y x", + "22": "y x", + "23": "y x", + "24": "y x", + "25": "y x", + "26": "y x", + "27": "y x", + "28": "y x", + "29": "y x", + "30": "y x", + "31": "y x", + "32": "y x", + "33": "y x", + "34": "y x", + "35": "y x", + "36": "y x", + "37": "y x", + "38": "y x", + "39": "y x", + "40": "y x", + "41": "y x", + "42": "y x", + "43": "y x", + "44": "y x", + "45": "y x", + "46": "y x", + "47": "y x", + "48": "y x", + "49": "y x", + "50": "y x", + "51": "y x", + "52": "y x", + "53": "y x", + "54": "y x", + "55": "y x", + "56": "y x", + "57": "y x", + "58": "y x", + "59": "y x", + "60": "y x", + "61": "y x", + "62": "y x", + "63": "y x", + "64": "y x", + "65": "y x", + "66": "y x", + "67": "y x", + "68": "y x", + "69": "y x", + "70": "y x", + "71": "y x", + "72": "y x", + "73": "y x", + "74": "y x", + "75": "y x", + "76": "y x", + "77": "y x", + "78": "y x", + "79": "y x", + "80": "y x", + "81": "y x", + "83": "y x", + "84": "y x", + "85": "y x", + "86": "y x", + "87": "y x", + "88": "y x", + "89": "y x", + "90": "y x", + "91": "y x", + "92": "y x", + "93": "y x", + "94": "y x", + "95": "y x", + "96": "y x", + "97": "y x", + "98": "y x", + "99": "y x", + "100": "y x", + "101": "y x", + "102": "y x", + "103": "y x", + "104": "y x", + "105": "y x", + "106": "y x", + "107": "y x", + "108": "y x", + "109": "y x", + "110": "y x", + "111": "y x", + "112": "y x", + "113": "y x", + "114": "y x", + "115": "y x", + "116": "y x", + "117": "y x", + "118": "y x", + "119": "y x", + "120": "y x", + "121": "y x", + "122": "y x", + "123": "y x", + "124": "y x", + "125": "y x", + "126": "y x", + "127": "y x", + "128": "y x", + "129": "y x", + "130": "y x", + "131": "y x", + "132": "y x", + "133": "y x", + "134": "y x", + "135": "y x", + "136": "y x", + "137": "y x", + "138": "y x", + "139": "y x", + "140": "y x", + "141": "y x", + "142": "y x", + "143": "y x", + "144": "y x", + "145": "y x", + "146": "y x", + "147": "y x", + "148": "y x", + "149": "y x", + "150": "y x", + "151": "y x", + "152": "y x", + "153": "y x", + "154": "y x", + "155": "y x", + "156": "y x", + "157": "y x", + "158": "y x", + "159": "y x" + }, + "edge": { + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "y #1", + "18": "y #1", + "79": "y x", + "80": "y x", + "81": "y x", + "83": "y x", + "84": "y x", + "85": "y x", + "86": "y x", + "87": "y x", + "88": "y x", + "89": "y x", + "90": "y x", + "91": "y x", + "92": "y x", + "93": "y x", + "94": "y x", + "95": "y x", + "96": "y x", + "97": "y x", + "98": "y x", + "99": "y x", + "100": "y x", + "101": "y x", + "102": "y x", + "103": "y x", + "104": "y x", + "105": "y x", + "106": "y x", + "107": "y x", + "108": "y x", + "109": "y x", + "110": "y x", + "111": "y x", + "112": "y x", + "113": "y x", + "114": "y x", + "115": "y x", + "116": "y x", + "117": "y x", + "118": "y x", + "119": "y x", + "120": "y x", + "121": "y x", + "122": "y x", + "123": "y x", + "124": "y x", + "125": "y x", + "126": "y x", + "127": "y x", + "128": "y x", + "129": "y x", + "130": "y x", + "131": "y x", + "132": "y x", + "133": "y x", + "134": "y x", + "135": "y x", + "136": "y x", + "137": "y x", + "138": "y x", + "139": "y x", + "140": "y x", + "141": "y x", + "142": "y x", + "143": "y x", + "144": "y x", + "145": "y x", + "146": "y x", + "147": "y x", + "148": "y x", + "149": "y x", + "150": "y x", + "151": "y x", + "152": "y x", + "153": "y x", + "154": "y x", + "155": "y x" + }, + "firefox": { + "2": "n", + "3": "n", + "3.5": "n", + "3.6": "n", + "4": "n", + "5": "n", + "6": "n", + "7": "n", + "8": "n", + "9": "n", + "10": "n", + "11": "n", + "12": "n", + "13": "n", + "14": "n", + "15": "n", + "16": "n", + "17": "n", + "18": "n", + "19": "n", + "20": "n", + "21": "n", + "22": "n", + "23": "n", + "24": "n", + "25": "n", + "26": "n", + "27": "n", + "28": "n", + "29": "n", + "30": "n", + "31": "n", + "32": "n", + "33": "n", + "34": "n", + "35": "n", + "36": "n", + "37": "n", + "38": "n", + "39": "n", + "40": "n", + "41": "n", + "42": "n", + "43": "n", + "44": "n", + "45": "n", + "46": "n", + "47": "n", + "48": "n", + "49": "n", + "50": "n", + "51": "n", + "52": "n", + "53": "n", + "54": "n", + "55": "n", + "56": "n", + "57": "n", + "58": "n", + "59": "n", + "60": "n", + "61": "n", + "62": "n", + "63": "n", + "64": "n", + "65": "n", + "66": "n", + "67": "n", + "68": "y x", + "69": "y x", + "70": "y x", + "71": "y x", + "72": "y x", + "73": "y x", + "74": "y x", + "75": "y x", + "76": "y x", + "77": "y x", + "78": "y x", + "79": "y x", + "80": "y x", + "81": "y x", + "82": "y x", + "83": "y x", + "84": "y x", + "85": "y x", + "86": "y x", + "87": "y x", + "88": "y x", + "89": "y x", + "90": "y x", + "91": "y x", + "92": "y x", + "93": "y x", + "94": "y x", + "95": "y x", + "96": "y x", + "97": "y x", + "98": "y x", + "99": "y x", + "100": "y x", + "101": "y x", + "102": "y x", + "103": "y x", + "104": "y x", + "105": "y x", + "106": "y x", + "107": "y x", + "108": "y x", + "109": "y x", + "110": "y x", + "111": "y x", + "112": "y x", + "113": "y x", + "114": "y x", + "115": "y x", + "116": "y x", + "117": "y x", + "118": "y x", + "119": "y x", + "120": "y x", + "121": "y x", + "122": "y x", + "123": "y x", + "124": "y x", + "125": "y x", + "126": "y x", + "127": "y x", + "128": "y x", + "129": "y x", + "130": "y x", + "131": "y x", + "132": "y x", + "133": "y x", + "134": "y x", + "135": "y x", + "136": "y x", + "137": "y x", + "138": "y x", + "139": "y x", + "140": "y x", + "141": "y x", + "142": "y x", + "143": "y x", + "144": "y x", + "145": "y x", + "146": "y x", + "147": "y x", + "148": "y x", + "149": "y x", + "150": "y x", + "151": "y x", + "152": "y x", + "153": "y x", + "154": "y x", + "155": "y x", + "156": "y x", + "157": "y x", + "158": "y x", + "159": "y x", + "160": "y x", + "161": "y x" + }, + "safari": { + "3.1": "n", + "3.2": "n", + "4": "n", + "5": "y x", + "5.1": "y x", + "6": "y x", + "6.1": "y x", + "7": "y x", + "7.1": "y x", + "8": "y x", + "9": "y x", + "9.1": "y x", + "10": "y x", + "10.1": "y x", + "11": "y x", + "11.1": "y x", + "12": "y x", + "12.1": "y x", + "13": "y x", + "13.1": "y x", + "14": "y x", + "14.1": "y x", + "15": "y x", + "15.1": "y x", + "15.2-15.3": "y x", + "15.4": "y x", + "15.5": "y x", + "15.6": "y x", + "16.0": "y x", + "16.1": "y x", + "16.2": "y x", + "16.3": "y x", + "16.4": "y x", + "16.5": "y x", + "16.6": "y x", + "17.0": "y x", + "17.1": "y x", + "17.2": "y x", + "17.3": "y x", + "17.4": "y x", + "17.5": "y x", + "17.6": "y x", + "18.0": "y x", + "18.1": "y x", + "18.2": "y x", + "18.3": "y x", + "18.4": "y x", + "18.5-18.7": "y x", + "26.0": "y x", + "26.1": "y x", + "26.2": "y x", + "26.3": "y x", + "26.4": "y x", + "26.5": "y x", + "26.6": "y x", + "27": "y x", + "27.1": "y x", + "27.2": "y x", + "TP": "y x" + }, + "ios_saf": { + "3.2": "n", + "4.0-4.1": "n", + "4.2-4.3": "n", + "5.0-5.1": "y x", + "6.0-6.1": "y x", + "7.0-7.1": "y x", + "8": "y x", + "8.1-8.4": "y x", + "9.0-9.2": "y x", + "9.3": "y x", + "10.0-10.2": "y x", + "10.3": "y x", + "11.0-11.2": "y x", + "11.3-11.4": "y x", + "12.0-12.1": "y x", + "12.2-12.5": "y x", + "13.0-13.1": "y x", + "13.2": "y x", + "13.3": "y x", + "13.4-13.7": "y x", + "14.0-14.4": "y x", + "14.5-14.8": "y x", + "15.0-15.1": "y x", + "15.2-15.3": "y x", + "15.4": "y x", + "15.5": "y x", + "15.6-15.8": "y x", + "16.0": "y x", + "16.1": "y x", + "16.2": "y x", + "16.3": "y x", + "16.4": "y x", + "16.5": "y x", + "16.6-16.7": "y x", + "17.0": "y x", + "17.1": "y x", + "17.2": "y x", + "17.3": "y x", + "17.4": "y x", + "17.5": "y x", + "17.6-17.7": "y x", + "18.0": "y x", + "18.1": "y x", + "18.2": "y x", + "18.3": "y x", + "18.4": "y x", + "18.5-18.7": "y x", + "26.0": "y x", + "26.1": "y x", + "26.2": "y x", + "26.3": "y x", + "26.4": "y x", + "26.5": "y x", + "26.6": "y x", + "27.0": "y x", + "27.1": "y x", + "27.2": "y x" + }, + "samsung": { + "4": "y x", + "5.0-5.4": "y x", + "6.2-6.4": "y x", + "7.2-7.4": "y x", + "8.2": "y x", + "9.2": "y x", + "10.1": "y x", + "11.1-11.2": "y x", + "12.0": "y x", + "13.0": "y x", + "14.0": "y x", + "15.0": "y x", + "16.0": "y x", + "17.0": "y x", + "18.0": "y x", + "19.0": "y x", + "20": "y x", + "21": "y x", + "22": "y x", + "23": "y x", + "24": "y x", + "25": "y x", + "26": "y x", + "27": "y x", + "28": "y x", + "29": "y x", + "30": "y x" + }, + "and_chr": { "156": "y x" } + }, + "notes": "Older (presto-based) versions of the Opera browser have also supported the same effect using the proprietary `-o-ellipsis-lastline;` value for `text-overflow`.", + "notes_by_num": { "1": "Supported in MS Edge with `-webkit-` prefix only (not with `-ms-`)" }, + "usage_perc_y": 97.27, + "usage_perc_a": 0, + "ucprefix": false, + "parent": "", + "keywords": "", + "chrome_id": "" + } + } +} diff --git a/extensions/commit-helper/backslash.toml b/extensions/commit-helper/backslash.toml new file mode 100644 index 0000000..af0dbb1 --- /dev/null +++ b/extensions/commit-helper/backslash.toml @@ -0,0 +1,71 @@ +[extension] +id = "commit-helper" +title = "Commit Helper" +description = "Write Conventional Commits messages with scopes learned from your history, gitmoji, and commit the staged changes" +version = "1.0.0" +author = "Backslash" +icon = "bs-commit" +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 for repositories (which a fixed +# read list couldn't cover); git reads their history and staged changes, and +# commits when you ask. +run = ["git", "find"] + +[[commands]] +name = "compose" +title = "Compose Commit Message" +description = "Type, scope, description, body and footers; then copy the message or commit the staged changes" +icon = "bs-commit" +keywords = [ + "commit", + "conventional", + "conventional commits", + "git", + "message", + "feat", + "fix", + "changelog", + "semver", + "scope", + "message de commit", + "valider", + "commiter", +] + +[[commands]] +name = "gitmoji" +title = "Search Gitmoji" +description = "The full gitmoji table: copy or paste an emoji or its :code:" +icon = "bs-smile" +keywords = ["gitmoji", "emoji", "commit", "git", "emoticon", "émoji", "émoticône"] + +[[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 = "gitmoji" +title = "Always suggest a gitmoji" +description = "Preselect the type's gitmoji even in repositories that don't use them" +type = "boolean" +default = false + +[[preferences]] +name = "gitmojiStyle" +title = "Gitmoji style" +description = "emoji (✨) or code (:sparkles:); repositories writing codes get codes anyway" +default = "emoji" diff --git a/extensions/commit-helper/commands/compose.ts b/extensions/commit-helper/commands/compose.ts new file mode 100644 index 0000000..2d5b2f7 --- /dev/null +++ b/extensions/commit-helper/commands/compose.ts @@ -0,0 +1,359 @@ +import { + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + field, + form, + type FormSpec, + Icons, + type Item, + list, + meta, + open, + paste, + pop, + refresh, + replace, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + git, + GitMissing, + HOME, + lastRepo, + type Prefs, + rememberRepo, + type RepoInfo, + repoInfo, + scanRepos, +} from "../git.ts"; +import { + buildMessage, + commitCommand, + type CommitType, + commitType, + type Draft, + gitmoji, + gitmojisFor, + header, + installCommand, + lint, + parseShortstat, + pickRepo, + type RepoState, + suggestScopes, + tilde, + TYPES, + usesGitmoji, +} from "../lib.ts"; + +const name = (path: string) => path.split("/").pop() || path; +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +function stagedSummary(info: RepoInfo): string { + if (!info.staged.length) return "nothing staged"; + return `${ + plural(info.stat.files || info.staged.length, "file") + } staged, +${info.stat.insertions} −${info.stat.deletions}`; +} + +// --------------------------------------------------------------------------- +// Step 3: the message, the staged changes, commit + +export async function review(draft: Draft, info: RepoInfo | undefined): Promise { + const message = buildMessage(draft); + const warnings = lint(draft, info?.history); + const errors = warnings.filter((w) => w.level === "error"); + let stat = ""; + let staged = info?.stat; + if (info) { + const [s, short] = await Promise.all([ + git(info.path, ["diff", "--cached", "--stat=80"]).catch(() => ""), + git(info.path, ["diff", "--cached", "--shortstat"]).catch(() => ""), + ]); + stat = s; + staged = parseShortstat(short); + } + const subject = header(draft); + const t = commitType(draft.type); + const g = draft.gitmoji ? gitmoji(draft.gitmoji) : undefined; + + const markdown = [ + "```", + message.trimEnd(), + "```", + ...(warnings.length ? ["", ...warnings.map((w) => `> ${w.level === "error" ? "Error" : "Note"}: ${w.text}`)] : []), + "", + ...(info + ? stat ? ["**Staged changes**", "", "```", stat, "```"] : [ + "**Nothing is staged.** Stage what goes in this commit with `git add`, or use “Stage Tracked Changes” (git add -u).", + ] + : ["_No repository: copy the message and commit yourself._"]), + ].join("\n"); + + const actions: Action[] = [copy(message, "Copy Message")]; + if (info && staged?.files && !errors.length) { + actions.push( + action("Commit Staged Changes", async () => { + await git(info.path, ["commit", "--file=-", "--cleanup=strip"], { stdin: message }); + const hash = await git(info.path, ["log", "-1", "--format=%h"]).catch(() => ""); + await rememberRepo(info.path); + await toast(`Committed ${hash}`, { style: "success", message: subject }); + return close(`Committed ${hash} on ${info.branch}`); + }, { + icon: Icons.commit, + confirm: `Commit ${plural(staged.files, "file")} to ${info.branch} in ${name(info.path)}?`, + }), + ); + } + if (info && !staged?.files) { + actions.push( + action("Stage Tracked Changes", async () => { + await git(info.path, ["add", "--update"]); + return replace(await review(draft, await repoInfo(info.path))); + }, { icon: Icons.plus, confirm: "Stage every modified and deleted tracked file (git add -u)?" }), + ); + } + actions.push( + paste(message, "Paste Message", { section: "Copy" }), + copy(subject, "Copy Subject Line", { section: "Copy", shortcut: "c" }), + copy(commitCommand(message), "Copy git commit Command", { icon: Icons.terminal, section: "Copy" }), + action("Edit Message", () => pop(), { icon: Icons.note, shortcut: "e", section: "Message" }), + ...(info ? [open(info.path, "Open Repository Folder", { icon: Icons.folder, section: "Message" })] : []), + ); + + return detail({ + title: subject, + markdown, + stats: [ + { label: "Subject", value: `${[...subject].length}/72`, color: [...subject].length > 72 ? "red" : undefined }, + ...(staged + ? [ + { label: "Files", value: staged.files }, + { label: "Insertions", value: `+${staged.insertions}`, color: "green" as const }, + { label: "Deletions", value: `−${staged.deletions}`, color: "red" as const }, + ] + : []), + ], + status: info ? `${tilde(info.path, HOME)} · ${info.branch}` : "No repository", + metadata: [ + meta.tags("Type", [{ text: draft.type, color: t?.tint }]), + ...(draft.scope ? [meta.text("Scope", draft.scope)] : []), + ...(draft.breaking ? [meta.tags("Breaking", [{ text: "Breaking change", color: "red" }])] : []), + ...(g ? [meta.text("Gitmoji", `${g.emoji} :${g.code}:`)] : []), + ...(info + ? [ + meta.separator(), + meta.text("Repository", tilde(info.path, HOME), { icon: Icons.repository }), + meta.text("Branch", info.branch, { icon: Icons.branch }), + ] + : []), + ], + actions, + }); +} + +// --------------------------------------------------------------------------- +// Step 2: the form + +export function composeForm(t: CommitType, info: RepoInfo | undefined, prefs: Prefs): FormSpec { + const scopes = info ? suggestScopes(info.staged, info.history, t.type) : []; + const gitmojiOn = prefs.gitmoji === true || (info ? usesGitmoji(info.history) : false); + const style = prefs.gitmojiStyle?.trim() === "code" || + (info && info.history.withGitmoji > 0 && info.history.gitmojiAsCode / info.history.withGitmoji > 0.5) + ? "code" + : "emoji"; + return form({ + title: `${t.type}: ${t.title}`, + submitTitle: "Review", + fields: [ + field.dropdown("scope", "Scope", [ + { value: "", title: "No scope" }, + ...scopes.map((s) => ({ value: s.scope, title: `${s.scope} · ${s.reason}` })), + ], { value: scopes.find((s) => s.staged)?.scope ?? "" }), + field.text("customScope", "Other scope", { placeholder: "Overrides the scope above" }), + field.text("description", "Description", { required: true, placeholder: t.placeholder }), + field.textArea("body", "Body", { placeholder: "Why this change, and what it does (wrapped at 72 columns)" }), + field.checkbox("breaking", "Breaking change"), + field.text("breakingNote", "What breaks", { placeholder: "The BREAKING CHANGE footer, if any" }), + field.textArea("footers", "Footers", { placeholder: "Closes #123\nCo-authored-by: Name " }), + field.dropdown("gitmoji", "Gitmoji", [ + { value: "", title: "None" }, + ...gitmojisFor(t.type).map((g) => ({ value: g.code, title: `${g.emoji} ${g.description}` })), + ], { value: gitmojiOn ? t.gitmoji : "" }), + ], + async submit(v) { + const draft: Draft = { + type: t.type, + scope: String(v.customScope ?? "").trim() || String(v.scope ?? ""), + description: String(v.description ?? ""), + body: String(v.body ?? ""), + breaking: v.breaking === true, + breakingNote: String(v.breakingNote ?? ""), + footers: String(v.footers ?? ""), + gitmoji: String(v.gitmoji ?? "") || undefined, + gitmojiStyle: style, + }; + if (draft.breakingNote?.trim()) draft.breaking = true; + return await review(draft, info); + }, + }); +} + +// --------------------------------------------------------------------------- +// Step 1: the type, in a repository + +function typeItem(t: CommitType, info: RepoInfo | undefined, prefs: Prefs): Item { + const used = info?.history.types[t.type] ?? 0; + const scopes = info?.history.scopes.filter((s) => s.types.includes(t.type)).slice(0, 6).map((s) => s.scope) ?? []; + const g = gitmoji(t.gitmoji)!; + return { + id: t.type, + title: t.type, + subtitle: t.description, + icon: { source: t.icon, tint: t.tint }, + section: "Type", + keywords: [t.title, ...t.title.split(" ")], + accessories: [ + ...(used ? [{ text: `${used}`, tooltip: `${used} of the last 200 commits` }] : []), + { text: g.emoji, tooltip: `:${g.code}:` }, + ], + detail: { + markdown: [ + `**${t.title}**: ${t.description}.`, + "", + "```", + `${t.type}${scopes[0] ? `(${scopes[0]})` : ""}: ${t.placeholder}`, + "```", + ].join("\n"), + metadata: [ + meta.text("Gitmoji", `${g.emoji} :${g.code}:`), + ...(info ? [meta.text("In history", `${used} of the last ${info.history.total} commits`)] : []), + ...(scopes.length ? [meta.tags("Scopes used", scopes)] : []), + ], + }, + actions: [action("Compose", () => composeForm(t, info, prefs), { icon: Icons.note })], + }; +} + +function repoItem(info: RepoInfo | undefined, repos: RepoState[], roots: string): Item { + if (!info) { + return { + id: "repo", + title: "No repository found", + subtitle: `Set the folders to scan in Settings → Extensions → Commit Helper (${roots})`, + icon: { source: Icons.repository, tint: "gray" }, + section: "Repository", + actions: [], + }; + } + const h = info.history; + return { + id: "repo", + title: name(info.path), + subtitle: tilde(info.path, HOME), + icon: { source: Icons.repository, tint: info.staged.length ? "green" : "gray" }, + section: "Repository", + accessories: [ + { icon: Icons.branch, text: info.branch }, + { + tag: info.staged.length ? `${info.staged.length} staged` : "Nothing staged", + color: info.staged.length ? "green" : "gray", + }, + ], + detail: { + stats: [ + { label: "Staged", value: info.staged.length, color: info.staged.length ? "green" : undefined }, + { label: "Conventional", value: h.total ? `${Math.round((h.conventional / h.total) * 100)} %` : "–" }, + { label: "Gitmoji", value: h.total ? `${Math.round((h.withGitmoji / h.total) * 100)} %` : "–" }, + ], + markdown: info.staged.length + ? [ + "**Staged files**", + "", + ...info.staged.slice(0, 25).map((f) => `- \`${f}\``), + ...(info.staged.length > 25 ? [`- … ${info.staged.length - 25} more`] : []), + ].join("\n") + : "Nothing is staged yet: `git add` what goes in the commit.", + metadata: [ + meta.text("Branch", info.branch, { icon: Icons.branch }), + meta.text("Changes", stagedSummary(info)), + ...(h.scopes.length ? [meta.tags("Scopes", h.scopes.slice(0, 8).map((s) => s.scope))] : []), + ], + }, + actions: [ + ...(repos.length > 1 ? [chooseRepo(repos, info.path)] : []), + open(info.path, "Open Folder", { icon: Icons.folder }), + ], + }; +} + +function chooseRepo(repos: RepoState[], current?: string): Action { + return submenu("Change Repository", () => + [...repos] + .sort((a, b) => Number(b.staged > 0) - Number(a.staged > 0) || b.lastCommit - a.lastCommit) + .map((r) => + action( + `${name(r.path)}${r.staged ? ` · ${r.staged} staged` : ""}${r.path === current ? " ✓" : ""}`, + async () => { + await rememberRepo(r.path); + return refresh(); + }, + { icon: Icons.repository }, + ) + ), { icon: Icons.repository, shortcut: "r" }); +} + +let status = ""; + +export default (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + let repos: RepoState[] = []; + return list({ + placeholder: "Pick the commit type…", + status: () => status, + actions: [ + action("Refresh", () => refresh(), { icon: Icons.refresh }), + ], + async items({ signal }) { + let info: RepoInfo | undefined; + try { + repos = await scanRepos(prefs, signal); + } catch (e) { + if (!(e instanceof GitMissing)) throw e; + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const install = installCommand(os); + status = "git is not installed"; + return [{ + id: "missing", + title: "git is not installed", + subtitle: install, + icon: { source: Icons.alert, tint: "orange" }, + actions: [copy(install, "Copy Install Command")], + }]; + } + const picked = pickRepo(repos, await lastRepo()); + if (picked) info = await repoInfo(picked.path, signal); + status = info + ? `${name(info.path)} · ${info.branch} · ${stagedSummary(info)}` + : "No repository: messages can still be copied"; + updateCommandMetadata({ + subtitle: info?.staged.length ? `${info.staged.length} staged in ${name(info.path)}` : null, + }) + .catch(() => {}); + return [ + ...TYPES.map((t) => { + const item = typeItem(t, info, prefs); + return repos.length > 1 ? { ...item, actions: [...item.actions!, chooseRepo(repos, info?.path)] } : item; + }), + repoItem(info, repos, prefs.folders?.trim() || "~/Projects, ~/code…"), + ]; + }, + }); +}; diff --git a/extensions/commit-helper/commands/gitmoji.ts b/extensions/commit-helper/commands/gitmoji.ts new file mode 100644 index 0000000..0ec6adc --- /dev/null +++ b/extensions/commit-helper/commands/gitmoji.ts @@ -0,0 +1,41 @@ +import { copy, Icons, type Item, list, meta, paste } from "@backslash/api"; +import { gitmojiKeywords, GITMOJIS, TYPES } from "../lib.ts"; + +const SEMVER = { major: "red", minor: "orange", patch: "green" } as const; + +export default list({ + placeholder: "Search gitmojis by meaning, code or commit type…", + status: (items) => `${items.length} of ${GITMOJIS.length} gitmojis · Enter copies the emoji`, + items: GITMOJIS.map((g): Item => { + const types = TYPES.filter((t) => t.gitmoji === g.code || t.alsoGitmojis.includes(g.code)).map((t) => t.type); + return { + id: g.code, + title: g.description, + subtitle: `:${g.code}:`, + // The emoji is the content itself. + icon: g.emoji, + keywords: gitmojiKeywords(g), + accessories: [ + ...types.slice(0, 2).map((t) => ({ text: t })), + ...(g.semver ? [{ tag: g.semver, color: SEMVER[g.semver], tooltip: "Version bump" }] : []), + ], + detail: { + markdown: [`# ${g.emoji}`, "", g.description, "", "```", `${g.emoji} ${types[0] ?? "chore"}: …`, "```"].join( + "\n", + ), + metadata: [ + meta.text("Code", `:${g.code}:`, { icon: Icons.hash }), + ...(types.length ? [meta.tags("Commit types", types)] : []), + ...(g.semver ? [meta.tags("Semver", [{ text: g.semver, color: SEMVER[g.semver] }])] : []), + meta.link("Gitmoji", "gitmoji.dev", "https://gitmoji.dev"), + ], + }, + actions: [ + copy(g.emoji, "Copy Emoji"), + copy(`:${g.code}:`, "Copy Code"), + paste(g.emoji, "Paste Emoji", { section: "Paste" }), + paste(`:${g.code}:`, "Paste Code", { section: "Paste" }), + ], + }; + }), +}); diff --git a/extensions/commit-helper/git.ts b/extensions/commit-helper/git.ts new file mode 100644 index 0000000..29f410b --- /dev/null +++ b/extensions/commit-helper/git.ts @@ -0,0 +1,121 @@ +// Running git and find. Never asks for credentials or an editor: a prompt would hang the launcher. + +import { storage } from "@backslash/api"; +import { + findArgs, + type History, + learnHistory, + parseFolders, + parseNames, + parseShortstat, + reposFromFind, + type RepoState, + type Shortstat, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +export class GitMissing extends Error {} + +const ENV = { GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true", LC_ALL: "C" }; + +export async function run( + program: string, + args: string[], + options: { signal?: AbortSignal; allowFailure?: boolean; stdin?: string } = {}, +): Promise { + let out: Deno.CommandOutput; + try { + const child = new Deno.Command(program, { + args, + stdin: options.stdin === undefined ? "null" : "piped", + stdout: "piped", + stderr: "piped", + signal: options.signal, + env: ENV, + }).spawn(); + if (options.stdin !== undefined) { + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(options.stdin)); + await writer.close(); + } + out = await child.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, 6).join("\n")); + } + return text(out.stdout); +} + +export const git = (dir: string, args: string[], options: { signal?: AbortSignal; stdin?: string } = {}) => + run("git", ["-C", dir, ...args], options); + +export interface Prefs { + folders?: string; + depth?: number; + gitmoji?: boolean; + gitmojiStyle?: string; +} + +/** 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 repository in the configured folders, with its staged file count and last commit time. */ +export async function scanRepos(prefs: Prefs, signal?: AbortSignal): Promise { + const roots = parseFolders(prefs.folders, HOME); + // find exits with 1 when a folder doesn't exist; what it found is still good. + const paths = reposFromFind( + await run("find", findArgs(roots, Number(prefs.depth ?? 3)), { signal, allowFailure: true }), + ); + return await pool(paths, 8, async (path) => { + const [names, last] = await Promise.all([ + git(path, ["diff", "--cached", "--name-only", "-z"], { signal }).catch(() => ""), + git(path, ["log", "-1", "--format=%ct"], { signal }).catch(() => "0"), + ]); + return { path, staged: parseNames(names).length, lastCommit: Number(last) || 0 }; + }); +} + +export interface RepoInfo { + path: string; + branch: string; + staged: string[]; + stat: Shortstat; + history: History; +} + +export async function repoInfo(path: string, signal?: AbortSignal): Promise { + const [branch, names, shortstat, subjects] = await Promise.all([ + git(path, ["branch", "--show-current"], { signal }).catch(() => ""), + git(path, ["diff", "--cached", "--name-only", "-z"], { signal }).catch(() => ""), + git(path, ["diff", "--cached", "--shortstat"], { signal }).catch(() => ""), + git(path, ["log", "--format=%s", "-n", "200"], { signal }).catch(() => ""), + ]); + return { + path, + branch: branch || "(detached HEAD)", + staged: parseNames(names), + stat: parseShortstat(shortstat), + history: learnHistory(subjects.split("\n")), + }; +} + +const LAST_REPO = "lastRepo"; + +export const lastRepo = () => storage.get(LAST_REPO).catch(() => undefined); +export const rememberRepo = (path: string) => storage.set(LAST_REPO, path).catch(() => {}); diff --git a/extensions/commit-helper/lib.ts b/extensions/commit-helper/lib.ts new file mode 100644 index 0000000..1e5d8ae --- /dev/null +++ b/extensions/commit-helper/lib.ts @@ -0,0 +1,602 @@ +// Commit Helper: Conventional Commits types, messages, scopes learned from history, and the gitmoji table. +// Pure, tested in lib_test.ts. + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export interface CommitType { + type: string; + title: string; + description: string; + /** Its usual gitmoji (code without colons). */ + gitmoji: string; + /** More gitmojis that fit, best first. */ + alsoGitmojis: string[]; + tint: Tint; + icon: string; + placeholder: string; +} + +export const TYPES: CommitType[] = [ + { + type: "feat", + title: "Feature", + description: "A new feature", + gitmoji: "sparkles", + alsoGitmojis: ["tada", "lipstick", "chart_with_upwards_trend", "globe_with_meridians", "iphone", "dizzy"], + tint: "green", + icon: "bs-sparkle", + placeholder: "add dark mode to the settings page", + }, + { + type: "fix", + title: "Bug Fix", + description: "A bug fix", + gitmoji: "bug", + alsoGitmojis: ["ambulance", "adhesive_bandage", "lock", "pencil2", "goal_net", "green_heart", "alien"], + tint: "red", + icon: "bs-bug", + placeholder: "handle empty responses from the API", + }, + { + type: "docs", + title: "Documentation", + description: "Documentation only", + gitmoji: "memo", + alsoGitmojis: ["bulb", "page_facing_up", "pencil2", "busts_in_silhouette"], + tint: "blue", + icon: "bs-book", + placeholder: "explain the configuration options", + }, + { + type: "refactor", + title: "Refactor", + description: "A change that neither fixes a bug nor adds a feature", + gitmoji: "recycle", + alsoGitmojis: ["art", "truck", "fire", "coffin", "building_construction", "label"], + tint: "purple", + icon: "bs-cycle", + placeholder: "split the parser into smaller functions", + }, + { + type: "perf", + title: "Performance", + description: "A change that improves performance", + gitmoji: "zap", + alsoGitmojis: ["thread"], + tint: "orange", + icon: "bs-bolt", + placeholder: "cache the parsed configuration", + }, + { + type: "test", + title: "Tests", + description: "Adding missing tests or correcting existing ones", + gitmoji: "white_check_mark", + alsoGitmojis: ["test_tube", "camera_flash", "clown_face"], + tint: "teal", + icon: "bs-square-check", + placeholder: "cover the date parser edge cases", + }, + { + type: "build", + title: "Build", + description: "The build system or external dependencies", + gitmoji: "package", + alsoGitmojis: ["arrow_up", "arrow_down", "heavy_plus_sign", "heavy_minus_sign", "pushpin", "wrench", "bricks"], + tint: "yellow", + icon: "bs-package", + placeholder: "upgrade to TypeScript 6", + }, + { + type: "ci", + title: "Continuous Integration", + description: "CI configuration files and scripts", + gitmoji: "construction_worker", + alsoGitmojis: ["green_heart", "rocket", "wrench"], + tint: "blue", + icon: "bs-rocket", + placeholder: "run the tests on pull requests", + }, + { + type: "chore", + title: "Chore", + description: "Other changes that don't modify source or test files", + gitmoji: "wrench", + alsoGitmojis: ["hammer", "see_no_evil", "bookmark", "rotating_light", "busts_in_silhouette", "page_facing_up"], + tint: "gray", + icon: "bs-settings", + placeholder: "update the editor settings", + }, + { + type: "revert", + title: "Revert", + description: "Reverts a previous commit", + gitmoji: "rewind", + alsoGitmojis: [], + tint: "pink", + icon: "bs-history", + placeholder: '"feat: add dark mode"', + }, +]; + +export const commitType = (type: string) => TYPES.find((t) => t.type === type); + +// --------------------------------------------------------------------------- +// Gitmoji (gitmoji.dev, MIT) + +export interface Gitmoji { + emoji: string; + code: string; + description: string; + /** The version bump it implies, when gitmoji says so. */ + semver?: "major" | "minor" | "patch"; +} + +const G = (emoji: string, code: string, description: string, semver?: Gitmoji["semver"]): Gitmoji => ({ + emoji, + code, + description, + ...(semver ? { semver } : {}), +}); + +export const GITMOJIS: Gitmoji[] = [ + G("🎨", "art", "Improve structure / format of the code"), + G("⚡️", "zap", "Improve performance", "patch"), + G("🔥", "fire", "Remove code or files"), + G("🐛", "bug", "Fix a bug", "patch"), + G("🚑️", "ambulance", "Critical hotfix", "patch"), + G("✨", "sparkles", "Introduce new features", "minor"), + G("📝", "memo", "Add or update documentation"), + G("🚀", "rocket", "Deploy stuff"), + G("💄", "lipstick", "Add or update the UI and style files", "patch"), + G("🎉", "tada", "Begin a project"), + G("✅", "white_check_mark", "Add, update, or pass tests"), + G("🔒️", "lock", "Fix security or privacy issues", "patch"), + G("🔐", "closed_lock_with_key", "Add or update secrets"), + G("🔖", "bookmark", "Release / Version tags"), + G("🚨", "rotating_light", "Fix compiler / linter warnings"), + G("🚧", "construction", "Work in progress"), + G("💚", "green_heart", "Fix CI Build"), + G("⬇️", "arrow_down", "Downgrade dependencies", "patch"), + G("⬆️", "arrow_up", "Upgrade dependencies", "patch"), + G("📌", "pushpin", "Pin dependencies to specific versions", "patch"), + G("👷", "construction_worker", "Add or update CI build system"), + G("📈", "chart_with_upwards_trend", "Add or update analytics or track code", "patch"), + G("♻️", "recycle", "Refactor code"), + G("➕", "heavy_plus_sign", "Add a dependency", "patch"), + G("➖", "heavy_minus_sign", "Remove a dependency", "patch"), + G("🔧", "wrench", "Add or update configuration files", "patch"), + G("🔨", "hammer", "Add or update development scripts"), + G("🌐", "globe_with_meridians", "Internationalization and localization", "patch"), + G("✏️", "pencil2", "Fix typos", "patch"), + G("💩", "poop", "Write bad code that needs to be improved"), + G("⏪️", "rewind", "Revert changes", "patch"), + G("🔀", "twisted_rightwards_arrows", "Merge branches"), + G("📦️", "package", "Add or update compiled files or packages", "patch"), + G("👽️", "alien", "Update code due to external API changes", "patch"), + G("🚚", "truck", "Move or rename resources (e.g.: files, paths, routes)"), + G("📄", "page_facing_up", "Add or update license"), + G("💥", "boom", "Introduce breaking changes", "major"), + G("🍱", "bento", "Add or update assets", "patch"), + G("♿️", "wheelchair", "Improve accessibility", "patch"), + G("💡", "bulb", "Add or update comments in source code"), + G("🍻", "beers", "Write code drunkenly"), + G("💬", "speech_balloon", "Add or update text and literals", "patch"), + G("🗃️", "card_file_box", "Perform database related changes", "patch"), + G("🔊", "loud_sound", "Add or update logs"), + G("🔇", "mute", "Remove logs"), + G("👥", "busts_in_silhouette", "Add or update contributor(s)"), + G("🚸", "children_crossing", "Improve user experience / usability", "patch"), + G("🏗️", "building_construction", "Make architectural changes"), + G("📱", "iphone", "Work on responsive design", "patch"), + G("🤡", "clown_face", "Mock things"), + G("🥚", "egg", "Add or update an easter egg", "patch"), + G("🙈", "see_no_evil", "Add or update a .gitignore file"), + G("📸", "camera_flash", "Add or update snapshots"), + G("⚗️", "alembic", "Perform experiments", "patch"), + G("🔍️", "mag", "Improve SEO", "patch"), + G("🏷️", "label", "Add or update types", "patch"), + G("🌱", "seedling", "Add or update seed files"), + G("🚩", "triangular_flag_on_post", "Add, update, or remove feature flags", "patch"), + G("🥅", "goal_net", "Catch errors", "patch"), + G("💫", "dizzy", "Add or update animations and transitions", "patch"), + G("🗑️", "wastebasket", "Deprecate code that needs to be cleaned up", "patch"), + G("🛂", "passport_control", "Work on code related to authorization, roles and permissions", "patch"), + G("🩹", "adhesive_bandage", "Simple fix for a non-critical issue", "patch"), + G("🧐", "monocle_face", "Data exploration/inspection"), + G("⚰️", "coffin", "Remove dead code"), + G("🧪", "test_tube", "Add a failing test"), + G("👔", "necktie", "Add or update business logic", "patch"), + G("🩺", "stethoscope", "Add or update healthcheck"), + G("🧱", "bricks", "Infrastructure related changes"), + G("🧑‍💻", "technologist", "Improve developer experience"), + G("💸", "money_with_wings", "Add sponsorships or money related infrastructure"), + G("🧵", "thread", "Add or update code related to multithreading or concurrency"), + G("🦺", "safety_vest", "Add or update code related to validation"), + G("✈️", "airplane", "Improve offline support"), + G("🦖", "t-rex", "Code that adds backwards compatibility"), +]; + +export const gitmoji = (code: string) => GITMOJIS.find((g) => g.code === code); + +/** The type's gitmojis first, then the rest. */ +export function gitmojisFor(type: string): Gitmoji[] { + const t = commitType(type); + const first = t ? [t.gitmoji, ...t.alsoGitmojis] : []; + const picked = first.map(gitmoji).filter((g): g is Gitmoji => !!g); + return [...picked, ...GITMOJIS.filter((g) => !first.includes(g.code))]; +} + +/** Extra search words for a gitmoji: its code split, and the commit types it fits. */ +export function gitmojiKeywords(g: Gitmoji): string[] { + const types = TYPES.filter((t) => t.gitmoji === g.code || t.alsoGitmojis.includes(g.code)).map((t) => t.type); + return [...g.code.split(/[_-]/), g.code, ...types, ...(g.semver ? [g.semver] : [])]; +} + +// --------------------------------------------------------------------------- +// Parsing subjects + +export interface Subject { + type: string; + scope?: string; + breaking: boolean; + description: string; + /** A leading gitmoji, as an emoji or a :code:. */ + gitmoji?: string; +} + +const EMOJI = /^(\p{Extended_Pictographic}[\u{FE0F}\u{200D}\p{Extended_Pictographic}]*|:[a-z0-9_+-]+:)\s*/u; + +/** `✨ feat(api)!: add x` → its parts; undefined when it isn't a conventional commit. */ +export function parseSubject(subject: string): Subject | undefined { + let s = subject.trim(); + let mark: string | undefined; + const lead = s.match(EMOJI); + if (lead) { + mark = lead[1]; + s = s.slice(lead[0].length); + } + const m = s.match(/^([a-zA-Z]+)(?:\(([^()]*)\))?(!)?: +(\S.*)$/); + if (!m) return undefined; + return { + type: m[1].toLowerCase(), + ...(m[2]?.trim() ? { scope: m[2].trim() } : {}), + breaking: !!m[3], + description: m[4].trim(), + ...(mark ? { gitmoji: mark } : {}), + }; +} + +export interface History { + total: number; + conventional: number; + /** How many subjects start with a gitmoji. */ + withGitmoji: number; + /** Gitmojis written as `:code:` rather than emoji. */ + gitmojiAsCode: number; + /** Descriptions starting with a lowercase letter, among conventional ones. */ + lowercase: number; + types: Record; + /** Scopes, most used first. */ + scopes: { scope: string; count: number; types: string[] }[]; +} + +/** What `git log --format=%s` says about the repository's habits. */ +export function learnHistory(subjects: string[]): History { + const h: History = { + total: 0, + conventional: 0, + withGitmoji: 0, + gitmojiAsCode: 0, + lowercase: 0, + types: {}, + scopes: [], + }; + const scopes = new Map }>(); + for (const line of subjects) { + const s = line.trim(); + if (!s) continue; + h.total++; + const lead = s.match(EMOJI); + if (lead) { + h.withGitmoji++; + if (lead[1].startsWith(":")) h.gitmojiAsCode++; + } + const p = parseSubject(s); + if (!p || !TYPES.some((t) => t.type === p.type) && !["style", "wip", "release"].includes(p.type)) continue; + h.conventional++; + h.types[p.type] = (h.types[p.type] ?? 0) + 1; + if (/^[a-z]/.test(p.description)) h.lowercase++; + if (p.scope) { + for (const scope of p.scope.split(/\s*,\s*/).filter(Boolean)) { + const e = scopes.get(scope) ?? { count: 0, types: new Set() }; + e.count++; + e.types.add(p.type); + scopes.set(scope, e); + } + } + } + h.scopes = [...scopes.entries()] + .map(([scope, e]) => ({ scope, count: e.count, types: [...e.types] })) + .sort((a, b) => b.count - a.count || a.scope.localeCompare(b.scope)); + return h; +} + +/** Whether the repository mostly writes gitmojis (to preselect one). */ +export const usesGitmoji = (h: History) => h.total >= 5 && h.withGitmoji / h.total >= 0.3; + +/** Folders whose children are the natural scopes: `extensions/git-repos/…` → `git-repos`. */ +const CONTAINERS = new Set([ + "extensions", + "packages", + "apps", + "crates", + "libs", + "lib", + "modules", + "services", + "plugins", + "components", + "cmd", + "internal", + "pkg", +]); + +/** Scope candidates from the staged paths, most touched first. */ +export function scopesFromPaths(paths: string[]): { scope: string; files: number }[] { + const counts = new Map(); + for (const path of paths) { + const parts = path.split("/").filter(Boolean); + if (parts.length < 2) continue; + let i = parts[0] === "src" && parts.length > 2 ? 1 : 0; + if (CONTAINERS.has(parts[i]) && parts.length > i + 2) i++; + const scope = parts[i].replace(/^\./, ""); + if (!scope) continue; + counts.set(scope, (counts.get(scope) ?? 0) + 1); + } + return [...counts.entries()].map(([scope, files]) => ({ scope, files })) + .sort((a, b) => b.files - a.files || a.scope.localeCompare(b.scope)); +} + +export interface ScopeSuggestion { + scope: string; + /** Why it's suggested: "3 staged files", "used 12 times". */ + reason: string; + staged: boolean; +} + +/** Staged folders first (the likely scope), then the history's scopes. */ +export function suggestScopes(staged: string[], history: History, type?: string, max = 25): ScopeSuggestion[] { + const out: ScopeSuggestion[] = []; + const seen = new Set(); + const known = new Map(history.scopes.map((s) => [s.scope, s])); + // Most touched first; on a tie, the one the history knows. + const fromPaths = scopesFromPaths(staged).sort((a, b) => + b.files - a.files || (known.get(b.scope)?.count ?? 0) - (known.get(a.scope)?.count ?? 0) + ); + for (const { scope, files } of fromPaths) { + seen.add(scope); + const used = known.get(scope)?.count; + out.push({ + scope, + reason: `${files} staged file${files === 1 ? "" : "s"}${ + used ? `, used ${used} time${used === 1 ? "" : "s"}` : "" + }`, + staged: true, + }); + } + // Scopes used with this type come first among the rest. + const rest = [...history.scopes].sort((a, b) => + Number(!!type && b.types.includes(type)) - Number(!!type && a.types.includes(type)) || b.count - a.count + ); + for (const s of rest) { + if (seen.has(s.scope)) continue; + seen.add(s.scope); + out.push({ scope: s.scope, reason: `used ${s.count} time${s.count === 1 ? "" : "s"}`, staged: false }); + } + return out.slice(0, max); +} + +// --------------------------------------------------------------------------- +// Building messages + +export interface Draft { + type: string; + scope?: string; + description: string; + body?: string; + breaking?: boolean; + /** What breaks: becomes the `BREAKING CHANGE:` footer. */ + breakingNote?: string; + /** One per line: `Closes #12`, `Co-authored-by: …`. */ + footers?: string; + /** Gitmoji code (without colons). */ + gitmoji?: string; + gitmojiStyle?: "emoji" | "code"; +} + +/** Wraps prose paragraphs at `width`; lists, quotes, indented and long-word lines are kept. */ +export function wrap(text: string, width = 72): string { + return text.replace(/\r\n?/g, "\n").split("\n").map((line) => { + if (line.length <= width || /^\s*([-*+>]|\d+[.)])\s|^\s{2,}|^```/.test(line)) return line; + const out: string[] = []; + let cur = ""; + for (const word of line.split(/ +/)) { + if (cur && cur.length + 1 + word.length > width) { + out.push(cur); + cur = word; + } else cur = cur ? `${cur} ${word}` : word; + } + if (cur) out.push(cur); + return out.join("\n"); + }).join("\n"); +} + +export function header(d: Draft): string { + const g = d.gitmoji ? gitmoji(d.gitmoji) : undefined; + const mark = g ? `${d.gitmojiStyle === "code" ? `:${g.code}:` : g.emoji} ` : ""; + const scope = d.scope?.trim() ? `(${d.scope.trim()})` : ""; + return `${mark}${d.type}${scope}${d.breaking ? "!" : ""}: ${d.description.trim()}`; +} + +/** Normalizes footers to `Token: value` / `Token #value` lines. */ +export function footerLines(text: string | undefined): string[] { + return (text ?? "").split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { + // "closes #12" → "Closes #12" + const ref = l.match(/^(close[sd]?|fix(e[sd])?|resolve[sd]?|refs?|see)\s*:?\s*(#\d+.*)$/i); + if (ref) return `${ref[1][0].toUpperCase()}${ref[1].slice(1).toLowerCase()} ${ref[3]}`; + return l; + }); +} + +export function buildMessage(d: Draft): string { + const parts = [header(d)]; + const body = d.body?.trim(); + if (body) parts.push(wrap(body)); + const footers = footerLines(d.footers); + if (d.breaking && d.breakingNote?.trim()) footers.unshift(`BREAKING CHANGE: ${d.breakingNote.trim()}`); + if (footers.length) parts.push(footers.join("\n")); + return parts.join("\n\n") + "\n"; +} + +export interface Warning { + text: string; + level: "error" | "warning"; +} + +export function lint(d: Draft, history?: History): Warning[] { + const w: Warning[] = []; + const desc = d.description.trim(); + if (!desc) w.push({ text: "The description is empty", level: "error" }); + const h = header(d); + if ([...h].length > 72) { + w.push({ text: `The subject is ${[...h].length} characters: 72 at most reads best`, level: "warning" }); + } else if ([...h].length > 50) { + w.push({ text: `The subject is ${[...h].length} characters: 50 or fewer is ideal`, level: "warning" }); + } + if (/[.。]$/.test(desc)) w.push({ text: "The description shouldn't end with a period", level: "warning" }); + if (d.scope && /\s/.test(d.scope.trim())) w.push({ text: "Scopes usually have no spaces", level: "warning" }); + const mostlyLower = !history || history.conventional < 5 || history.lowercase / history.conventional >= 0.6; + if (/^[A-Z][a-z]/.test(desc) && mostlyLower) { + w.push({ text: "Descriptions usually start with a lowercase letter", level: "warning" }); + } + if (/^(added|adds|fixed|fixes|updated|updates|removed|removes)\b/i.test(desc)) { + w.push({ text: "Use the imperative mood: “add”, not “added” or “adds”", level: "warning" }); + } + if (d.breaking && !d.breakingNote?.trim()) { + w.push({ text: "Say what breaks in “What breaks” (the BREAKING CHANGE footer)", level: "warning" }); + } + if (d.type === "revert" && !/\b[0-9a-f]{7,40}\b/.test(`${d.body ?? ""} ${d.footers ?? ""}`)) { + w.push({ text: "Mention the reverted commit: “This reverts commit .”", level: "warning" }); + } + return w; +} + +// --------------------------------------------------------------------------- +// git output + +export interface Shortstat { + files: number; + insertions: number; + deletions: number; +} + +/** ` 3 files changed, 10 insertions(+), 2 deletions(-)` */ +export function parseShortstat(out: string): Shortstat { + const n = (re: RegExp) => Number(out.match(re)?.[1] ?? 0); + return { + files: n(/(\d+) files? changed/), + insertions: n(/(\d+) insertions?\(\+\)/), + deletions: n(/(\d+) deletions?\(-\)/), + }; +} + +/** NUL-separated `git diff --cached --name-only -z`. */ +export const parseNames = (out: string) => out.split("\0").map((s) => s.trim()).filter(Boolean); + +/** A POSIX shell word. */ +export const shellQuote = (s: string) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`; + +/** `git commit -m 'subject' -m 'body'…`, one -m per paragraph. */ +export function commitCommand(message: string): string { + const paragraphs = message.trim().split(/\n{2,}/); + return ["git commit", ...paragraphs.map((p) => `-m ${shellQuote(p)}`)].join(" "); +} + +// --------------------------------------------------------------------------- +// Finding repositories (the same scan as the Git Repos extension) + +export const DEFAULT_FOLDERS = "~/Projects, ~/projects, ~/code, ~/Code, ~/src, ~/dev, ~/git, ~/repos, ~/workspace"; + +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("/")); +} + +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", + ]; +} + +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)); +} + +export const tilde = (path: string, home: string) => + home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; + +export interface RepoState { + path: string; + staged: number; + /** Unix seconds of the last commit (0 for none). */ + lastCommit: number; +} + +/** The repository to start with: the last one used if it has staged changes, else the most recent with some. */ +export function pickRepo(repos: RepoState[], lastUsed?: string): RepoState | undefined { + const last = repos.find((r) => r.path === lastUsed); + if (last && last.staged) return last; + const staged = repos.filter((r) => r.staged).sort((a, b) => b.lastCommit - a.lastCommit); + return staged[0] ?? last ?? [...repos].sort((a, b) => b.lastCommit - a.lastCommit)[0]; +} + +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("nixos")) return "nix-env -iA nixos.git"; + return "Install git with your package manager"; +} diff --git a/extensions/commit-helper/lib_test.ts b/extensions/commit-helper/lib_test.ts new file mode 100644 index 0000000..5754c7d --- /dev/null +++ b/extensions/commit-helper/lib_test.ts @@ -0,0 +1,257 @@ +import { assert, assertEquals } from "@std/assert"; +import { + buildMessage, + commitCommand, + findArgs, + footerLines, + gitmojiKeywords, + GITMOJIS, + gitmojisFor, + header, + installCommand, + learnHistory, + lint, + parseFolders, + parseNames, + parseShortstat, + parseSubject, + pickRepo, + reposFromFind, + scopesFromPaths, + shellQuote, + suggestScopes, + tilde, + TYPES, + usesGitmoji, + wrap, +} from "./lib.ts"; + +// Real `git log --format=%s -n 200` lines from a conventional repository, trimmed. +const LOG = [ + "feat(git-repos): show ahead/behind counts", + "fix(git-repos): don't hang on credential prompts", + "feat(linear)!: switch to the GraphQL API", + "docs: explain the permissions", + "chore(deps): bump @std/assert to 1.0.19", + "fix(ssh): quote host names", + "Merge pull request #42 from someone/branch", + "refactor(sdk, cli): share the protocol types", + "✨ feat(weather): hourly forecast", + ":bug: fix(weather): wind units", + "feat: Initial version", + "wip: experiments", + "", +]; + +Deno.test("types and gitmojis are complete", () => { + assertEquals(TYPES.map((t) => t.type), [ + "feat", + "fix", + "docs", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", + ]); + assertEquals(GITMOJIS.length, 75); + assertEquals(new Set(GITMOJIS.map((g) => g.code)).size, GITMOJIS.length); + // Every type's gitmojis exist. + for (const t of TYPES) { + for (const code of [t.gitmoji, ...t.alsoGitmojis]) assert(GITMOJIS.some((g) => g.code === code), code); + } + assertEquals(gitmojisFor("fix").slice(0, 3).map((g) => g.code), ["bug", "ambulance", "adhesive_bandage"]); + assertEquals(gitmojisFor("fix").length, GITMOJIS.length); + assertEquals(gitmojisFor("nope")[0].code, "art"); + assertEquals(gitmojiKeywords(GITMOJIS.find((g) => g.code === "white_check_mark")!), [ + "white", + "check", + "mark", + "white_check_mark", + "test", + ]); +}); + +Deno.test("parseSubject reads conventional subjects", () => { + assertEquals(parseSubject("feat(api)!: add pagination"), { + type: "feat", + scope: "api", + breaking: true, + description: "add pagination", + }); + assertEquals(parseSubject("✨ feat: sparkle"), { + type: "feat", + breaking: false, + description: "sparkle", + gitmoji: "✨", + }); + assertEquals(parseSubject(":bug: fix(x): y")?.gitmoji, ":bug:"); + assertEquals(parseSubject("🧑‍💻 chore: dx")?.gitmoji, "🧑‍💻"); + assertEquals(parseSubject("Fix(): odd")?.scope, undefined); + assertEquals(parseSubject("Merge branch 'main'"), undefined); + assertEquals(parseSubject("feat:no space"), undefined); +}); + +Deno.test("learnHistory finds scopes and habits", () => { + const h = learnHistory(LOG); + assertEquals(h.total, 12); + assertEquals(h.conventional, 11); + assertEquals(h.withGitmoji, 2); + assertEquals(h.gitmojiAsCode, 1); + assertEquals(h.lowercase, 10); + assertEquals(h.types, { feat: 4, fix: 3, docs: 1, chore: 1, refactor: 1, wip: 1 }); + assertEquals(h.scopes.slice(0, 3), [ + { scope: "git-repos", count: 2, types: ["feat", "fix"] }, + { scope: "weather", count: 2, types: ["feat", "fix"] }, + { scope: "cli", count: 1, types: ["refactor"] }, + ]); + assert(h.scopes.some((s) => s.scope === "sdk")); + assert(!usesGitmoji(h)); + assert(usesGitmoji(learnHistory(["✨ feat: a", "🐛 fix: b", "📝 docs: c", "chore: d", "fix: e"]))); +}); + +Deno.test("scopes come from staged folders, then history", () => { + assertEquals( + scopesFromPaths([ + "extensions/git-repos/lib.ts", + "extensions/git-repos/commands/repos.ts", + "extensions/ssh/terminal.ts", + "README.md", + "src/parser/lexer.rs", + "docs/guide.md", + ".github/workflows/ci.yml", + "crates/core/src/lib.rs", + ]), + [ + { scope: "git-repos", files: 2 }, + { scope: "core", files: 1 }, + { scope: "docs", files: 1 }, + { scope: "github", files: 1 }, + { scope: "parser", files: 1 }, + { scope: "ssh", files: 1 }, + ], + ); + // A file directly in a container folder names the container. + assertEquals(scopesFromPaths(["packages/index.ts"]), [{ scope: "packages", files: 1 }]); + + const h = learnHistory(LOG); + const s = suggestScopes(["extensions/weather/lib.ts"], h, "refactor"); + assertEquals(s[0], { scope: "weather", reason: "1 staged file, used 2 times", staged: true }); + // Then the scopes used with this type. + assertEquals(s.slice(1, 3).map((x) => x.scope), ["cli", "sdk"]); + assertEquals(new Set(s.map((x) => x.scope)).size, s.length); + // On a tie, the scope the history knows wins. + assertEquals(suggestScopes(["docs/a.md", "extensions/weather/a.ts"], h)[0].scope, "weather"); +}); + +Deno.test("header and buildMessage", () => { + assertEquals(header({ type: "feat", scope: "api", description: "add x" }), "feat(api): add x"); + assertEquals( + header({ type: "fix", description: " y ", breaking: true, gitmoji: "bug" }), + "🐛 fix!: y", + ); + assertEquals(header({ type: "fix", description: "y", gitmoji: "bug", gitmojiStyle: "code" }), ":bug: fix: y"); + assertEquals( + buildMessage({ + type: "feat", + scope: "parser", + description: "support comments", + body: + "Comments were rejected with a confusing error. They are now skipped by the lexer, which keeps their position for error messages.\n\n- line comments\n- block comments", + breaking: true, + breakingNote: "the `strict` option is gone", + footers: "closes #12\nCo-authored-by: Ada ", + }), + [ + "feat(parser)!: support comments", + "", + "Comments were rejected with a confusing error. They are now skipped by", + "the lexer, which keeps their position for error messages.", + "", + "- line comments", + "- block comments", + "", + "BREAKING CHANGE: the `strict` option is gone", + "Closes #12", + "Co-authored-by: Ada ", + "", + ].join("\n"), + ); + assertEquals(buildMessage({ type: "docs", description: "typo" }), "docs: typo\n"); +}); + +Deno.test("wrap keeps lists and code", () => { + assertEquals(wrap("a b c d", 3), "a b\nc d"); + assertEquals(wrap("- a long list item that stays as is", 10), "- a long list item that stays as is"); + assertEquals(wrap(" indented code line stays", 10), " indented code line stays"); + assertEquals(wrap("https://example.com/a/very/long/url", 10), "https://example.com/a/very/long/url"); +}); + +Deno.test("footerLines normalizes issue references", () => { + assertEquals(footerLines("closes #1\nFIXES: #2\nrefs #3, #4\n\nReviewed-by: Z"), [ + "Closes #1", + "Fixes #2", + "Refs #3, #4", + "Reviewed-by: Z", + ]); + assertEquals(footerLines(undefined), []); +}); + +Deno.test("lint catches common mistakes", () => { + const texts = (d: Parameters[0]) => lint(d).map((w) => w.text); + assertEquals(texts({ type: "feat", description: "add x" }), []); + assertEquals(texts({ type: "feat", description: "" }), ["The description is empty"]); + assertEquals(texts({ type: "fix", description: "Added a thing." }), [ + "The description shouldn't end with a period", + "Descriptions usually start with a lowercase letter", + "Use the imperative mood: “add”, not “added” or “adds”", + ]); + assert(texts({ type: "feat", description: "x".repeat(80) })[0].includes("72 at most")); + assert(texts({ type: "feat", description: "x".repeat(50) })[0].includes("50 or fewer")); + assertEquals(texts({ type: "feat", description: "a", breaking: true }).length, 1); + assertEquals(texts({ type: "revert", description: "a", body: "This reverts commit 1a2b3c4d." }), []); + assertEquals(texts({ type: "revert", description: "a" }).length, 1); + // A repository writing capitalized descriptions isn't nagged. + const capital = learnHistory(["feat: Add a", "fix: Fix b", "docs: Update c", "chore: Bump d", "fix: Handle e"]); + assertEquals(lint({ type: "feat", description: "Add x" }, capital), []); +}); + +Deno.test("git output parsing and shell quoting", () => { + assertEquals(parseShortstat(" 3 files changed, 10 insertions(+), 2 deletions(-)"), { + files: 3, + insertions: 10, + deletions: 2, + }); + assertEquals(parseShortstat(" 1 file changed, 1 insertion(+)"), { files: 1, insertions: 1, deletions: 0 }); + assertEquals(parseShortstat(""), { files: 0, insertions: 0, deletions: 0 }); + assertEquals(parseNames("a.ts\0dir/b c.ts\0"), ["a.ts", "dir/b c.ts"]); + assertEquals(shellQuote("simple"), "simple"); + assertEquals(shellQuote("it's here"), "'it'\\''s here'"); + assertEquals(commitCommand("feat: x\n\nbody line\n"), "git commit -m 'feat: x' -m 'body line'"); +}); + +Deno.test("repository discovery", () => { + assertEquals(parseFolders("~/code, /srv/git:~/x", "/home/ada"), ["/home/ada/code", "/srv/git", "/home/ada/x"]); + assertEquals(parseFolders("", "/h").length, 9); + const args = findArgs(["/h/code"], 3); + assertEquals(args.slice(0, 5), ["/h/code", "-mindepth", "1", "-maxdepth", "4"]); + assertEquals(reposFromFind("/h/code/b/.git\n/h/code/a/.git\n/h/code/a/.git\nnoise"), ["/h/code/a", "/h/code/b"]); + assertEquals(tilde("/home/ada/code/x", "/home/ada"), "~/code/x"); + const repos = [ + { path: "/a", staged: 0, lastCommit: 300 }, + { path: "/b", staged: 2, lastCommit: 100 }, + { path: "/c", staged: 1, lastCommit: 200 }, + ]; + assertEquals(pickRepo(repos)?.path, "/c"); + assertEquals(pickRepo(repos, "/b")?.path, "/b"); + assertEquals(pickRepo(repos, "/a")?.path, "/c"); + assertEquals( + pickRepo([{ path: "/a", staged: 0, lastCommit: 1 }, { path: "/z", staged: 0, lastCommit: 9 }], "/a")?.path, + "/a", + ); + assertEquals(pickRepo([]), undefined); + assertEquals(installCommand("ID=fedora\nVERSION_ID=41"), "sudo dnf install git"); + assertEquals(installCommand('ID="linuxmint"\nID_LIKE="ubuntu debian"'), "sudo apt install git"); +}); diff --git a/extensions/container-registries/api.ts b/extensions/container-registries/api.ts new file mode 100644 index 0000000..12431ff --- /dev/null +++ b/extensions/container-registries/api.ts @@ -0,0 +1,260 @@ +// Docker Hub's and Quay's JSON APIs, and the OCI distribution API (anonymous tokens) for GHCR and Quay. + +import { + type ImageConfig, + imageSize, + type ImageSummary, + isIndex, + mapConfig, + mapHubRepo, + mapHubSearch, + mapHubTags, + mapQuayRepo, + mapQuaySearch, + mapQuayTags, + nextLink, + parseChallenge, + type Platform, + platformsOf, + type RegistryId, + type RepoDetails, + sortTagNames, + type Tag, +} from "./lib.ts"; + +export class NotFound extends Error {} + +const q = encodeURIComponent; + +async function getJson(url: string, signal?: AbortSignal, headers: Record = {}): Promise { + const res = await fetch(url, { signal, headers: { Accept: "application/json", ...headers } }); + if (res.status === 404) throw new NotFound(url); + if (res.status === 429) throw new Error(`${new URL(url).host} is rate limiting: wait a minute`); + if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`); + return await res.json(); +} + +// --------------------------------------------------------------------------- +// Search + +export async function search(registry: RegistryId, query: string, signal: AbortSignal): Promise { + if (registry === "quay") { + return mapQuaySearch(await getJson(`https://quay.io/api/v1/find/repositories?query=${q(query)}&page=1`, signal)); + } + return mapHubSearch( + await getJson(`https://hub.docker.com/v2/search/repositories/?query=${q(query)}&page_size=25`, signal), + ); +} + +export async function repoDetails( + registry: RegistryId, + repository: string, + signal?: AbortSignal, +): Promise { + if (registry === "docker") { + return mapHubRepo(await getJson(`https://hub.docker.com/v2/repositories/${repository}/`, signal)); + } + if (registry === "quay") return mapQuayRepo(await getJson(`https://quay.io/api/v1/repository/${repository}`, signal)); + return { categories: [] }; +} + +// --------------------------------------------------------------------------- +// OCI distribution API with anonymous bearer tokens + +const HOST: Record<"ghcr" | "quay", string> = { ghcr: "ghcr.io", quay: "quay.io" }; +const tokens = new Map(); + +const MANIFEST_TYPES = [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +].join(", "); + +/** GETs a registry URL, fetching an anonymous pull token when the registry asks for one. */ +async function registryGet(url: string, repository: string, signal?: AbortSignal, accept = "application/json") { + const host = new URL(url).host; + const key = `${host}/${repository}`; + const attempt = () => + fetch(url, { + signal, + headers: { Accept: accept, ...(tokens.has(key) ? { Authorization: `Bearer ${tokens.get(key)}` } : {}) }, + }); + let res = await attempt(); + if (res.status === 401) { + const challenge = parseChallenge(res.headers.get("www-authenticate")); + await res.body?.cancel(); + if (!challenge) throw new Error(`${host} wants credentials`); + const tokenUrl = new URL(challenge.realm); + if (challenge.service) tokenUrl.searchParams.set("service", challenge.service); + tokenUrl.searchParams.set("scope", challenge.scope ?? `repository:${repository}:pull`); + const t = await fetch(tokenUrl, { signal }); + if (t.status === 401 || t.status === 403) { + await t.body?.cancel(); + throw new NotFound(`${host}/${repository} doesn't exist or is private`); + } + if (!t.ok) throw new Error(`${host} refused a token (${t.status})`); + const body = await t.json(); + tokens.set(key, body.token ?? body.access_token); + res = await attempt(); + } + if (res.status === 404 || res.status === 403) { + await res.body?.cancel(); + throw new NotFound(`${host}/${repository} doesn't exist or is private`); + } + if (res.status === 429) throw new Error(`${host} is rate limiting: wait a minute`); + if (!res.ok) throw new Error(`${host} answered ${res.status}`); + return res; +} + +/** Every tag name (GHCR lists them alphabetically, 1000 a page). */ +async function listTagNames(registry: "ghcr" | "quay", repository: string, signal: AbortSignal): Promise { + const names: string[] = []; + let url: string | undefined = `https://${HOST[registry]}/v2/${repository}/tags/list?n=1000`; + for (let page = 0; url && page < 10; page++) { + const res = await registryGet(url, repository, signal); + const body = await res.json(); + names.push(...(body.tags ?? [])); + url = nextLink(res.headers.get("link"), url); + } + return names; +} + +export interface ManifestInfo { + digest?: string; + platforms: Platform[]; + multiArch: boolean; + /** Of the first platform (or the image). */ + size?: number; + /** The image manifest's config blob digest, for dates and labels. */ + config?: string; +} + +export async function manifest( + registry: "ghcr" | "quay", + repository: string, + reference: string, + signal?: AbortSignal, +): Promise { + const base = `https://${HOST[registry]}/v2/${repository}/manifests/`; + const res = await registryGet(base + reference, repository, signal, MANIFEST_TYPES); + const digest = res.headers.get("docker-content-digest") ?? undefined; + const body = await res.json(); + if (!isIndex(body)) { + return { digest, platforms: [], multiArch: false, size: imageSize(body), config: body.config?.digest }; + } + const platforms = platformsOf(body); + // The first platform's own manifest gives a size and a config. + const first = platforms.find((p) => p.os === "linux" && p.architecture === "amd64") ?? platforms[0]; + let size: number | undefined; + let config: string | undefined; + if (first?.digest) { + const image = await (await registryGet(base + first.digest, repository, signal, MANIFEST_TYPES)).json() + .catch(() => undefined); + size = imageSize(image); + config = image?.config?.digest; + first.size = size; + } + return { digest, platforms, multiArch: true, size, config }; +} + +/** The image config: creation date and labels. Blobs are served from a CDN host (pkg-containers.githubusercontent.com). */ +export async function imageConfig( + registry: "ghcr" | "quay", + repository: string, + digest: string, + signal?: AbortSignal, +): Promise { + const res = await registryGet(`https://${HOST[registry]}/v2/${repository}/blobs/${digest}`, repository, signal); + return mapConfig(await res.json()); +} + +/** 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; +} + +/** How many tags get their manifest fetched up front on GHCR; the others load on demand. */ +const DETAILED = 12; + +export async function tags( + registry: RegistryId, + repository: string, + signal: AbortSignal, + filter?: string, +): Promise { + if (registry === "docker") { + const name = filter ? `&name=${q(filter)}` : ""; + return mapHubTags( + await getJson( + `https://hub.docker.com/v2/repositories/${repository}/tags?page_size=100&ordering=last_updated${name}`, + signal, + ), + ); + } + if (registry === "quay") { + const found = mapQuayTags( + await getJson( + `https://quay.io/api/v1/repository/${repository}/tag/?onlyActiveTags=true&limit=100&page=1${ + filter ? `&filter_tag_name=like:${q(filter)}` : "" + }`, + signal, + ), + ); + // Multi-arch tags: their platforms and sizes from the registry, for the first ones. + const lists = found.filter((t) => t.partial).slice(0, DETAILED); + await pool(lists, 4, async (t) => { + const m = await manifest("quay", repository, t.digest ?? t.name, signal).catch(() => undefined); + if (!m) return; + Object.assign(t, { platforms: m.platforms, size: m.size ?? t.size, partial: false }); + }); + return found; + } + const names = sortTagNames(await listTagNames("ghcr", repository, signal)); + const list: Tag[] = names.map((name) => ({ name, platforms: [], partial: true })); + const wanted = (filter ? list.filter((t) => t.name.startsWith(filter)) : list).slice(0, DETAILED); + await pool(wanted, 4, async (t) => { + const m = await manifest("ghcr", repository, t.name, signal).catch(() => undefined); + if (!m) return; + Object.assign(t, { + digest: m.digest, + platforms: m.platforms, + size: m.size, + multiArch: m.multiArch, + partial: false, + }); + }); + return list; +} + +/** Everything about one tag: manifest, platforms, and the config's date and labels when reachable. */ +export async function tagDetails( + registry: RegistryId, + repository: string, + tag: Tag, + signal?: AbortSignal, +): Promise<{ tag: Tag; config?: ImageConfig }> { + if (registry === "docker") return { tag }; + const m = await manifest(registry, repository, tag.name, signal); + const full: Tag = { + ...tag, + digest: m.digest ?? tag.digest, + platforms: m.platforms.length ? m.platforms : tag.platforms, + size: m.size ?? tag.size, + multiArch: m.multiArch, + partial: false, + }; + const config = m.config + ? await imageConfig(registry, repository, m.config, signal).catch(() => undefined) + : undefined; + if (config?.created && !full.pushed) full.pushed = config.created; + return { tag: full, config }; +} diff --git a/extensions/container-registries/backslash.toml b/extensions/container-registries/backslash.toml new file mode 100644 index 0000000..1150b33 --- /dev/null +++ b/extensions/container-registries/backslash.toml @@ -0,0 +1,36 @@ +[extension] +id = "container-registries" +title = "Container Registries" +description = "Search Docker Hub and Quay.io images and browse tags on Docker Hub, GHCR and Quay with sizes, digests and platforms" +version = "1.0.0" +author = "Backslash" +icon = "bs-box" +license = "MIT" + +[permissions] +# Docker Hub's API; GHCR's and Quay's registry API with anonymous pull tokens +# (public images only); Quay's own API for search and tag dates. GHCR serves +# image configs (build date, labels) from pkg-containers.githubusercontent.com. +net = ["hub.docker.com", "ghcr.io", "quay.io", "pkg-containers.githubusercontent.com"] + +[[commands]] +name = "search" +title = "Search Container Images" +description = "Docker Hub search, then tags with size, digest, platforms and pull commands; or type ghcr.io/owner/image" +icon = "bs-box" +keywords = [ + "docker", + "container", + "image", + "tag", + "registry", + "docker hub", + "ghcr", + "quay", + "podman", + "pull", + "oci", + "conteneur", + "registre", + "étiquette", +] diff --git a/extensions/container-registries/commands/search.ts b/extensions/container-registries/commands/search.ts new file mode 100644 index 0000000..b172c68 --- /dev/null +++ b/extensions/container-registries/commands/search.ts @@ -0,0 +1,378 @@ +import { + type Action, + action, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, +} from "@backslash/api"; +import { NotFound, repoDetails, search, tagDetails, tags } from "../api.ts"; +import { + compact, + filterTags, + formatBytes, + type ImageConfig, + type ImageRef, + type ImageSummary, + parseImageRef, + platformLabel, + pullCommands, + REGISTRIES, + type RegistryId, + relativeDate, + shortDigest, + shortName, + type Tag, + trimReadme, + webUrl, +} from "../lib.ts"; + +const dateText = (iso: string | undefined) => + iso ? new Date(iso).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }) : ""; + +function errorItem(e: unknown, what: string, page: string): Item { + const missing = e instanceof NotFound; + return { + id: "error", + title: missing ? `${what} not found` : `Can't reach ${what}`, + subtitle: missing ? "Check the name; private images need a sign-in this extension doesn't do" : String( + e instanceof Error ? e.message : e, + ), + icon: { source: missing ? Icons.search : Icons.wifiOff, tint: missing ? "gray" : "red" }, + actions: [open(page, "Open in Browser")], + }; +} + +// --------------------------------------------------------------------------- +// Tags + +function tagActions(registry: RegistryId, repository: string, t: Tag, withDetails: boolean): Action[] { + const pulls = pullCommands(registry, repository, t.name, t.digest); + const docker = pulls.filter((p) => p.title.startsWith("docker")); + const podman = pulls.filter((p) => p.title.startsWith("podman")); + return [ + copy(docker[0].command, "Copy docker pull", { icon: Icons.terminal }), + ...(docker[1] ? [copy(docker[1].command, "Copy docker pull by Digest", { icon: Icons.terminal })] : []), + ...(withDetails + ? [action("Show Details", () => tagDetail(registry, repository, t), { icon: Icons.info, shortcut: "d" })] + : []), + ...podman.map((p, i) => + copy(p.command, i ? "Copy podman pull by Digest" : "Copy podman pull", { + icon: Icons.terminal, + section: "Podman", + ...(i === 0 ? { shortcut: "p" } : {}), + }) + ), + open(webUrl(registry, repository, t.name), `Open on ${REGISTRIES[registry].title}`, { + shortcut: "o", + section: "Links", + }), + copy(t.name, "Copy Tag", { section: "Copy" }), + ...(t.digest ? [copy(t.digest, "Copy Digest", { section: "Copy", shortcut: "d" })] : []), + copy(`${shortName(registry, repository)}:${t.name}`, "Copy Image Reference", { section: "Copy" }), + ]; +} + +function tagMetadata(registry: RegistryId, repository: string, t: Tag, config?: ImageConfig): Metadata[] { + const sized = t.platforms.filter((p) => p.size); + const source = config?.labels["org.opencontainers.image.source"]; + const version = config?.labels["org.opencontainers.image.version"]; + return [ + ...(t.digest ? [meta.text("Digest", shortDigest(t.digest), { icon: Icons.hash })] : []), + meta.tags("Type", [{ + text: t.multiArch ? "Multi-arch index" : "Single image", + color: t.multiArch ? "blue" : "gray", + }]), + ...(t.platforms.length + ? [meta.tags("Platforms", t.platforms.map((p) => ({ text: platformLabel(p), color: "teal" as const })))] + : []), + ...(sized.length > 1 ? sized.map((p) => meta.text(platformLabel(p), formatBytes(p.size))) : []), + ...(t.pushed ? [meta.text("Pushed", dateText(t.pushed), { icon: Icons.upload })] : []), + ...(t.pulled ? [meta.text("Last pulled", relativeDate(t.pulled), { icon: Icons.download })] : []), + ...(config?.created && config.created !== t.pushed ? [meta.text("Built", dateText(config.created))] : []), + ...(version ? [meta.text("Version label", version, { icon: Icons.tag })] : []), + meta.separator(), + meta.link( + REGISTRIES[registry].title, + `${shortName(registry, repository)}:${t.name}`, + webUrl(registry, repository, t.name), + ), + ...(source ? [meta.link("Source", source.replace(/^https:\/\//, ""), source)] : []), + ]; +} + +function tagItem(registry: RegistryId, repository: string, t: Tag): Item { + const pulls = pullCommands(registry, repository, t.name, t.digest); + const archs = t.platforms.length; + const pushed = relativeDate(t.pushed); + return { + id: `tag:${t.name}`, + title: t.name, + subtitle: pushed ? `pushed ${pushed}` : t.partial ? "Ctrl+D for details" : undefined, + icon: { source: Icons.tag, tint: t.name === "latest" || t.name === "stable" ? "green" : REGISTRIES[registry].tint }, + accessories: [ + ...(archs > 1 ? [{ text: `${archs} platforms`, tooltip: t.platforms.map(platformLabel).join(", ") }] : []), + ...(archs === 1 ? [{ text: platformLabel(t.platforms[0]) }] : []), + ...(t.size ? [{ text: formatBytes(t.size), tooltip: "Compressed size" }] : []), + ...(t.digest ? [{ tag: shortDigest(t.digest).slice(0, 7), color: "gray" as const, tooltip: t.digest }] : []), + ], + detail: { + stats: [ + ...(t.size ? [{ label: "Size", value: formatBytes(t.size) }] : []), + ...(archs ? [{ label: "Platforms", value: archs }] : []), + ...(pushed ? [{ label: "Pushed", value: pushed }] : []), + ], + markdown: [ + "```", + ...pulls.map((p) => p.command), + "```", + ...(t.partial ? ["", "_Press Ctrl+D to load its platforms, size and dates._"] : []), + ].join("\n"), + metadata: tagMetadata(registry, repository, t), + }, + actions: tagActions(registry, repository, t, registry !== "docker"), + }; +} + +async function tagDetail(registry: RegistryId, repository: string, t: Tag): Promise { + const { tag, config } = await tagDetails(registry, repository, t); + const pulls = pullCommands(registry, repository, tag.name, tag.digest); + const labels = Object.entries(config?.labels ?? {}); + return detail({ + title: `${shortName(registry, repository)}:${tag.name}`, + stats: [ + ...(tag.size ? [{ label: "Size", value: formatBytes(tag.size) }] : []), + ...(tag.platforms.length ? [{ label: "Platforms", value: tag.platforms.length }] : []), + ...(tag.pushed ? [{ label: "Pushed", value: relativeDate(tag.pushed) }] : []), + ], + markdown: [ + "```", + ...pulls.map((p) => p.command), + "```", + ...(labels.length ? ["", "**Labels**", "", ...labels.slice(0, 20).map(([k, v]) => `- \`${k}\`: ${v}`)] : []), + ].join("\n"), + metadata: tagMetadata(registry, repository, tag, config), + actions: tagActions(registry, repository, tag, false), + }); +} + +/** Tags already loaded, by registry/repository, so typing `:1.2` filters without a new request. */ +const tagCache = new Map(); + +async function loadTags(registry: RegistryId, repository: string, signal: AbortSignal, filter?: string) { + // Docker Hub and Quay filter on their side (they can have thousands of tags); GHCR is listed whole. + const server = registry === "ghcr" ? undefined : filter || undefined; + const key = `${registry}/${repository}:${server ?? ""}`; + if (!tagCache.has(key)) tagCache.set(key, await tags(registry, repository, signal, server)); + return tagCache.get(key)!; +} + +function tagsList(image: ImageSummary): ListSpec { + const { registry, repository } = image; + let count = 0; + return list({ + title: shortName(registry, repository), + placeholder: `Filter the tags of ${shortName(registry, repository)}…`, + empty: { title: "No tags", description: "This image has no tag.", icon: Icons.tag }, + status: (items) => + `${items.length === count ? count : `${items.length} of ${count}`} tags${ + registry === "ghcr" ? "" : " · the 100 most recent" + } · ${REGISTRIES[registry].title}`, + actions: [ + action("Show Overview", () => overview(image), { icon: Icons.info }), + open(webUrl(registry, repository), `Open on ${REGISTRIES[registry].title}`), + ], + async items({ signal }) { + try { + const found = await loadTags(registry, repository, signal); + count = found.length; + return found.map((t) => tagItem(registry, repository, t)); + } catch (e) { + if (signal.aborted) throw e; + return [errorItem(e, shortName(registry, repository), webUrl(registry, repository))]; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// Images + +async function overview(image: ImageSummary): Promise { + const { registry, repository } = image; + const d = await repoDetails(registry, repository).catch(() => + ({ categories: [] as string[] }) as Awaited< + ReturnType + > + ); + const readme = d.readme ? trimReadme(d.readme, 120) : d.description ?? image.description ?? "_No description._"; + return detail({ + title: shortName(registry, repository), + stats: [ + ...((d.pulls ?? image.pulls) !== undefined ? [{ label: "Pulls", value: compact(d.pulls ?? image.pulls) }] : []), + ...((d.stars ?? image.stars) !== undefined ? [{ label: "Stars", value: compact(d.stars ?? image.stars) }] : []), + ...(d.updated ? [{ label: "Updated", value: relativeDate(d.updated) }] : []), + ], + markdown: readme, + metadata: [ + meta.text("Registry", REGISTRIES[registry].title, { icon: Icons.server }), + ...(image.official ? [meta.tags("Badge", [{ text: "Docker Official Image", color: "blue" }])] : []), + ...(d.categories.length ? [meta.tags("Categories", d.categories)] : []), + ...(d.created ? [meta.text("Created", dateText(d.created), { icon: Icons.calendar })] : []), + meta.separator(), + meta.link("Page", webUrl(registry, repository).replace(/^https:\/\//, ""), webUrl(registry, repository)), + ], + actions: [ + action("Show Tags", () => tagsList(image), { icon: Icons.tag }), + copy(`docker pull ${shortName(registry, repository)}`, "Copy docker pull", { icon: Icons.terminal }), + open(webUrl(registry, repository), `Open on ${REGISTRIES[registry].title}`), + ], + }); +} + +function imageItem(image: ImageSummary): Item { + const { registry, repository } = image; + const name = shortName(registry, repository); + const pulls = pullCommands(registry, repository, "latest"); + return { + id: `${registry}:${repository}`, + title: name, + subtitle: image.description, + icon: { + source: image.official ? Icons.check : Icons.box, + tint: image.official ? "blue" : REGISTRIES[registry].tint, + }, + section: image.official ? "Official Images" : undefined, + accessories: [ + ...(image.official ? [{ tag: "Official", color: "blue" as const }] : []), + ...(image.stars !== undefined ? [{ icon: Icons.star, text: compact(image.stars), tooltip: "Stars" }] : []), + ...(image.pulls !== undefined ? [{ icon: Icons.download, text: compact(image.pulls), tooltip: "Pulls" }] : []), + ], + detail: { + stats: [ + ...(image.pulls !== undefined ? [{ label: "Pulls", value: compact(image.pulls) }] : []), + ...(image.stars !== undefined ? [{ label: "Stars", value: compact(image.stars) }] : []), + ...(image.updated ? [{ label: "Updated", value: relativeDate(image.updated) }] : []), + ], + markdown: [image.description ?? "", "", "```", ...pulls.map((p) => p.command), "```"].join("\n").trim(), + metadata: [ + meta.text("Registry", REGISTRIES[registry].title, { icon: Icons.server }), + ...(image.official ? [meta.tags("Badge", [{ text: "Docker Official Image", color: "blue" }])] : []), + meta.link("Page", webUrl(registry, repository).replace(/^https:\/\//, ""), webUrl(registry, repository)), + ], + }, + actions: [ + action("Show Tags", () => tagsList(image), { icon: Icons.tag }), + copy(pulls[0].command, "Copy docker pull", { icon: Icons.terminal }), + action("Show Overview", () => overview(image), { icon: Icons.info, shortcut: "d" }), + copy(pulls[1].command, "Copy podman pull", { icon: Icons.terminal, section: "Podman" }), + open(webUrl(registry, repository), `Open on ${REGISTRIES[registry].title}`, { + shortcut: "o", + section: "Links", + }), + copy(name, "Copy Image Name", { section: "Copy" }), + ], + }; +} + +const HINTS: { query: string; text: string; registry: RegistryId; repository: string }[] = [ + { + query: "nginx", + text: "Search Docker Hub (official images first)", + registry: "docker", + repository: "library/nginx", + }, + { query: "postgres:17", text: "Tags of an image, filtered", registry: "docker", repository: "library/postgres" }, + { + query: "ghcr.io/home-assistant/home-assistant", + text: "Tags on the GitHub Container Registry", + registry: "ghcr", + repository: "home-assistant/home-assistant", + }, + { + query: "quay.io/prometheus/node-exporter", + text: "Tags on Quay.io", + registry: "quay", + repository: "prometheus/node-exporter", + }, +]; + +let status = ""; + +export default list({ + placeholder: "Search images, or type an image: nginx:1.27, ghcr.io/owner/image, quay.io/ns/name…", + filter: { + options: [{ value: "docker", title: "Docker Hub" }, { value: "quay", title: "Quay.io" }], + }, + empty: { title: "No image found", description: "Check the spelling, or try the other registry.", icon: Icons.box }, + status: () => status, + async search(query, { signal, filter }) { + const q = query.trim(); + if (!q) { + status = "Docker Hub, GHCR and Quay.io"; + return HINTS.map((h) => ({ + id: `hint:${h.query}`, + title: h.query, + subtitle: h.text, + icon: { source: Icons.search, tint: REGISTRIES[h.registry].tint }, + section: "Type", + actions: [open(webUrl(h.registry, h.repository), `Open on ${REGISTRIES[h.registry].title}`)], + })); + } + const ref: ImageRef | undefined = parseImageRef(q); + if (ref) { + const { registry, repository } = ref; + const name = shortName(registry, repository); + try { + const found = filterTags(await loadTags(registry, repository, signal, ref.tag), ref.tag); + status = `${found.length} tag${found.length === 1 ? "" : "s"} of ${name} · ${REGISTRIES[registry].title}`; + const image: ImageSummary = { registry, repository }; + return [ + ...found.slice(0, 200).map((t) => ({ ...tagItem(registry, repository, t), section: `Tags of ${name}` })), + { + id: "overview", + title: `About ${name}`, + subtitle: "Description, stats and all tags", + icon: { source: Icons.info, tint: "gray" as const }, + section: "Image", + actions: [ + action("Show Overview", () => overview(image), { icon: Icons.info }), + action("Show Tags", () => tagsList(image), { icon: Icons.tag }), + open(webUrl(registry, repository), `Open on ${REGISTRIES[registry].title}`), + ], + }, + ]; + } catch (e) { + if (signal.aborted) throw e; + status = `${name}: ${e instanceof NotFound ? "not found" : "unreachable"}`; + return [errorItem(e, name, webUrl(registry, repository))]; + } + } + const registry = (filter as RegistryId) === "quay" ? "quay" : "docker"; + try { + const found = await search(registry, q, signal); + status = `${found.length} image${found.length === 1 ? "" : "s"} on ${ + REGISTRIES[registry].title + } · type name:tag for tags`; + return found.map(imageItem); + } catch (e) { + if (signal.aborted) throw e; + status = `${REGISTRIES[registry].title} is unreachable`; + return [ + errorItem( + e, + REGISTRIES[registry].title, + registry === "quay" + ? `https://quay.io/search?q=${encodeURIComponent(q)}` + : `https://hub.docker.com/search?q=${encodeURIComponent(q)}`, + ), + ]; + } + }, +}); diff --git a/extensions/container-registries/lib.ts b/extensions/container-registries/lib.ts new file mode 100644 index 0000000..b603a40 --- /dev/null +++ b/extensions/container-registries/lib.ts @@ -0,0 +1,460 @@ +// Container registries: image references, Docker Hub / Quay / OCI registry JSON mapping, pull commands. +// Pure, tested in lib_test.ts. + +export type RegistryId = "docker" | "ghcr" | "quay"; + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export interface Registry { + id: RegistryId; + title: string; + host: string; + tint: Tint; +} + +export const REGISTRIES: Record = { + docker: { id: "docker", title: "Docker Hub", host: "docker.io", tint: "blue" }, + ghcr: { id: "ghcr", title: "GitHub Container Registry", host: "ghcr.io", tint: "purple" }, + quay: { id: "quay", title: "Quay.io", host: "quay.io", tint: "red" }, +}; + +export interface ImageRef { + registry: RegistryId; + /** `library/nginx`, `owner/image`, `ns/name`. */ + repository: string; + /** What follows `:`, to filter the tags with (may be empty: `nginx:`). */ + tag?: string; + digest?: string; +} + +const HOSTS: Record = { + "docker.io": "docker", + "index.docker.io": "docker", + "registry-1.docker.io": "docker", + "hub.docker.com": "docker", + "ghcr.io": "ghcr", + "quay.io": "quay", +}; + +const NAME = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/; + +/** + * Reads what was typed: `nginx:1.27`, `grafana/grafana`, `docker.io/library/nginx`, + * `ghcr.io/owner/image:tag`, `quay.io/ns/name@sha256:…`, or a registry web page URL. + * Returns undefined for words to search for (`nginx`, `node exporter`). + */ +export function parseImageRef(input: string): ImageRef | undefined { + let s = input.trim(); + if (!s || /\s/.test(s)) return undefined; + // Web pages. + let m = s.match(/^https?:\/\/hub\.docker\.com\/(?:_\/([^/?#]+)|r\/([^/?#]+\/[^/?#]+))/); + if (m) return { registry: "docker", repository: m[1] ? `library/${m[1]}` : m[2] }; + m = s.match(/^https?:\/\/quay\.io\/repository\/([^/?#]+\/[^/?#]+)/); + if (m) return { registry: "quay", repository: m[1] }; + m = s.match(/^https?:\/\/github\.com\/(?:orgs|users)\/([^/]+)\/packages\/container\/(?:package\/)?([^/?#]+)/); + if (m) return { registry: "ghcr", repository: `${m[1]}/${decodeURIComponent(m[2])}`.toLowerCase() }; + s = s.replace(/^https?:\/\//, ""); + + let digest: string | undefined; + const at = s.indexOf("@"); + if (at >= 0) { + digest = s.slice(at + 1); + s = s.slice(0, at); + } + let tag: string | undefined; + const colon = s.lastIndexOf(":"); + if (colon > s.lastIndexOf("/")) { + tag = s.slice(colon + 1); + s = s.slice(0, colon); + } + const parts = s.split("/"); + let registry: RegistryId = "docker"; + let explicit = tag !== undefined || digest !== undefined; + if (parts.length > 1 && (parts[0].includes(".") || parts[0].includes(":") || parts[0] === "localhost")) { + const id = HOSTS[parts[0].toLowerCase()]; + if (!id) return undefined; + registry = id; + parts.shift(); + explicit = true; + } + const repository = parts.join("/").toLowerCase(); + if (!repository || !NAME.test(repository)) return undefined; + if (!explicit) return undefined; + if (registry !== "docker" && !repository.includes("/")) return undefined; + return { + registry, + repository: registry === "docker" && !repository.includes("/") ? `library/${repository}` : repository, + ...(tag !== undefined ? { tag } : {}), + ...(digest ? { digest } : {}), + }; +} + +/** How `docker pull` writes it: `nginx`, `grafana/grafana`, `ghcr.io/owner/image`. */ +export function shortName(registry: RegistryId, repository: string): string { + if (registry === "docker") return repository.replace(/^library\//, ""); + return `${REGISTRIES[registry].host}/${repository}`; +} + +/** Fully qualified, as podman wants it (no short-name prompt): `docker.io/library/nginx`. */ +export const fullName = (registry: RegistryId, repository: string) => `${REGISTRIES[registry].host}/${repository}`; + +export interface PullCommand { + title: string; + command: string; +} + +export function pullCommands(registry: RegistryId, repository: string, tag: string, digest?: string): PullCommand[] { + const short = shortName(registry, repository); + const full = fullName(registry, repository); + return [ + { title: "docker pull", command: `docker pull ${short}:${tag}` }, + ...(digest ? [{ title: "docker pull by digest", command: `docker pull ${short}@${digest}` }] : []), + { title: "podman pull", command: `podman pull ${full}:${tag}` }, + ...(digest ? [{ title: "podman pull by digest", command: `podman pull ${full}@${digest}` }] : []), + ]; +} + +/** The image's (or tag's) page on the registry's website. */ +export function webUrl(registry: RegistryId, repository: string, tag?: string): string { + const q = tag ? encodeURIComponent(tag) : ""; + switch (registry) { + case "docker": { + const base = repository.startsWith("library/") + ? `https://hub.docker.com/_/${repository.slice(8)}` + : `https://hub.docker.com/r/${repository}`; + return tag ? `${base}/tags?name=${q}` : base; + } + case "ghcr": + // ghcr.io sends browsers to the package's page on GitHub. + return `https://ghcr.io/${repository}`; + case "quay": + return `https://quay.io/repository/${repository}${tag ? `?tab=tags&tag=${q}` : ""}`; + } +} + +// --------------------------------------------------------------------------- +// Search results + +export interface ImageSummary { + registry: RegistryId; + repository: string; + description?: string; + official?: boolean; + stars?: number; + pulls?: number; + updated?: string; +} + +/** Docker Hub `/v2/search/repositories`: official images first, then the API's order. */ +export function mapHubSearch(json: any): ImageSummary[] { + const items: ImageSummary[] = (json?.results ?? []).map((r: any) => { + const name = String(r.repo_name ?? ""); + return { + registry: "docker" as const, + repository: name.includes("/") ? name : `library/${name}`, + description: r.short_description || undefined, + official: !!r.is_official, + stars: r.star_count ?? undefined, + pulls: r.pull_count ?? undefined, + }; + }).filter((i: ImageSummary) => i.repository && NAME.test(i.repository)); + return [...items.filter((i) => i.official), ...items.filter((i) => !i.official)]; +} + +/** Quay `/api/v1/find/repositories`. */ +export function mapQuaySearch(json: any): ImageSummary[] { + return (json?.results ?? []) + .filter((r: any) => (r.kind ?? "repository") === "repository" && r.name && r.namespace) + .map((r: any) => ({ + registry: "quay" as const, + repository: `${typeof r.namespace === "string" ? r.namespace : r.namespace.name}/${r.name}`, + description: firstLine(r.description), + stars: r.stars ?? undefined, + updated: typeof r.last_modified === "number" ? new Date(r.last_modified * 1000).toISOString() : undefined, + })); +} + +/** The first line of a markdown description, without markup. */ +export function firstLine(md: string | undefined | null): string | undefined { + const line = (md ?? "").split("\n").map((l) => l.replace(/^#+\s*|[*_`]/g, "").trim()).find(Boolean); + if (!line) return undefined; + return line.length > 140 ? `${line.slice(0, 139)}…` : line; +} + +// --------------------------------------------------------------------------- +// Tags + +export interface Platform { + os: string; + architecture: string; + variant?: string; + digest?: string; + /** Compressed size, in bytes. */ + size?: number; +} + +export interface Tag { + name: string; + /** Of the tag (an index for multi-arch images). */ + digest?: string; + /** Compressed size of the main platform, in bytes. */ + size?: number; + platforms: Platform[]; + /** ISO dates. */ + pushed?: string; + pulled?: string; + multiArch?: boolean; + /** Details still to load (GHCR lists names only). */ + partial?: boolean; +} + +export const platformLabel = (p: Platform) => [p.os, p.architecture, p.variant].filter(Boolean).join("/"); + +/** Attestations and signatures ride along in indexes as `unknown/unknown`: not platforms. */ +const realPlatform = (p: Platform) => p.os !== "unknown" && p.architecture !== "unknown"; + +export function mapHubTags(json: any): Tag[] { + return (json?.results ?? []).map((r: any) => { + const platforms: Platform[] = (r.images ?? []).map((i: any) => ({ + os: i.os ?? "", + architecture: i.architecture ?? "", + ...(i.variant ? { variant: i.variant } : {}), + ...(i.digest ? { digest: i.digest } : {}), + ...(typeof i.size === "number" ? { size: i.size } : {}), + })).filter(realPlatform); + return { + name: String(r.name), + ...(r.digest ? { digest: r.digest } : {}), + ...(typeof r.full_size === "number" ? { size: r.full_size } : {}), + platforms, + ...(r.tag_last_pushed || r.last_updated ? { pushed: r.tag_last_pushed ?? r.last_updated } : {}), + ...(r.tag_last_pulled ? { pulled: r.tag_last_pulled } : {}), + multiArch: /index|list/.test(String(r.media_type ?? "")) || platforms.length > 1, + }; + }); +} + +/** Quay `/api/v1/repository/{ns}/{name}/tag/`. */ +export function mapQuayTags(json: any): Tag[] { + return (json?.tags ?? []).map((t: any) => ({ + name: String(t.name), + ...(t.manifest_digest ? { digest: t.manifest_digest } : {}), + ...(typeof t.size === "number" ? { size: t.size } : {}), + platforms: [], + ...(t.last_modified ? { pushed: toIso(t.last_modified) } : t.start_ts ? { pushed: toIso(t.start_ts) } : {}), + multiArch: !!t.is_manifest_list, + ...(t.is_manifest_list ? { partial: true } : {}), + })); +} + +function toIso(v: string | number): string | undefined { + const d = typeof v === "number" ? new Date(v * 1000) : new Date(v); + return isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +/** The platforms of an OCI index or Docker manifest list. */ +export function platformsOf(index: any): Platform[] { + return (index?.manifests ?? []) + .map((m: any) => ({ + os: m.platform?.os ?? "unknown", + architecture: m.platform?.architecture ?? "unknown", + ...(m.platform?.variant ? { variant: m.platform.variant } : {}), + ...(m.digest ? { digest: m.digest } : {}), + })) + .filter(realPlatform); +} + +export const isIndex = (manifest: any) => + Array.isArray(manifest?.manifests) || /index|list/.test(String(manifest?.mediaType ?? "")); + +/** Compressed size of an image manifest: its layers and config. */ +export function imageSize(manifest: any): number | undefined { + const layers = manifest?.layers; + if (!Array.isArray(layers)) return undefined; + return layers.reduce((n: number, l: any) => n + (Number(l.size) || 0), 0) + (Number(manifest.config?.size) || 0); +} + +export interface ImageConfig { + created?: string; + labels: Record; + os?: string; + architecture?: string; +} + +export function mapConfig(config: any): ImageConfig { + return { + ...(config?.created ? { created: config.created } : {}), + labels: config?.config?.Labels ?? {}, + ...(config?.os ? { os: config.os } : {}), + ...(config?.architecture ? { architecture: config.architecture } : {}), + }; +} + +/** `Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:x/y:pull"` */ +export function parseChallenge(header: string | null): { realm: string; service?: string; scope?: string } | undefined { + if (!header || !/^bearer\s/i.test(header)) return undefined; + const params: Record = {}; + for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) params[m[1].toLowerCase()] = m[2]; + if (!params.realm) return undefined; + return { realm: params.realm, service: params.service, scope: params.scope }; +} + +/** The `next` URL of a `Link: ; rel="next"` header, relative to `base`. */ +export function nextLink(header: string | null, base: string): string | undefined { + const m = header?.match(/<([^>]+)>\s*;\s*rel="?next"?/); + return m ? new URL(m[1], base).toString() : undefined; +} + +/** Splits a tag into numbers and words, to sort versions naturally. */ +function versionKey(tag: string): (number | string)[] { + return tag.replace(/^v(?=\d)/, "").split(/(\d+)/).filter(Boolean).map((p) => /^\d+$/.test(p) ? Number(p) : p); +} + +/** Whether a tag looks like a release version (`1.27.3`, `v2.1`), not a build or a branch. */ +const isVersion = (tag: string) => /^v?\d+(\.\d+)*$/.test(tag); + +/** + * Registries without dates (GHCR) list tags alphabetically: show `latest` and the + * like first, then release versions newest first, then the rest, newest-looking first. + */ +export function sortTagNames(names: string[]): string[] { + const pinned = ["latest", "stable", "main", "master", "edge", "nightly", "dev"]; + const cmp = (a: string, b: string) => { + const ka = versionKey(a); + const kb = versionKey(b); + for (let i = 0; i < Math.max(ka.length, kb.length); i++) { + const x = ka[i]; + const y = kb[i]; + if (x === undefined) return 1; + if (y === undefined) return -1; + if (x === y) continue; + if (typeof x === "number" && typeof y === "number") return y - x; + if (typeof x === "number") return -1; + if (typeof y === "number") return 1; + return x < y ? 1 : -1; + } + return 0; + }; + const first = pinned.filter((p) => names.includes(p)); + const rest = names.filter((n) => !pinned.includes(n)); + return [ + ...first, + ...rest.filter(isVersion).sort(cmp), + ...rest.filter((n) => !isVersion(n) && !/^sha256-/.test(n)).sort(cmp), + // Cosign signatures and attestations. + ...rest.filter((n) => /^sha256-/.test(n)).sort(), + ]; +} + +/** Tags matching what follows `:` (prefix first, then substring). */ +export function filterTags(tags: T[], filter: string | undefined): T[] { + const f = (filter ?? "").toLowerCase(); + if (!f) return tags; + return [ + ...tags.filter((t) => t.name.toLowerCase().startsWith(f)), + ...tags.filter((t) => !t.name.toLowerCase().startsWith(f) && t.name.toLowerCase().includes(f)), + ]; +} + +// --------------------------------------------------------------------------- +// Repository details + +export interface RepoDetails { + description?: string; + readme?: string; + stars?: number; + pulls?: number; + updated?: string; + created?: string; + categories: string[]; +} + +export function mapHubRepo(json: any): RepoDetails { + return { + description: json?.description || undefined, + readme: json?.full_description || undefined, + stars: json?.star_count ?? undefined, + pulls: json?.pull_count ?? undefined, + updated: json?.last_updated ?? undefined, + created: json?.date_registered ?? undefined, + categories: (json?.categories ?? []).map((c: any) => String(c.name ?? c)).filter(Boolean), + }; +} + +export function mapQuayRepo(json: any): RepoDetails { + return { + description: firstLine(json?.description), + readme: json?.description || undefined, + stars: undefined, + categories: [], + }; +} + +/** A README for the side panel: the first `maxLines` lines, HTML comments and badges removed. */ +export function trimReadme(md: string, maxLines = 80): string { + const lines = md.replace(//g, "").replace(/\r\n?/g, "\n").split("\n") + // Badge rows: lines made only of images or linked images. + .filter((l) => !/^\s*(\[?!\[[^\]]*\]\([^)]*\)\]?(\([^)]*\))?\s*)+$/.test(l)); + const out: string[] = []; + let blank = false; + let cut = false; + for (const [i, l] of lines.entries()) { + if (!l.trim()) { + if (!blank && out.length) out.push(""); + blank = true; + continue; + } + blank = false; + out.push(l); + if (out.length >= maxLines) { + cut = lines.slice(i + 1).some((rest) => rest.trim()); + break; + } + } + // Close a code block cut in the middle. + if (out.filter((l) => l.trim().startsWith("```")).length % 2) out.push("```"); + const text = out.join("\n").trim(); + return cut ? `${text}\n\n…` : text; +} + +// --------------------------------------------------------------------------- +// Formatting + +export function formatBytes(n: number | undefined): string { + if (n === undefined) return ""; + 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]}`; +} + +export function compact(n: number | undefined): string { + if (n === undefined) return ""; + if (n < 1000) return String(n); + const units = ["K", "M", "B"]; + let v = n; + let i = -1; + while (v >= 1000 && i < units.length - 1) v /= 1000, i++; + return `${v >= 100 ? Math.round(v) : v.toFixed(1).replace(/\.0$/, "")}${units[i]}`; +} + +/** "just now", "5 min ago", "3 h ago", "yesterday", "12 days ago", "3 months ago", "2 years ago". */ +export function relativeDate(iso: string | undefined, now = Date.now()): string { + if (!iso) return ""; + const t = new Date(iso).getTime(); + if (isNaN(t)) return ""; + const s = Math.max(0, Math.round((now - t) / 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`; + const d = Math.floor(s / 86400); + if (d === 1) return "yesterday"; + if (d < 45) return `${d} days ago`; + const months = Math.floor(d / 30); + if (d < 730) return `${months} month${months === 1 ? "" : "s"} ago`; + return `${Math.floor(d / 365)} years ago`; +} + +export const shortDigest = (d: string | undefined) => d ? d.replace(/^sha256:/, "").slice(0, 12) : ""; diff --git a/extensions/container-registries/lib_test.ts b/extensions/container-registries/lib_test.ts new file mode 100644 index 0000000..de7b18c --- /dev/null +++ b/extensions/container-registries/lib_test.ts @@ -0,0 +1,317 @@ +import { assert, assertEquals } from "@std/assert"; +import { + compact, + filterTags, + firstLine, + formatBytes, + fullName, + imageSize, + isIndex, + mapConfig, + mapHubRepo, + mapHubSearch, + mapHubTags, + mapQuayRepo, + mapQuaySearch, + mapQuayTags, + nextLink, + parseChallenge, + parseImageRef, + platformLabel, + platformsOf, + pullCommands, + relativeDate, + shortDigest, + shortName, + sortTagNames, + trimReadme, + webUrl, +} from "./lib.ts"; + +// Real responses (October 2026), trimmed: Docker Hub search, tags and repository; a GHCR index and manifest. +const read = async (name: string) => + JSON.parse(await Deno.readTextFile(new URL(`./testdata/${name}`, import.meta.url))); +const HUB_SEARCH = await read("hub_search.json"); +const HUB_TAGS = await read("hub_tags.json"); +const HUB_REPO = await read("hub_repo.json"); +const GHCR_INDEX = await read("ghcr_index.json"); +const GHCR_MANIFEST = await read("ghcr_manifest.json"); + +// quay.io is not reachable from here: these follow its API documentation. +const QUAY_SEARCH = { + results: [ + { + kind: "repository", + title: "repo", + namespace: { + title: "org", + kind: "org", + avatar: {}, + name: "prometheus", + score: 1, + href: "/organization/prometheus", + }, + name: "node-exporter", + description: "# Node exporter\n\nExporter for machine metrics", + is_public: true, + score: 4.2, + href: "/repository/prometheus/node-exporter", + last_modified: 1792000000, + stars: 38, + popularity: 12, + }, + { kind: "user", name: "someone", namespace: { name: "someone" } }, + ], + has_additional: false, + page: 1, +}; +const QUAY_TAGS = { + tags: [ + { + name: "v1.9.1", + reversion: false, + start_ts: 1781006400, + manifest_digest: "sha256:d00a542e409ee618a4edc67da14dd48c5da66726bbd5537ab2af9c1dfc442c8a", + is_manifest_list: true, + size: null, + last_modified: "Tue, 09 Jun 2026 12:00:00 -0000", + }, + { + name: "master", + reversion: false, + start_ts: 1792000000, + manifest_digest: "sha256:aa11", + is_manifest_list: false, + size: 11534336, + last_modified: "Wed, 14 Oct 2026 08:26:40 -0000", + }, + ], + page: 1, + has_additional: true, +}; + +Deno.test("parseImageRef reads references and pages", () => { + assertEquals(parseImageRef("nginx"), undefined); // a word to search for + assertEquals(parseImageRef("grafana/grafana"), undefined); + assertEquals(parseImageRef("node exporter"), undefined); + assertEquals(parseImageRef("nginx:"), { registry: "docker", repository: "library/nginx", tag: "" }); + assertEquals(parseImageRef("nginx:1.27"), { registry: "docker", repository: "library/nginx", tag: "1.27" }); + assertEquals(parseImageRef("docker.io/grafana/grafana"), { registry: "docker", repository: "grafana/grafana" }); + assertEquals(parseImageRef("docker.io/nginx"), { registry: "docker", repository: "library/nginx" }); + assertEquals(parseImageRef("ghcr.io/home-assistant/home-assistant:stable"), { + registry: "ghcr", + repository: "home-assistant/home-assistant", + tag: "stable", + }); + assertEquals(parseImageRef("GHCR.io/Owner/Image"), { registry: "ghcr", repository: "owner/image" }); + assertEquals(parseImageRef("quay.io/prometheus/node-exporter@sha256:abc"), { + registry: "quay", + repository: "prometheus/node-exporter", + digest: "sha256:abc", + }); + assertEquals(parseImageRef("ghcr.io/onlyone"), undefined); + assertEquals(parseImageRef("registry.example.com/x/y"), undefined); + assertEquals(parseImageRef("https://hub.docker.com/_/postgres"), { + registry: "docker", + repository: "library/postgres", + }); + assertEquals(parseImageRef("https://hub.docker.com/r/grafana/loki/tags"), { + registry: "docker", + repository: "grafana/loki", + }); + assertEquals(parseImageRef("https://quay.io/repository/coreos/etcd?tab=tags"), { + registry: "quay", + repository: "coreos/etcd", + }); + assertEquals(parseImageRef("https://github.com/orgs/home-assistant/packages/container/package/home-assistant"), { + registry: "ghcr", + repository: "home-assistant/home-assistant", + }); +}); + +Deno.test("names, pull commands and web pages", () => { + assertEquals(shortName("docker", "library/nginx"), "nginx"); + assertEquals(shortName("docker", "grafana/grafana"), "grafana/grafana"); + assertEquals(shortName("ghcr", "a/b"), "ghcr.io/a/b"); + assertEquals(fullName("docker", "library/nginx"), "docker.io/library/nginx"); + assertEquals(pullCommands("docker", "library/nginx", "1.27", "sha256:abc"), [ + { title: "docker pull", command: "docker pull nginx:1.27" }, + { title: "docker pull by digest", command: "docker pull nginx@sha256:abc" }, + { title: "podman pull", command: "podman pull docker.io/library/nginx:1.27" }, + { title: "podman pull by digest", command: "podman pull docker.io/library/nginx@sha256:abc" }, + ]); + assertEquals(pullCommands("quay", "a/b", "latest").map((c) => c.command), [ + "docker pull quay.io/a/b:latest", + "podman pull quay.io/a/b:latest", + ]); + assertEquals(webUrl("docker", "library/nginx"), "https://hub.docker.com/_/nginx"); + assertEquals(webUrl("docker", "grafana/grafana", "11.0"), "https://hub.docker.com/r/grafana/grafana/tags?name=11.0"); + assertEquals(webUrl("ghcr", "a/b", "x"), "https://ghcr.io/a/b"); + assertEquals(webUrl("quay", "a/b", "v1"), "https://quay.io/repository/a/b?tab=tags&tag=v1"); +}); + +Deno.test("mapHubSearch puts official images first", () => { + const found = mapHubSearch(HUB_SEARCH); + assertEquals(found.length, 4); + assertEquals(found[0], { + registry: "docker", + repository: "library/nginx", + description: "Official build of Nginx.", + official: true, + stars: 21413, + pulls: 13521637612, + }); + assertEquals(found[1].repository, "nginx/nginx-ingress"); + const reordered = mapHubSearch({ + results: [{ repo_name: "mcp/grafana", is_official: false }, { repo_name: "grafana", is_official: true }], + }); + assertEquals(reordered.map((r) => r.repository), ["library/grafana", "mcp/grafana"]); + assertEquals(mapHubSearch({}), []); +}); + +Deno.test("mapQuaySearch keeps repositories", () => { + assertEquals(mapQuaySearch(QUAY_SEARCH), [{ + registry: "quay", + repository: "prometheus/node-exporter", + description: "Node exporter", + stars: 38, + updated: "2026-10-14T17:46:40.000Z", + }]); +}); + +Deno.test("mapHubTags reads platforms without attestations", () => { + const tags = mapHubTags(HUB_TAGS); + assertEquals(tags.length, 2); + const t = tags[0]; + assertEquals(t.name, "trixie-perl"); + assertEquals(t.digest, "sha256:bbe0395f57ca4975ec253875174df25b342e51a2d5a64f23f8aedefad76bfe5a"); + assertEquals(t.size, 76070953); + assertEquals(t.multiArch, true); + assertEquals(t.pushed, "2026-10-28T07:53:12.670797Z"); + assertEquals(t.platforms.map(platformLabel), ["linux/amd64", "linux/ppc64le", "linux/386"]); + assertEquals(t.platforms[0].size, 76070953); +}); + +Deno.test("mapQuayTags reads sizes and dates", () => { + const tags = mapQuayTags(QUAY_TAGS); + assertEquals(tags[0], { + name: "v1.9.1", + digest: "sha256:d00a542e409ee618a4edc67da14dd48c5da66726bbd5537ab2af9c1dfc442c8a", + platforms: [], + pushed: "2026-06-09T12:00:00.000Z", + multiArch: true, + partial: true, + }); + assertEquals(tags[1].size, 11534336); + assertEquals(tags[1].multiArch, false); +}); + +Deno.test("OCI indexes, manifests and configs", () => { + assert(isIndex(GHCR_INDEX)); + assert(!isIndex(GHCR_MANIFEST)); + const platforms = platformsOf(GHCR_INDEX); + assertEquals(platforms.map(platformLabel), ["linux/amd64", "linux/arm64"]); + assertEquals(platforms[0].digest, "sha256:21935614df6d9f24f680a1ef749f0570d2d6b7936c606c0a44253ae875865c04"); + assertEquals(imageSize(GHCR_MANIFEST), 659369166); + assertEquals(imageSize(GHCR_INDEX), undefined); + assertEquals( + mapConfig({ + created: "2026-10-27T18:00:00Z", + os: "linux", + architecture: "amd64", + config: { Labels: { "org.opencontainers.image.source": "https://github.com/home-assistant/core" } }, + }), + { + created: "2026-10-27T18:00:00Z", + labels: { "org.opencontainers.image.source": "https://github.com/home-assistant/core" }, + os: "linux", + architecture: "amd64", + }, + ); + assertEquals(mapConfig({}), { labels: {} }); +}); + +Deno.test("registry auth challenges and pagination links", () => { + assertEquals( + parseChallenge('Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:a/b:pull"'), + { realm: "https://ghcr.io/token", service: "ghcr.io", scope: "repository:a/b:pull" }, + ); + assertEquals(parseChallenge('Basic realm="x"'), undefined); + assertEquals(parseChallenge(null), undefined); + assertEquals( + nextLink('; rel="next"', "https://ghcr.io/v2/a/b/tags/list?n=3"), + "https://ghcr.io/v2/a/b/tags/list?last=2021.5.0b0&n=3", + ); + assertEquals(nextLink(null, "https://ghcr.io/"), undefined); +}); + +Deno.test("sortTagNames shows latest and new versions first", () => { + assertEquals( + sortTagNames([ + "2021.5.0b0", + "1.9", + "1.10", + "1.10.1", + "v2.0", + "latest", + "dev", + "1.10-alpine", + "sha256-abc.sig", + "2021.5.0", + "beta", + ]), + [ + "latest", + "dev", + "2021.5.0", + "v2.0", + "1.10.1", + "1.10", + "1.9", + "2021.5.0b0", + "1.10-alpine", + "beta", + "sha256-abc.sig", + ], + ); + assertEquals(filterTags([{ name: "1.27" }, { name: "alpine" }, { name: "1.27-alpine" }], "alp").map((t) => t.name), [ + "alpine", + "1.27-alpine", + ]); + assertEquals(filterTags([{ name: "a" }], "").length, 1); +}); + +Deno.test("repository details and README trimming", () => { + const repo = mapHubRepo(HUB_REPO); + assertEquals(repo.description, "Official build of Nginx."); + assertEquals(repo.categories, ["Web servers"]); + assertEquals(repo.created, "2014-06-05T19:14:45Z"); + assert(repo.readme?.startsWith("# Quick reference")); + assertEquals(mapQuayRepo({ description: "## Etcd\n\nA store" }).description, "Etcd"); + assertEquals( + trimReadme( + "\n[![CI](https://b/x.svg)](https://ci) ![v](https://b/v.svg)\n# Title\n\n\n\nText\n```sh\nrun\n", + 4, + ), + "# Title\n\nText\n```sh\n```\n\n…", + ); + assertEquals(trimReadme("# A\n\nshort\n\n", 10), "# A\n\nshort"); + assertEquals(firstLine("\n## **Hello** `world`\nmore"), "Hello world"); +}); + +Deno.test("formatting", () => { + assertEquals(formatBytes(76070953), "76.1 MB"); + assertEquals(formatBytes(659369166), "659 MB"); + assertEquals(formatBytes(512), "512 B"); + assertEquals(formatBytes(undefined), ""); + assertEquals(compact(13521637612), "13.5B"); + assertEquals(compact(21413), "21.4K"); + assertEquals(compact(999), "999"); + const now = Date.parse("2026-10-28T12:00:00Z"); + assertEquals(relativeDate("2026-10-28T07:53:12Z", now), "4 h ago"); + assertEquals(relativeDate("2026-09-01T00:00:00Z", now), "1 month ago"); + assertEquals(relativeDate("2026-10-01T00:00:00Z", now), "27 days ago"); + assertEquals(relativeDate("2024-01-01T00:00:00Z", now), "2 years ago"); + assertEquals(relativeDate(undefined, now), ""); + assertEquals(shortDigest("sha256:bbe0395f57ca4975ec253875"), "bbe0395f57ca"); +}); diff --git a/extensions/container-registries/testdata/ghcr_index.json b/extensions/container-registries/testdata/ghcr_index.json new file mode 100644 index 0000000..31dfe49 --- /dev/null +++ b/extensions/container-registries/testdata/ghcr_index.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:21935614df6d9f24f680a1ef749f0570d2d6b7936c606c0a44253ae875865c04", + "size": 4488, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:477477abde9a0cf2087ab434ad55ff8a25f78970abcf1331021cec756e3441c2", + "size": 838, + "annotations": { + "vnd.docker.reference.digest": "sha256:21935614df6d9f24f680a1ef749f0570d2d6b7936c606c0a44253ae875865c04", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:5881590fca108a6e6b5eb9fff1718baeddda9d6dd5b52d1594e3386fe33b4d66", + "size": 4488, + "platform": { + "architecture": "arm64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:04b1a4943b76de5dae292c09426cb49c9051d36f824b2a45e310ea2bec60708f", + "size": 838, + "annotations": { + "vnd.docker.reference.digest": "sha256:5881590fca108a6e6b5eb9fff1718baeddda9d6dd5b52d1594e3386fe33b4d66", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] +} diff --git a/extensions/container-registries/testdata/ghcr_manifest.json b/extensions/container-registries/testdata/ghcr_manifest.json new file mode 100644 index 0000000..98162cb --- /dev/null +++ b/extensions/container-registries/testdata/ghcr_manifest.json @@ -0,0 +1,121 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:8e7d93e48bed8980ec6924f8620928efa3bf981e3d33aa3be047944f37c6cd3a", + "size": 14626 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:6f6d00869972c40dea99d3d113335842e71ee3a6f9ce362a0f152de7983aed94", + "size": 3812482 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:f70c031ec4c7a94677380d807bd430da1190d7d242c0117ebbad09aec5b2759d", + "size": 14005922 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:a5bae240f110da55caa852b457cccb6b1de72bb880f28918d781f41530a9ef88", + "size": 22411840 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:663ba23e7bdd4d0f0e1a18127f26795fee2bf66c437609282fd5daa39341c5ab", + "size": 58734181 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:26189a060807dfc03306cf07be8844aef21777c903c4e7dd115f0b93263a3a6b", + "size": 4491052 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:2c1ce468d9f3d941396801f6e3afc8921466650dd05430fe644cd3537713d27f", + "size": 16 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:ac22de5c38e442b4a300cef639078ed4c2d07ce7ed90cda56b2ba9eb1411d5b0", + "size": 66839 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:1fdbf8c0c7ecd1817d3ab734781827147838934ed9f52ace68d48d1ee42972c9", + "size": 839002 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:e0ffbe8322d3a128afeb07b847f281b8d70c522b26afbf5f527ef61907922c44", + "size": 181 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:5a67b005504f9f828c9c3e7fff8a11bef780d7281cfec02ca019dc836090f7cc", + "size": 5534860 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:c3b19c441492ce3935f7eafd0f8d6969f5ed674530998e610f94de70f27ddb77", + "size": 238198 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:0c6526c8cc9038c8b62f9939729756d75a1eadc4ed79996c8dcc7e2d7f33d0da", + "size": 9083 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:2c1ce468d9f3d941396801f6e3afc8921466650dd05430fe644cd3537713d27f", + "size": 16 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:2026f6aa424d970a82029f6b19a9fde5f214dc16fa91e2238b3f87588185f027", + "size": 1675 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:72631b3b14aeeb271233122d951ab11722b9a41af1d94ddd154a380916908d43", + "size": 7400982 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:6948d38c7f046cda118649bc15e3b9e59167a3ac8e563645abdce546051759e0", + "size": 4286 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:92206108c403bcca48b2931cb1c9b760356c9c6224da57ab07775303f15c64a5", + "size": 87182823 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:11bee54cc43853106661a0c2e132a3ac73f3022e764a86de7db96bd47fae468f", + "size": 16696 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:5f64659b25ef033f4cc83c6703db0a0b1e488db4b14a44599f81429712f4afca", + "size": 399359294 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:8872abf3b64b602580ee09ed1f60ed17478fecd8714734f5c30d4dbacec82b70", + "size": 31422065 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:034502faa0b719aa2a0575126775b717f389e3990057c2e9a846245f22953c52", + "size": 23822958 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": "sha256:67bb31f8ee551d9011692d770c1fc3dd6ca30d4d5b829c48545d1316f3bbe7f8", + "size": 89 + } + ] +} diff --git a/extensions/container-registries/testdata/hub_repo.json b/extensions/container-registries/testdata/hub_repo.json new file mode 100644 index 0000000..c204b67 --- /dev/null +++ b/extensions/container-registries/testdata/hub_repo.json @@ -0,0 +1,51 @@ +{ + "user": "library", + "name": "nginx", + "namespace": "library", + "repository_type": "image", + "status": 1, + "status_description": "active", + "description": "Official build of Nginx.", + "is_private": false, + "is_automated": false, + "star_count": 21413, + "pull_count": 13522878169, + "last_updated": "2026-10-28T07:53:14.862135Z", + "last_modified": "2026-10-21T22:17:12.294204Z", + "date_registered": "2014-06-05T19:14:45Z", + "collaborator_count": 0, + "affiliation": null, + "hub_user": "stackbrew", + "has_starred": false, + "full_description": "# Quick reference\n\n-\t**Maintained by**: \n\t[the NGINX Docker Maintainers](https://github.com/nginx/docker-nginx)\n\n-\t**Where to get help**: \n\t[the Docker Community Slack](https://dockr.ly/comm-slack), [Server Fault](https://serverfault.com/help/on-topic), [Unix & Linux](https://unix.stackexchange.com/help/on-topic), or [Stack Overflow](https://stackoverflow.com/help/on-topic)\n\n# Supported tags and respective `Dockerfile` links\n\n-\t[`1.31.8`, `mainline`, `1`, `1.31`, `latest`, `1.31.8-trixie`, `mainline-trixie`, `1-trixie`, `1.31-trixie`, `trixie`](https://github.com/nginx/docker-nginx/blob/6fabb4c960a02a1e576d2789974c45f4cd2aa854/mainline/debian/Dockerfile)\n\n-\t[`1.31.8-perl`, `mainline-perl`, `1-perl`, `1.31-perl`, `perl`, `1.31.8-trixie-perl`, `mainline-trixie-perl`, `1-trixie-perl`, `1.31-trixie-perl`, `trixie-perl`](https://github.com/nginx/docker-nginx/blob/6fabb4c960a02a1e576d2789974c45f4cd2aa854/mainline/debian-perl/Dockerfile)\n\n-\t[`1.31.8-otel`, `mainline-otel`, `1-otel`, `1.31-otel`, `otel`, `1.31.8-trixie-otel`, `mainline-trixie-otel`, `1-trixie-otel`, `1.31-trixie-otel`, `trixie-otel`](https://github.com/nginx/docker-nginx/blob/6fabb4c960a02a1e576d2789974c45f4cd2aa854/mainline/debian-otel/Dockerfile)\n\n-\t[`1.31.8-alpine`, `mainline-alpine`, `1-alpine`, `1.31-alpine`, `alpine`, `1.31.8-alpine3.24`, `mainline-alpine3.24`, `1-alpine3.24`, `1.31-alpine3.24`, `alpine3.24`](https://github.com/nginx/docker-nginx/blob/6fabb4c960a02a1e576d2789974c45f4cd2aa854/mainline/alpine/D", + "permissions": { + "read": true, + "write": false, + "admin": false + }, + "media_types": [ + "application/octet-stream", + "application/vnd.docker.container.image.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v1+prettyjws", + "application/vnd.oci.image.index.v1+json" + ], + "content_types": [ + "unrecognized", + "image" + ], + "categories": [ + { + "name": "Web servers", + "slug": "web-servers" + } + ], + "immutable_tags_settings": { + "enabled": false, + "rules": [ + ".*" + ] + }, + "storage_size": 258741118340, + "source": null +} diff --git a/extensions/container-registries/testdata/hub_search.json b/extensions/container-registries/testdata/hub_search.json new file mode 100644 index 0000000..7e17a2c --- /dev/null +++ b/extensions/container-registries/testdata/hub_search.json @@ -0,0 +1,43 @@ +{ + "count": 295506, + "next": "https://hub.docker.com/v2/search/repositories/?page=2&page_size=4&query=nginx", + "previous": "", + "results": [ + { + "repo_name": "nginx", + "short_description": "Official build of Nginx.", + "star_count": 21413, + "pull_count": 13521637612, + "repo_owner": "", + "is_automated": false, + "is_official": true + }, + { + "repo_name": "nginx/nginx-ingress", + "short_description": "NGINX and NGINX Plus Ingress Controllers for Kubernetes", + "star_count": 124, + "pull_count": 1088218683, + "repo_owner": "", + "is_automated": false, + "is_official": false + }, + { + "repo_name": "nginx/nginx-prometheus-exporter", + "short_description": "NGINX Prometheus Exporter for NGINX and NGINX Plus", + "star_count": 53, + "pull_count": 91980314, + "repo_owner": "", + "is_automated": false, + "is_official": false + }, + { + "repo_name": "nginx/nginx-ingress-operator", + "short_description": "NGINX Ingress Operator for NGINX and NGINX Plus Ingress Controllers. Based on the Helm chart for NGI", + "star_count": 5, + "pull_count": 1309970, + "repo_owner": "", + "is_automated": false, + "is_official": false + } + ] +} diff --git a/extensions/container-registries/testdata/hub_tags.json b/extensions/container-registries/testdata/hub_tags.json new file mode 100644 index 0000000..8ec12a5 --- /dev/null +++ b/extensions/container-registries/testdata/hub_tags.json @@ -0,0 +1,149 @@ +{ + "count": 1381, + "next": "https://hub.docker.com/v2/repositories/library/nginx/tags?ordering=last_updated&page=2&page_size=2", + "previous": null, + "results": [ + { + "creator": 1156886, + "id": 987274600, + "images": [ + { + "architecture": "amd64", + "features": "", + "variant": null, + "digest": "sha256:283e2845eb33ca01dfb66d05a0d53b6992cf1c54c1f87a615ff8058793b4a7ab", + "os": "linux", + "os_features": "", + "os_version": null, + "size": 76070953, + "status": "active", + "last_pulled": "2026-10-28T12:28:56.199858808Z", + "last_pushed": "2026-10-28T07:50:29.382912972Z" + }, + { + "architecture": "ppc64le", + "features": "", + "variant": null, + "digest": "sha256:4c739ffe00cc87f115a1fd143619a48e3b9e1dca088d186116473d4cea26da79", + "os": "linux", + "os_features": "", + "os_version": null, + "size": 81120296, + "status": "active", + "last_pulled": "2026-10-28T12:30:40.333526713Z", + "last_pushed": "2026-10-22T13:50:30.110996894Z" + }, + { + "architecture": "386", + "features": "", + "variant": null, + "digest": "sha256:dad7787e8885a86a4996446924ed58aa79f1650ed61038ef68a8ede28ea867a8", + "os": "linux", + "os_features": "", + "os_version": null, + "size": 76629716, + "status": "active", + "last_pulled": "2026-10-28T12:24:29.981325347Z", + "last_pushed": "2026-10-28T07:50:30.132538455Z" + }, + { + "architecture": "unknown", + "features": "", + "variant": null, + "digest": "sha256:129d553d7e6a03cf2dc11520781a14ea9f6bf1ea5b63ca8537c0ac9e9badfc88", + "os": "unknown", + "os_features": "", + "os_version": null, + "size": 4283579, + "status": "active", + "last_pulled": "2026-10-28T12:30:40.512079316Z", + "last_pushed": "2026-10-28T04:50:58.365831794Z" + } + ], + "last_updated": "2026-10-28T07:53:12.670797Z", + "last_updater": 1156886, + "last_updater_username": "doijanky", + "name": "trixie-perl", + "repository": 21171, + "full_size": 76070953, + "v2": true, + "tag_status": "active", + "tag_last_pulled": "2026-10-28T12:30:40.745151613Z", + "tag_last_pushed": "2026-10-28T07:53:12.670797Z", + "media_type": "application/vnd.oci.image.index.v1+json", + "content_type": "image", + "digest": "sha256:bbe0395f57ca4975ec253875174df25b342e51a2d5a64f23f8aedefad76bfe5a" + }, + { + "creator": 1156886, + "id": 987274577, + "images": [ + { + "architecture": "amd64", + "features": "", + "variant": null, + "digest": "sha256:1e90addcf987500831a3ae6d397bae1f80cb03499784c47603c59fe61f94fdd7", + "os": "linux", + "os_features": "", + "os_version": null, + "size": 67709392, + "status": "active", + "last_pulled": "2026-10-28T12:20:08.746334439Z", + "last_pushed": "2026-10-28T07:50:21.299520643Z" + }, + { + "architecture": "arm64", + "features": "", + "variant": "v8", + "digest": "sha256:a9334748a640a94931c8379c003a796d0a30121f2247de8a28ad980cb97471d8", + "os": "linux", + "os_features": "", + "os_version": null, + "size": 65827482, + "status": "active", + "last_pulled": "2026-10-28T12:23:41.637470593Z", + "last_pushed": "2026-10-28T04:50:45.613178108Z" + }, + { + "architecture": "unknown", + "features": "", + "variant": null, + "digest": "sha256:ef6cd50621efa52669303f49856ab842841e9dff49bbf40b589a3aface332bb4", + "os": "unknown", + "os_features": "", + "os_version": null, + "size": 2865866, + "status": "active", + "last_pulled": "2026-10-28T12:23:42.203739065Z", + "last_pushed": "2026-10-28T04:50:46.048919101Z" + }, + { + "architecture": "unknown", + "features": "", + "variant": null, + "digest": "sha256:ef6cd50621efa52669303f49856ab842841e9dff49bbf40b589a3aface332bb4", + "os": "unknown", + "os_features": "", + "os_version": null, + "size": 2865866, + "status": "active", + "last_pulled": "2026-10-28T12:23:42.203739065Z", + "last_pushed": "2026-10-28T04:50:46.048919101Z" + } + ], + "last_updated": "2026-10-28T07:53:09.818488Z", + "last_updater": 1156886, + "last_updater_username": "doijanky", + "name": "trixie-otel", + "repository": 21171, + "full_size": 67709392, + "v2": true, + "tag_status": "active", + "tag_last_pulled": "2026-10-28T12:25:02.368798169Z", + "tag_last_pushed": "2026-10-28T07:53:09.818488Z", + "media_type": "application/vnd.oci.image.index.v1+json", + "content_type": "image", + "digest": "sha256:167dacee35422d64f20d88082b812de054a2afb63ca298db44278e489a6c33ac" + } + ] +} diff --git a/extensions/firewall/backslash.toml b/extensions/firewall/backslash.toml new file mode 100644 index 0000000..d690533 --- /dev/null +++ b/extensions/firewall/backslash.toml @@ -0,0 +1,43 @@ +[extension] +id = "firewall" +title = "Firewall" +description = "State, default policies and rules of ufw or firewalld; allow or block a port, delete a rule, turn the firewall on or off" +version = "1.0.0" +author = "Backslash" +icon = "bs-shield" +license = "MIT" + +# ufw's state and default policies are read from its configuration files (no root); +# its rules and every change need root, asked once per change through pkexec (polkit). +# firewall-cmd lists firewalld's zones as a user; changes run through pkexec too, as +# one script so that runtime and permanent configuration change with one prompt. +[permissions] +read = ["/etc/ufw/ufw.conf", "/etc/default/ufw", "/etc/os-release"] +run = ["firewall-cmd", "pkexec"] + +[[commands]] +name = "firewall" +title = "Firewall" +description = "ufw or firewalld: state, default policies, rules and zones" +icon = "bs-shield" +keywords = [ + "firewall", + "pare-feu", + "parefeu", + "ufw", + "firewalld", + "gufw", + "port", + "ports", + "ouvrir un port", + "open port", + "allow", + "autoriser", + "block", + "bloquer", + "rules", + "règles", + "zone", + "security", + "sécurité", +] diff --git a/extensions/firewall/commands/firewall.ts b/extensions/firewall/commands/firewall.ts new file mode 100644 index 0000000..a28a137 --- /dev/null +++ b/extensions/firewall/commands/firewall.ts @@ -0,0 +1,627 @@ +import { + type Action, + action, + type Color, + type Context, + copy, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + pop, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + describeRich, + firewalldAddArgs, + firewalldScript, + installHint, + parseUfwConfig, + parseUfwScript, + parseZones, + pkexecError, + type Protocol, + removalArgs, + ruleCommand, + type RuleInput, + ruleSentence, + ruleTitle, + shellLine, + sortZones, + UFW_STATUS_SCRIPT, + ufwAddCommands, + type UfwConfig, + ufwDeleteArgs, + type UfwRule, + type UfwSnapshot, + validatePort, + validateSource, + type Zone, +} from "../lib.ts"; + +interface Output { + code: number; + stdout: string; + stderr: string; +} + +async function sh(program: string, args: string[], timeout = 60_000): Promise { + const { code, stdout, stderr } = await new Deno.Command(program, { + args, + stdin: "null", + stdout: "piped", + stderr: "piped", + env: { LC_ALL: "C" }, + signal: AbortSignal.timeout(timeout), + }).output(); + const text = (b: Uint8Array) => new TextDecoder().decode(b); + return { code, stdout: text(stdout), stderr: text(stderr).trim() }; +} + +/** Runs a script as root, after polkit asks for the password once. */ +async function asRoot(script: string): Promise { + let o: Output; + try { + // Two minutes to type the password. + o = await sh("pkexec", ["/bin/sh", "-c", script], 120_000); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("pkexec (polkit) is needed to change the firewall"); + throw e; + } + const denied = pkexecError(o.code, o.stderr); + if (denied) throw new Error(denied); + if (o.code !== 0) throw new Error(o.stderr.split("\n").filter(Boolean).pop() || `Exited with ${o.code}`); + return o.stdout; +} + +// --- State --------------------------------------------------------------------- + +/** What `ufw status` said as root, kept while the command is open. */ +let ufwRoot: UfwSnapshot | undefined; + +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 ufwConfig(): Promise { + let conf: string; + try { + conf = await Deno.readTextFile("/etc/ufw/ufw.conf"); + } catch { + return undefined; + } + return parseUfwConfig(conf, await Deno.readTextFile("/etc/default/ufw").catch(() => "")); +} + +/** As root, then the fresh rules, in one password prompt. */ +async function ufwRun(commands: string[][], message: string) { + // The status only after the change succeeded, so its error isn't hidden. + const script = `${commands.map((c) => shellLine(["ufw", ...c])).join(" && ")} && { ${UFW_STATUS_SCRIPT}; }`; + ufwRoot = parseUfwScript(await asRoot(script)); + await toast(message, { style: "success" }); + changed(); +} + +type Firewalld = { state: "missing" } | { state: "stopped" } | { state: "running"; zones: Zone[]; error?: string }; + +async function firewalld(): Promise { + let state: Output; + try { + state = await sh("firewall-cmd", ["--state"], 10_000); + } catch (e) { + if (e instanceof Deno.errors.NotFound || e instanceof Deno.errors.NotCapable) return { state: "missing" }; + throw e; + } + if (state.code !== 0 || !/running/.test(state.stdout) || /not running/.test(state.stdout + state.stderr)) { + return { state: "stopped" }; + } + const all = await sh("firewall-cmd", ["--list-all-zones"]); + if (all.code !== 0) { + return { state: "running", zones: [], error: all.stderr || `firewall-cmd exited with ${all.code}` }; + } + return { state: "running", zones: sortZones(parseZones(all.stdout)) }; +} + +async function firewalldRun(script: string, message: string) { + await asRoot(script); + await toast(message, { style: "success" }); + changed(); +} + +// --- Forms --------------------------------------------------------------------- + +function ruleForm(o: { kind: "ufw" } | { kind: "firewalld"; zones: Zone[]; zone: string }, action: "allow" | "deny") { + const ufw = o.kind === "ufw"; + return form({ + title: action === "allow" ? "Allow a Port" : "Block a Port", + submitTitle: action === "allow" ? "Allow" : "Block", + fields: [ + field.text("port", "Port", { placeholder: "22, 8000-8100, 80,443 or a service: ssh", required: true }), + field.dropdown("protocol", "Protocol", [ + { value: "tcp", title: "TCP" }, + { value: "udp", title: "UDP" }, + { value: "any", title: "TCP and UDP" }, + ], { value: "tcp" }), + field.text("from", "From", { placeholder: "Anywhere, or 192.168.1.0/24" }), + field.dropdown( + "action", + "Action", + ufw + ? [ + { value: "allow", title: "Allow" }, + { value: "limit", title: "Allow, limiting repeated connections (SSH)" }, + { value: "deny", title: "Deny (drop silently)" }, + { value: "reject", title: "Reject (tell the sender)" }, + ] + : [ + { value: "allow", title: "Allow" }, + { value: "deny", title: "Drop silently" }, + { value: "reject", title: "Reject (tell the sender)" }, + ], + { value: action }, + ), + ...(ufw ? [field.text("comment", "Comment", { placeholder: "What it is for" })] : [field.dropdown( + "zone", + "Zone", + o.zones.map((z) => ({ + value: z.name, + title: `${z.name}${z.default ? " (default)" : z.active ? " (active)" : ""}`, + })), + { value: o.zone }, + )]), + ], + async submit(values) { + const input: RuleInput = { + action: String(values.action) as RuleInput["action"], + port: String(values.port ?? ""), + protocol: String(values.protocol ?? "tcp") as Protocol, + from: String(values.from ?? ""), + comment: String(values.comment ?? ""), + }; + const problem = validatePort(input.port) ?? validateSource(input.from); + if (problem) throw new Error(problem); + const verb = input.action === "allow" || input.action === "limit" ? "Allowed" : "Blocked"; + if (o.kind === "ufw") { + await ufwRun(ufwAddCommands(input), `${verb} ${input.port}`); + } else { + const zone = String(values.zone || o.zone); + await firewalldRun(firewalldScript(zone, firewalldAddArgs(input)), `${verb} ${input.port} in ${zone}`); + } + return pop(); + }, + }); +} + +// --- ufw items ---------------------------------------------------------------- + +const ACTION_LOOK: Record = { + ALLOW: { tint: "green", icon: Icons.check, label: "Allow" }, + LIMIT: { tint: "yellow", icon: Icons.timer, label: "Limit" }, + DENY: { tint: "red", icon: Icons.cross, label: "Deny" }, + REJECT: { tint: "orange", icon: Icons.cross, label: "Reject" }, +}; + +function ufwCommon(): Action[] { + return [ + action("Allow a Port…", () => ruleForm({ kind: "ufw" }, "allow"), { + icon: Icons.plus, + shortcut: "n", + section: "Rules", + }), + action("Block a Port…", () => ruleForm({ kind: "ufw" }, "deny"), { + icon: Icons.shield, + shortcut: "b", + section: "Rules", + }), + action(ufwRoot ? "Reload Rules" : "Show Rules", async () => { + ufwRoot = parseUfwScript(await asRoot(UFW_STATUS_SCRIPT)); + changed(); + }, { icon: Icons.refresh, shortcut: "r", section: "Rules" }), + ]; +} + +function ufwStatusItem(conf: UfwConfig): Item { + const active = ufwRoot ? ufwRoot.status.active : conf.enabled; + const s = ufwRoot?.status; + const incoming = s?.incoming ?? conf.incoming ?? "?", outgoing = s?.outgoing ?? conf.outgoing ?? "?"; + const routed = s?.routed ?? conf.routed; + const tint: Color = active ? "green" : "red"; + const metadata: Metadata[] = [ + meta.text("Firewall", active ? "Active" : "Inactive", { icon: { source: Icons.shield, tint }, color: tint }), + meta.text("Incoming", incoming, { icon: Icons.arrowDown, color: incoming === "allow" ? "orange" : undefined }), + meta.text("Outgoing", outgoing, { icon: Icons.arrowUp }), + ...(routed ? [meta.text("Routed", routed, { icon: Icons.arrowRight })] : []), + meta.text("Logging", s?.logging ?? conf.logLevel ?? "?", { icon: Icons.note }), + ...(ufwRoot ? [meta.text("Rules", String(ufwRoot.rules.length), { icon: Icons.layers })] : []), + ]; + const toggle = active + ? action("Disable Firewall", () => ufwRun([["disable"]], "Firewall disabled"), { + icon: Icons.power, + destructive: true, + confirm: "Turn the firewall off? Every port becomes reachable", + section: "Firewall", + }) + : action("Enable Firewall", () => ufwRun([["--force", "enable"]], "Firewall enabled"), { + icon: Icons.power, + confirm: incoming === "allow" + ? "Enable the firewall? Incoming connections are allowed by default" + : "Enable the firewall? Incoming connections without a rule are blocked (SSH too)", + section: "Firewall", + }); + const common = ufwCommon(); + return { + id: "ufw", + title: `ufw · ${active ? "Active" : "Inactive"}`, + subtitle: `Incoming ${incoming} · Outgoing ${outgoing}`, + icon: { source: Icons.shield, tint }, + section: "Status", + keywords: ["ufw", "status", active ? "enabled" : "disabled"], + accessories: [ + ...(ufwRoot + ? [{ text: `${ufwRoot.rules.length} rules` }] + : [{ text: "Rules need your password", icon: Icons.lock }]), + { tag: active ? "On" : "Off", color: tint }, + ], + detail: { + stats: [ + { label: "State", value: active ? "Active" : "Inactive", color: tint }, + { label: "Incoming", value: incoming }, + { label: "Outgoing", value: outgoing }, + ], + markdown: ufwRoot + ? undefined + : "Rules are readable by root only: **Enter** shows them after asking for your password.", + metadata, + }, + actions: ufwRoot + ? [common[0], toggle, ...common.filter((_, i) => i !== 0)] + : [common[2], common[0], common[1], toggle], + }; +} + +function ufwRuleItem(r: UfwRule): Item { + const look = ACTION_LOOK[r.action]; + const title = ruleTitle(r); + return { + id: `ufw:${r.number ?? r.spec}:${r.to}:${r.from}:${r.v6}`, + title: r.comment ? `${title} · ${r.comment}` : title, + subtitle: ruleSentence(r), + icon: { source: look.icon, tint: look.tint }, + section: "Rules", + keywords: [r.to, r.from, r.action, r.comment ?? "", r.v6 ? "ipv6" : "ipv4"], + accessories: [ + ...(r.v6 ? [{ tag: "IPv6", color: "purple" as const }] : []), + ...(r.direction !== "IN" ? [{ tag: r.direction === "OUT" ? "Out" : "Forward", color: "blue" as const }] : []), + { tag: look.label, color: look.tint }, + ...(r.number !== undefined ? [{ text: `#${r.number}`, tooltip: "Rule number" }] : []), + ], + detail: { + metadata: [ + meta.text("Action", look.label, { icon: { source: look.icon, tint: look.tint }, color: look.tint }), + meta.text("To", r.to, { icon: Icons.plug }), + meta.text("From", r.from, { icon: Icons.globe }), + meta.text("Direction", { IN: "Incoming", OUT: "Outgoing", FWD: "Forwarded" }[r.direction], { + icon: r.direction === "OUT" ? Icons.arrowUp : Icons.arrowDown, + }), + meta.text("IP version", r.v6 ? "IPv6" : "IPv4", { icon: Icons.hash }), + ...(r.comment ? [meta.text("Comment", r.comment, { icon: Icons.comment })] : []), + ...(r.log ? [meta.text("Logged", "Yes", { icon: Icons.note })] : []), + meta.separator(), + meta.text("Command", ruleCommand(r), { icon: Icons.terminal }), + ], + }, + actions: [ + action("Delete Rule", () => ufwRun([ufwDeleteArgs(r)], `Rule ${title} deleted`), { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${ruleSentence(r)}” to ${title}${r.v6 ? " (IPv6)" : ""}?`, + shortcut: "Delete", + section: "Danger", + }), + ...ufwCommon(), + copy(ruleCommand(r), "Copy as Command", { section: "Copy", shortcut: "c" }), + ], + }; +} + +// --- firewalld items ----------------------------------------------------------- + +function zoneItem(z: Zone, fw: Firewalld & { state: "running" }): Item { + const tint: Color = z.active ? "green" : "gray"; + const allowsAll = z.target === "ACCEPT"; + const add = (a: "allow" | "deny") => + action( + a === "allow" ? "Allow a Port…" : "Block a Port…", + () => ruleForm({ kind: "firewalld", zones: fw.zones, zone: z.name }, a), + { + icon: a === "allow" ? Icons.plus : Icons.shield, + shortcut: a === "allow" ? "n" : "b", + section: "Rules", + }, + ); + const count = z.services.length + z.ports.length + z.richRules.length; + return { + id: `zone:${z.name}`, + title: `Zone ${z.name}`, + subtitle: [ + z.default ? "Default" : z.active ? "Active" : "Inactive", + z.interfaces.length ? z.interfaces.join(", ") : "", + z.sources.length ? z.sources.join(", ") : "", + ].filter(Boolean).join(" · "), + icon: { source: Icons.layers, tint }, + section: z.active || z.default ? "Active Zones" : "Other Zones", + keywords: ["zone", z.name, ...z.interfaces, ...z.sources], + accessories: [ + { text: `${count} rule${count === 1 ? "" : "s"}` }, + ...(z.default ? [{ tag: "Default", color: "blue" as const }] : []), + { tag: z.active ? "Active" : "Inactive", color: tint }, + ], + detail: { + stats: [ + { label: "Services", value: z.services.length }, + { label: "Ports", value: z.ports.length }, + { label: "Rich rules", value: z.richRules.length }, + ], + metadata: [ + meta.text("Target", z.target ?? "default", { + icon: Icons.shield, + color: allowsAll ? "orange" : undefined, + }), + meta.tags("Interfaces", z.interfaces.length ? z.interfaces : ["None"]), + meta.tags("Sources", z.sources.length ? z.sources : ["None"]), + meta.tags( + "Services", + z.services.length ? z.services.map((s) => ({ text: s, color: "green" as const })) : ["None"], + ), + meta.tags("Ports", z.ports.length ? z.ports.map((p) => ({ text: p, color: "green" as const })) : ["None"]), + meta.text("Masquerade", z.masquerade ? "Yes" : "No", { icon: Icons.eyeOff }), + ...(z.forwardPorts.length + ? [meta.text("Forwarded ports", z.forwardPorts.join(", "), { icon: Icons.arrowRight })] + : []), + ...(z.icmpBlocks.length ? [meta.tags("ICMP blocked", z.icmpBlocks)] : []), + ], + }, + actions: [ + add("allow"), + add("deny"), + copy(`firewall-cmd --zone=${z.name} --list-all`, "Copy List Command", { section: "Copy" }), + ], + }; +} + +function zoneRuleItems(z: Zone, fw: Firewalld & { state: "running" }): Item[] { + const section = `Zone ${z.name}`; + const remove = (kind: "port" | "service" | "rich-rule", value: string, label: string) => + action( + "Remove Rule", + () => firewalldRun(firewalldScript(z.name, removalArgs(kind, value)), `${label} removed from ${z.name}`), + { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${label} from zone ${z.name}?`, + shortcut: "Delete", + section: "Danger", + }, + ); + const add = action("Allow a Port…", () => ruleForm({ kind: "firewalld", zones: fw.zones, zone: z.name }, "allow"), { + icon: Icons.plus, + shortcut: "n", + section: "Rules", + }); + const items: Item[] = []; + for (const s of z.services) { + items.push({ + id: `${z.name}:service:${s}`, + title: s, + subtitle: "Service allowed", + icon: { source: Icons.box, tint: "green" }, + section, + keywords: ["service", z.name], + accessories: [{ tag: "Allow", color: "green" }], + detail: { + metadata: [ + meta.text("Service", s, { icon: Icons.box }), + meta.text("Zone", z.name, { icon: Icons.layers }), + meta.text("Ports", "As defined by firewalld (firewall-cmd --info-service)", { icon: Icons.plug }), + ], + }, + actions: [ + remove("service", s, `service ${s}`), + add, + copy(`firewall-cmd --info-service=${s}`, "Copy Info Command", { section: "Copy" }), + ], + }); + } + for (const p of z.ports) { + items.push({ + id: `${z.name}:port:${p}`, + title: p, + subtitle: "Port allowed", + icon: { source: Icons.plug, tint: "green" }, + section, + keywords: ["port", z.name], + accessories: [{ tag: "Allow", color: "green" }], + detail: { + metadata: [meta.text("Port", p, { icon: Icons.plug }), meta.text("Zone", z.name, { icon: Icons.layers })], + }, + actions: [remove("port", p, `port ${p}`), add, copy(p, "Copy Port", { section: "Copy" })], + }); + } + for (const rule of z.richRules) { + const d = describeRich(rule); + const allow = d.action === "Allow"; + const what = d.port ?? d.service ?? "Any port"; + items.push({ + id: `${z.name}:rich:${rule}`, + title: what, + subtitle: `${d.action} from ${d.from ?? "anywhere"}`, + icon: { source: allow ? Icons.check : Icons.cross, tint: allow ? "green" : "red" }, + section, + keywords: ["rich rule", z.name, rule], + accessories: [{ tag: d.action, color: allow ? "green" : "red" }], + detail: { + markdown: `\`\`\`\n${rule}\n\`\`\``, + metadata: [ + meta.text("Action", d.action, { icon: allow ? Icons.check : Icons.cross, color: allow ? "green" : "red" }), + meta.text("To", what, { icon: Icons.plug }), + meta.text("From", d.from ?? "Anywhere", { icon: Icons.globe }), + meta.text("Zone", z.name, { icon: Icons.layers }), + ], + }, + actions: [remove("rich-rule", rule, `rule ${what}`), add, copy(rule, "Copy Rich Rule", { section: "Copy" })], + }); + } + return items; +} + +function firewalldItems(fw: Firewalld): Item[] { + if (fw.state === "missing") return []; + if (fw.state === "stopped") { + return [{ + id: "firewalld", + title: "firewalld · Not Running", + subtitle: "Every port is reachable", + icon: { source: Icons.shield, tint: "red" }, + section: "Status", + accessories: [{ tag: "Off", color: "red" }], + detail: { markdown: "firewalld is installed but stopped. **Enter** starts it and enables it at boot." }, + actions: [ + action("Start firewalld", () => firewalldRun("systemctl enable --now firewalld", "firewalld started"), { + icon: Icons.power, + confirm: "Start firewalld? Connections the zones don't allow are blocked", + }), + ], + }]; + } + const def = fw.zones.find((z) => z.default); + const active = fw.zones.filter((z) => z.active || z.default); + const status: Item = { + id: "firewalld", + title: "firewalld · Running", + subtitle: `Default zone ${def?.name ?? "?"}${active.length > 1 ? ` · ${active.length} active zones` : ""}`, + icon: { source: Icons.shield, tint: "green" }, + section: "Status", + keywords: ["firewalld", "status"], + accessories: [{ tag: "On", color: "green" }], + detail: { + stats: [ + { label: "State", value: "Running", color: "green" }, + { label: "Default zone", value: def?.name ?? "?" }, + { label: "Active zones", value: active.length }, + ], + markdown: fw.error ? `Couldn't list the zones:\n\n\`\`\`\n${fw.error}\n\`\`\`` : undefined, + metadata: [ + meta.tags("Active zones", active.map((z) => ({ text: z.name, color: "green" as const }))), + meta.text("Zones", String(fw.zones.length), { icon: Icons.layers }), + ], + }, + actions: [ + ...(def + ? [action("Allow a Port…", () => ruleForm({ kind: "firewalld", zones: fw.zones, zone: def.name }, "allow"), { + icon: Icons.plus, + shortcut: "n", + })] + : []), + action("Stop firewalld", () => firewalldRun("systemctl disable --now firewalld", "firewalld stopped"), { + icon: Icons.power, + destructive: true, + confirm: "Stop firewalld and disable it at boot? Every port becomes reachable", + section: "Firewall", + }), + ], + }; + return [ + status, + ...active.map((z) => zoneItem(z, fw)), + ...active.flatMap((z) => zoneRuleItems(z, fw)), + ...fw.zones.filter((z) => !active.includes(z)).map((z) => zoneItem(z, fw)), + ]; +} + +// --- Command ------------------------------------------------------------------- + +async function emptyItems(): Promise { + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const hint = installHint(os); + return [{ + id: "install", + title: "No firewall found", + subtitle: `Install ${hint.tool}`, + icon: { source: Icons.shield, tint: "orange" }, + detail: { + markdown: [ + "## No firewall installed", + "", + "Backslash knows **ufw** (Ubuntu, Debian, Arch) and **firewalld** (Fedora, openSUSE, RHEL). On this system:", + "", + "```", + hint.command, + "```", + ].join("\n"), + }, + actions: [ + copy(hint.command, "Copy Install Command"), + open( + hint.tool === "ufw" ? "https://help.ubuntu.com/community/UFW" : "https://firewalld.org/documentation/", + `${hint.tool} Documentation`, + { icon: Icons.book }, + ), + ], + }]; +} + +export default (_ctx: Context) => { + let shown = ""; + return list({ + placeholder: "Filter rules by port, address, zone…", + empty: { title: "No rule", description: "Ctrl+N allows a port", icon: Icons.shield }, + status: () => shown, + items: ({ signal }) => + live(async () => { + const [conf, fw] = await Promise.all([ufwConfig(), firewalld()]); + if (!conf && fw.state === "missing") { + shown = ""; + return await emptyItems(); + } + const items: Item[] = []; + const parts: string[] = []; + if (conf) { + items.push(ufwStatusItem(conf)); + if (ufwRoot) items.push(...ufwRoot.rules.map(ufwRuleItem)); + const active = ufwRoot ? ufwRoot.status.active : conf.enabled; + parts.push(`ufw ${active ? "active" : "inactive"}${ufwRoot ? ` · ${ufwRoot.rules.length} rules` : ""}`); + } + items.push(...firewalldItems(fw)); + if (fw.state !== "missing") { + parts.push( + fw.state === "running" + ? `firewalld running · zone ${fw.zones.find((z) => z.default)?.name ?? "?"}` + : "firewalld stopped", + ); + } + shown = parts.join(" · "); + const on = (conf && (ufwRoot ? ufwRoot.status.active : conf.enabled)) || fw.state === "running"; + updateCommandMetadata({ subtitle: on ? "Active" : "Off" }).catch(() => {}); + return items; + }, signal), + }); +}; diff --git a/extensions/firewall/lib.ts b/extensions/firewall/lib.ts new file mode 100644 index 0000000..9014d53 --- /dev/null +++ b/extensions/firewall/lib.ts @@ -0,0 +1,471 @@ +// ufw and firewalld outputs, and the commands that change them. No I/O here. + +// --------------------------------------------------------------------------- +// Shell + +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(" "); + +// --------------------------------------------------------------------------- +// ufw configuration files (readable without root) + +export type Policy = "allow" | "deny" | "reject"; + +export interface UfwConfig { + enabled: boolean; + logLevel?: string; + incoming?: Policy; + outgoing?: Policy; + routed?: Policy; +} + +const shellVars = (text: string) => + Object.fromEntries( + [...text.matchAll(/^\s*([A-Z_]+)\s*=\s*"?([^"\n#]*)"?/gm)].map((m) => [m[1], m[2].trim()]), + ); + +const policy = (v: string | undefined): Policy | undefined => + v === "DROP" ? "deny" : v === "ACCEPT" ? "allow" : v === "REJECT" ? "reject" : undefined; + +/** /etc/ufw/ufw.conf and /etc/default/ufw. */ +export function parseUfwConfig(ufwConf: string, defaults: string): UfwConfig { + const conf = shellVars(ufwConf), def = shellVars(defaults); + return { + enabled: /^yes$/i.test(conf.ENABLED ?? ""), + logLevel: conf.LOGLEVEL || undefined, + incoming: policy(def.DEFAULT_INPUT_POLICY), + outgoing: policy(def.DEFAULT_OUTPUT_POLICY), + routed: policy(def.DEFAULT_FORWARD_POLICY), + }; +} + +// --------------------------------------------------------------------------- +// ufw status (root) + +export interface UfwStatus { + active: boolean; + logging?: string; + incoming?: string; + outgoing?: string; + routed?: string; + newProfiles?: string; +} + +/** `ufw status verbose`. */ +export function parseUfwVerbose(out: string): UfwStatus { + const line = (k: string) => new RegExp(`^${k}:\\s*(.*)$`, "m").exec(out)?.[1]?.trim(); + const defaults = line("Default") ?? ""; + const pick = (dir: string) => new RegExp(`(\\w+) \\(${dir}\\)`).exec(defaults)?.[1]; + return { + active: line("Status") === "active", + logging: line("Logging"), + incoming: pick("incoming"), + outgoing: pick("outgoing"), + routed: pick("routed"), + newProfiles: line("New profiles"), + }; +} + +export type RuleAction = "ALLOW" | "DENY" | "REJECT" | "LIMIT"; + +export interface UfwRule { + /** `ufw status numbered`'s number, for `ufw delete N`; undefined when ufw is inactive. */ + number?: number; + to: string; + action: RuleAction; + direction: "IN" | "OUT" | "FWD"; + from: string; + v6: boolean; + comment?: string; + log?: boolean; + /** The rule as `ufw show added` prints it, without `ufw`. */ + spec?: string; +} + +const RULE = /^(.*?)\s+(ALLOW|DENY|REJECT|LIMIT)(?: (IN|OUT|FWD))?(?: \((log|log-all)\))?\s+(.*?)(?:\s+#\s?(.*))?$/; + +/** `ufw status numbered` (or plain `ufw status`, without numbers). */ +export function parseUfwNumbered(out: string): UfwRule[] { + const rules: UfwRule[] = []; + for (const raw of out.split("\n")) { + const numbered = /^\[\s*(\d+)\]\s(.*)$/.exec(raw); + const body = (numbered ? numbered[2] : raw).trimEnd(); + if (!numbered && (!body || /^(Status|To\s|--|Logging|Default|New profiles)/.test(body.trim()))) continue; + const m = RULE.exec(body.trim()); + if (!m) continue; + const [, to, action, direction, log, from, comment] = m; + const v6 = /\(v6\)/.test(to) || /\(v6\)/.test(from); + rules.push({ + number: numbered ? Number(numbered[1]) : undefined, + to: to.replace(/\s*\(v6\)/, "").trim(), + action: action as RuleAction, + direction: (direction ?? "IN") as UfwRule["direction"], + from: from.replace(/\s*\(v6\)/, "").replace(/\s*\(out\)$/, "").replace(/\s{2,}/g, " ").trim(), + v6, + comment: comment?.trim() || undefined, + log: log ? true : undefined, + }); + } + return rules; +} + +/** `ufw show added`: the rules as commands, also while ufw is inactive. */ +export function parseUfwAdded(out: string): UfwRule[] { + const rules: UfwRule[] = []; + for (const raw of out.split("\n")) { + const line = raw.trim(); + if (!line.startsWith("ufw ")) continue; + const spec = line.slice(4); + const words = spec.replace(/\s+comment\s+'.*'$/, "").split(/\s+/); + const comment = /comment\s+'(.*)'$/.exec(spec)?.[1]; + const actionWord = words.find((w) => /^(allow|deny|reject|limit)$/.test(w)) ?? "allow"; + const direction = words.includes("out") ? "OUT" : "IN"; + const at = (k: string) => { + const i = words.indexOf(k); + return i >= 0 ? words[i + 1] : undefined; + }; + const proto = at("proto"); + const port = at("port") ?? at("app"); + const simple = words[words.indexOf(actionWord) + 1]; + const to = port + ? `${port}${proto ? `/${proto}` : ""}` + : simple && !["from", "to", "in", "out", "proto"].includes(simple) + ? simple + : (at("to") && at("to") !== "any" ? at("to")! : "Anywhere"); + const from = at("from"); + rules.push({ + to, + action: actionWord.toUpperCase() as RuleAction, + direction, + from: !from || from === "any" ? "Anywhere" : from, + v6: false, + comment, + spec, + }); + } + return rules; +} + +/** The three outputs of {@link UFW_STATUS_SCRIPT}, split on its markers. */ +export const UFW_MARK = "@@backslash@@"; +export const UFW_STATUS_SCRIPT = + `ufw status verbose; echo ${UFW_MARK}; ufw status numbered; echo ${UFW_MARK}; ufw show added`; + +export interface UfwSnapshot { + status: UfwStatus; + rules: UfwRule[]; +} + +export function parseUfwScript(out: string): UfwSnapshot { + const [verbose = "", numbered = "", added = ""] = out.split(UFW_MARK); + const status = parseUfwVerbose(verbose); + return { status, rules: status.active ? parseUfwNumbered(numbered) : parseUfwAdded(added) }; +} + +// --------------------------------------------------------------------------- +// firewalld + +export interface Zone { + name: string; + active: boolean; + default: boolean; + target?: string; + interfaces: string[]; + sources: string[]; + services: string[]; + ports: string[]; + protocols: string[]; + forward?: boolean; + masquerade?: boolean; + forwardPorts: string[]; + sourcePorts: string[]; + icmpBlocks: string[]; + richRules: string[]; +} + +/** `firewall-cmd --list-all-zones` (or `--list-all`): blocks of `key: value` lines. */ +export function parseZones(out: string): Zone[] { + const zones: Zone[] = []; + let zone: Zone | undefined; + let inRich = false; + for (const raw of out.split("\n")) { + if (!raw.trim()) { + inRich = false; + continue; + } + const head = /^(\S+)(?:\s+\(([^)]*)\))?\s*$/.exec(raw); + if (head && !raw.startsWith(" ") && !raw.startsWith("\t")) { + const flags = (head[2] ?? "").split(/,\s*/); + zone = { + name: head[1], + active: flags.includes("active"), + default: flags.includes("default"), + interfaces: [], + sources: [], + services: [], + ports: [], + protocols: [], + forwardPorts: [], + sourcePorts: [], + icmpBlocks: [], + richRules: [], + }; + zones.push(zone); + inRich = false; + continue; + } + if (!zone) continue; + const kv = /^\s+([a-z -]+):\s?(.*)$/.exec(raw); + if (kv && !(inRich && raw.startsWith("\t"))) { + const key = kv[1].trim(), value = kv[2].trim(); + const list = value ? value.split(/\s+/) : []; + inRich = key === "rich rules"; + switch (key) { + case "target": + zone.target = value; + break; + case "interfaces": + zone.interfaces = list; + break; + case "sources": + zone.sources = list; + break; + case "services": + zone.services = list; + break; + case "ports": + zone.ports = list; + break; + case "protocols": + zone.protocols = list; + break; + case "forward": + zone.forward = value === "yes"; + break; + case "masquerade": + zone.masquerade = value === "yes"; + break; + case "forward-ports": + zone.forwardPorts = value ? [value] : []; + break; + case "source-ports": + zone.sourcePorts = list; + break; + case "icmp-blocks": + zone.icmpBlocks = list; + break; + case "rich rules": + if (value) zone.richRules.push(value); + break; + } + continue; + } + const cont = raw.trim(); + if (inRich) zone.richRules.push(cont); + else if (zone.forwardPorts.length && /^port=/.test(cont)) zone.forwardPorts.push(cont); + } + return zones; +} + +/** Active zones first (the default one on top), then the others by name. */ +export function sortZones(zones: Zone[]): Zone[] { + return [...zones].sort((a, b) => + Number(b.default) - Number(a.default) || Number(b.active) - Number(a.active) || a.name.localeCompare(b.name) + ); +} + +/** A readable line for a rich rule: `Allow 22/tcp from 192.168.1.0/24`. */ +export function describeRich(rule: string): { action: string; port?: string; service?: string; from?: string } { + const attr = (re: RegExp) => re.exec(rule)?.[1]; + const action = attr(/\b(accept|reject|drop|mark)\b(?!=)/) ?? "accept"; + const port = attr(/\bport port="([^"]+)"/); + const proto = attr(/\bport port="[^"]+" protocol="(\w+)"/); + return { + action: action === "accept" ? "Allow" : action === "drop" ? "Drop" : action === "reject" ? "Reject" : "Mark", + port: port ? `${port}${proto ? `/${proto}` : ""}` : undefined, + service: attr(/\bservice name="([^"]+)"/), + from: attr(/\bsource (?:NOT )?address="([^"]+)"/), + }; +} + +// --------------------------------------------------------------------------- +// New rules + +export type Protocol = "tcp" | "udp" | "any"; + +export interface RuleInput { + action: "allow" | "deny" | "reject" | "limit"; + /** `22`, `8000:8100`, `8000-8100`, `80,443` or a service name like `ssh`. */ + port: string; + protocol: Protocol; + /** Empty for anywhere; an address or network. */ + from: string; + comment?: string; +} + +export function validatePort(port: string): string | undefined { + const p = port.trim(); + if (!p) return "Enter a port, a range or a service name"; + if (/^[a-z][\w-]*$/i.test(p)) return undefined; + const parts = p.split(","); + for (const part of parts) { + const m = /^(\d{1,5})(?:[:-](\d{1,5}))?$/.exec(part.trim()); + if (!m) return `“${part.trim()}” is not a port`; + const lo = Number(m[1]), hi = Number(m[2] ?? m[1]); + if (lo < 1 || hi > 65535 || hi < lo) return `${part.trim()} is out of the 1–65535 range`; + } + return undefined; +} + +export const isServiceName = (port: string) => /^[a-z][\w-]*$/i.test(port.trim()); + +export function validateSource(from: string): string | undefined { + const f = from.trim(); + if (!f || /^(any|anywhere)$/i.test(f)) return undefined; + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(f); + if (v4) { + return v4.slice(1, 5).every((n) => Number(n) <= 255) && (v4[5] === undefined || Number(v4[5]) <= 32) + ? undefined + : `“${f}” is not an IPv4 address`; + } + if (/^[0-9a-f:]+(?:\/\d{1,3})?$/i.test(f) && f.includes(":")) return undefined; + return `“${f}” is not an IP address or network (192.168.1.0/24)`; +} + +const anywhere = (from: string) => !from.trim() || /^(any|anywhere)$/i.test(from.trim()); + +/** The arguments after `ufw` that add the rule. */ +export function ufwAddArgs(r: RuleInput): string[] { + const port = r.port.trim().replace(/-/g, ":").replace(/\s+/g, ""); + const ranged = /[:,]/.test(port); + // ufw needs a protocol with ranges and lists. + const proto = r.protocol === "any" && ranged ? undefined : r.protocol; + const comment = r.comment?.trim() ? ["comment", r.comment.trim()] : []; + if (anywhere(r.from) && !ranged) { + return [r.action, proto && proto !== "any" ? `${port}/${proto}` : port, ...comment]; + } + return [ + r.action, + ...(proto && proto !== "any" ? ["proto", proto] : []), + "from", + anywhere(r.from) ? "any" : r.from.trim(), + "to", + "any", + "port", + port, + ...comment, + ]; +} + +/** Both protocols for ranges and lists when "any" was picked: ufw wants one. */ +export function ufwAddCommands(r: RuleInput): string[][] { + const port = r.port.trim(); + if (r.protocol === "any" && /[:,-]/.test(port) && !isServiceName(port)) { + return [ufwAddArgs({ ...r, protocol: "tcp" }), ufwAddArgs({ ...r, protocol: "udp" })]; + } + return [ufwAddArgs(r)]; +} + +/** `firewall-cmd` arguments (without `--zone`/`--permanent`) that add the rule. */ +export function firewalldAddArgs(r: RuleInput): string[] { + const port = r.port.trim().replace(/:/g, "-").replace(/\s+/g, ""); + const protos = r.protocol === "any" ? ["tcp", "udp"] : [r.protocol]; + const service = isServiceName(port); + if (anywhere(r.from) && r.action === "allow") { + if (service) return [`--add-service=${port}`]; + return port.split(",").flatMap((p) => protos.map((proto) => `--add-port=${p}/${proto}`)); + } + return richRules(r).map((rule) => `--add-rich-rule=${rule}`); +} + +export function richRules(r: RuleInput): string[] { + const port = r.port.trim().replace(/:/g, "-").replace(/\s+/g, ""); + const protos = r.protocol === "any" ? ["tcp", "udp"] : [r.protocol]; + const from = r.from.trim(); + const source = anywhere(from) ? "" : ` source address="${from}"`; + const family = anywhere(from) ? "" : ` family="${from.includes(":") ? "ipv6" : "ipv4"}"`; + const verdict = r.action === "allow" ? "accept" : r.action === "reject" ? "reject" : "drop"; + if (isServiceName(port)) return [`rule${family}${source} service name="${port}" ${verdict}`]; + return port.split(",").flatMap((p) => + protos.map((proto) => `rule${family}${source} port port="${p}" protocol="${proto}" ${verdict}`) + ); +} + +/** Runtime and permanent configuration, in one script for one password prompt. */ +export function firewalldScript(zone: string, args: string[]): string { + const z = `--zone=${zone}`; + return [ + shellLine(["firewall-cmd", z, ...args]), + shellLine(["firewall-cmd", "--permanent", z, ...args]), + ].join(" && "); +} + +/** `--add-port=…` → `--remove-port=…`. */ +export const removalArgs = (kind: "port" | "service" | "rich-rule" | "source" | "interface", value: string) => [ + `--remove-${kind}=${value}`, +]; + +// --------------------------------------------------------------------------- +// Looks + +export function ruleTitle(r: UfwRule): string { + return r.to === "Anywhere" ? "Any port" : r.to.replace(/^Anywhere on /, "Any port on "); +} + +export function ruleSentence(r: UfwRule): string { + const verb = { ALLOW: "Allow", DENY: "Deny", REJECT: "Reject", LIMIT: "Limit" }[r.action]; + const dir = r.direction === "OUT" ? "out to" : r.direction === "FWD" ? "forwarded from" : "in from"; + return `${verb} ${dir} ${r.from === "Anywhere" ? "anywhere" : r.from}`; +} + +/** The rule as a ufw command, to copy. */ +export function ruleCommand(r: UfwRule): string { + if (r.spec) return `sudo ufw ${r.spec}`; + const action = r.action.toLowerCase(); + const out = r.direction === "OUT" ? " out" : ""; + const [port, proto] = r.to.split("/"); + if (r.from === "Anywhere" && /^[\w:,]+(\/\w+)?$/.test(r.to)) return `sudo ufw ${action}${out} ${r.to}`; + const from = r.from === "Anywhere" ? "any" : r.from; + return `sudo ufw ${action}${out}${proto ? ` proto ${proto}` : ""} from ${from} to any port ${port}`; +} + +/** What `ufw delete` needs: the number when active, else the rule itself. */ +export function ufwDeleteArgs(r: UfwRule): string[] { + if (r.number !== undefined) return ["--force", "delete", String(r.number)]; + if (r.spec) { + const words = r.spec.replace(/\s+comment\s+'.*'$/, "").split(/\s+/); + return ["--force", "delete", ...words]; + } + throw new Error("This rule can't be deleted from here"); +} + +/** `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); +} + +/** The usual firewall of the distribution, and how to install it. */ +export function installHint(osRelease: string): { tool: string; command: string } { + const ids = osIds(osRelease); + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) { + return { tool: "firewalld", command: "sudo dnf install firewalld && sudo systemctl enable --now firewalld" }; + } + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) { + return { tool: "firewalld", command: "sudo zypper install firewalld && sudo systemctl enable --now firewalld" }; + } + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) { + return { tool: "ufw", command: "sudo pacman -S ufw && sudo systemctl enable --now ufw && sudo ufw enable" }; + } + return { tool: "ufw", command: "sudo apt install ufw && sudo ufw enable" }; +} + +/** pkexec's exit codes when nobody authenticated. */ +export function pkexecError(code: number, stderr: string): string | undefined { + if (code === 126) return "Authentication was cancelled"; + if (code === 127 && /not authorized|authentication/i.test(stderr)) return "Not authorized"; + if (/No authentication agent/i.test(stderr)) return "No polkit agent to ask for your password"; + return undefined; +} diff --git a/extensions/firewall/lib_test.ts b/extensions/firewall/lib_test.ts new file mode 100644 index 0000000..f7a89bf --- /dev/null +++ b/extensions/firewall/lib_test.ts @@ -0,0 +1,294 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { + describeRich, + firewalldAddArgs, + firewalldScript, + installHint, + parseUfwAdded, + parseUfwConfig, + parseUfwNumbered, + parseUfwScript, + parseUfwVerbose, + parseZones, + pkexecError, + richRules, + ruleCommand, + ruleSentence, + shellLine, + sortZones, + UFW_MARK, + ufwAddArgs, + ufwAddCommands, + ufwDeleteArgs, + validatePort, + validateSource, +} from "./lib.ts"; + +const VERBOSE = `Status: active +Logging: on (low) +Default: deny (incoming), allow (outgoing), disabled (routed) +New profiles: skip + +To Action From +-- ------ ---- +22/tcp ALLOW IN Anywhere # SSH +80,443/tcp (Nginx Full) ALLOW IN Anywhere +22/tcp (v6) ALLOW IN Anywhere (v6) # SSH +`; + +const NUMBERED = `Status: active + + To Action From + -- ------ ---- +[ 1] 22/tcp LIMIT IN Anywhere # SSH +[ 2] 80,443/tcp (Nginx Full) ALLOW IN Anywhere +[ 3] 3000 DENY IN 192.168.1.0/24 # dev server +[ 4] Anywhere ALLOW IN 10.8.0.0/24 +[ 5] 53 ALLOW OUT Anywhere (out) +[ 6] Anywhere on wg0 ALLOW IN Anywhere +[ 7] 22/tcp (v6) LIMIT IN Anywhere (v6) # SSH +[10] 8080/tcp REJECT IN Anywhere +`; + +const ADDED = `Added user rules (see 'ufw status' for running firewall): +ufw limit 22/tcp comment 'SSH' +ufw allow 80,443/tcp +ufw deny from 192.168.1.0/24 to any port 3000 comment 'dev server' +ufw allow proto udp from any to any port 51820 +`; + +// `firewall-cmd --list-all-zones` on Fedora Workstation (trimmed to three zones). +const ZONES = `block + target: %%REJECT%% + icmp-block-inversion: no + interfaces: + sources: + services: + ports: + protocols: + forward: yes + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +FedoraWorkstation (default, active) + target: default + icmp-block-inversion: no + interfaces: wlp2s0 + sources: + services: dhcpv6-client mdns samba-client ssh + ports: 1025-65535/udp 1025-65535/tcp + protocols: + forward: yes + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + rule family="ipv4" source address="192.168.1.0/24" port port="8080" protocol="tcp" accept + rule family="ipv4" source address="10.0.0.5" drop + +public + target: default + icmp-block-inversion: no + interfaces: + sources: 192.168.50.0/24 + services: dhcpv6-client ssh + ports: + protocols: + forward: yes + masquerade: yes + forward-ports: + port=80:proto=tcp:toport=8080:toaddr= + source-ports: + icmp-blocks: echo-request + rich rules: +`; + +Deno.test("ufw configuration files", () => { + const conf = parseUfwConfig( + "# /etc/ufw/ufw.conf\nENABLED=yes\nLOGLEVEL=low\n", + 'IPV6=yes\nDEFAULT_INPUT_POLICY="DROP"\nDEFAULT_OUTPUT_POLICY="ACCEPT"\nDEFAULT_FORWARD_POLICY="REJECT"\n', + ); + assertEquals(conf, { enabled: true, logLevel: "low", incoming: "deny", outgoing: "allow", routed: "reject" }); + assertEquals(parseUfwConfig("ENABLED=no", "").enabled, false); +}); + +Deno.test("ufw status verbose", () => { + assertEquals(parseUfwVerbose(VERBOSE), { + active: true, + logging: "on (low)", + incoming: "deny", + outgoing: "allow", + routed: "disabled", + newProfiles: "skip", + }); + assertEquals(parseUfwVerbose("Status: inactive\n").active, false); +}); + +Deno.test("ufw status numbered", () => { + const rules = parseUfwNumbered(NUMBERED); + assertEquals(rules.length, 8); + assertEquals(rules[0], { + number: 1, + to: "22/tcp", + action: "LIMIT", + direction: "IN", + from: "Anywhere", + v6: false, + comment: "SSH", + log: undefined, + }); + assertEquals(rules[1].to, "80,443/tcp (Nginx Full)"); + assertEquals(rules[2].from, "192.168.1.0/24"); + assertEquals(rules[4].direction, "OUT"); + assertEquals(rules[4].from, "Anywhere"); + assertEquals(rules[5].to, "Anywhere on wg0"); + assertEquals(rules[6].v6, true); + assertEquals(rules[6].to, "22/tcp"); + assertEquals(rules[7].number, 10); + assertEquals(rules[7].action, "REJECT"); + // Plain `ufw status verbose` has no numbers. + assertEquals(parseUfwNumbered(VERBOSE).map((r) => r.number), [undefined, undefined, undefined]); +}); + +Deno.test("ufw show added (inactive firewall)", () => { + const rules = parseUfwAdded(ADDED); + assertEquals(rules.map((r) => [r.action, r.to, r.from, r.comment]), [ + ["LIMIT", "22/tcp", "Anywhere", "SSH"], + ["ALLOW", "80,443/tcp", "Anywhere", undefined], + ["DENY", "3000", "192.168.1.0/24", "dev server"], + ["ALLOW", "51820/udp", "Anywhere", undefined], + ]); + assertEquals(ufwDeleteArgs(rules[2]), [ + "--force", + "delete", + "deny", + "from", + "192.168.1.0/24", + "to", + "any", + "port", + "3000", + ]); + assertEquals(ruleCommand(rules[0]), "sudo ufw limit 22/tcp comment 'SSH'"); +}); + +Deno.test("the root script's outputs", () => { + const active = parseUfwScript([VERBOSE, NUMBERED, ADDED].join(`${UFW_MARK}\n`)); + assertEquals(active.status.active, true); + assertEquals(active.rules.length, 8); + const inactive = parseUfwScript(["Status: inactive\n", "Status: inactive\n", ADDED].join(`${UFW_MARK}\n`)); + assertEquals(inactive.status.active, false); + assertEquals(inactive.rules.length, 4); + assertEquals(inactive.rules[0].number, undefined); +}); + +Deno.test("ufw rules as text", () => { + const [ssh, , dev] = parseUfwNumbered(NUMBERED); + assertEquals(ruleSentence(ssh), "Limit in from anywhere"); + assertEquals(ruleSentence(dev), "Deny in from 192.168.1.0/24"); + assertEquals(ruleCommand(dev), "sudo ufw deny from 192.168.1.0/24 to any port 3000"); + assertEquals(ruleCommand(ssh), "sudo ufw limit 22/tcp"); + assertEquals(ufwDeleteArgs(dev), ["--force", "delete", "3"]); +}); + +Deno.test("new ufw rules", () => { + assertEquals(ufwAddArgs({ action: "allow", port: "22", protocol: "tcp", from: "" }), ["allow", "22/tcp"]); + assertEquals(ufwAddArgs({ action: "allow", port: "ssh", protocol: "any", from: "anywhere" }), ["allow", "ssh"]); + assertEquals( + ufwAddArgs({ action: "deny", port: "3000", protocol: "tcp", from: "192.168.1.0/24", comment: "dev" }), + ["deny", "proto", "tcp", "from", "192.168.1.0/24", "to", "any", "port", "3000", "comment", "dev"], + ); + assertEquals( + ufwAddArgs({ action: "allow", port: "8000-8100", protocol: "udp", from: "" }), + ["allow", "proto", "udp", "from", "any", "to", "any", "port", "8000:8100"], + ); + // ufw wants a protocol for ranges: both. + assertEquals(ufwAddCommands({ action: "allow", port: "60000:61000", protocol: "any", from: "" }).length, 2); + assertEquals( + shellLine([ + "ufw", + ...ufwAddArgs({ action: "allow", port: "80", protocol: "tcp", from: "", comment: "web server" }), + ]), + "ufw allow 80/tcp comment 'web server'", + ); +}); + +Deno.test("input checks", () => { + assertEquals(validatePort("22"), undefined); + assertEquals(validatePort("80,443"), undefined); + assertEquals(validatePort("8000-8100"), undefined); + assertEquals(validatePort("ssh"), undefined); + assert(validatePort("70000")); + assert(validatePort("100-50")); + assert(validatePort("")); + assertEquals(validateSource(""), undefined); + assertEquals(validateSource("192.168.1.0/24"), undefined); + assertEquals(validateSource("fd00::/8"), undefined); + assert(validateSource("300.1.1.1")); + assert(validateSource("my laptop")); +}); + +Deno.test("firewalld zones", () => { + const zones = parseZones(ZONES); + assertEquals(zones.map((z) => z.name), ["block", "FedoraWorkstation", "public"]); + const fw = zones[1]; + assertEquals(fw.default, true); + assertEquals(fw.active, true); + assertEquals(fw.interfaces, ["wlp2s0"]); + assertEquals(fw.services, ["dhcpv6-client", "mdns", "samba-client", "ssh"]); + assertEquals(fw.ports, ["1025-65535/udp", "1025-65535/tcp"]); + assertEquals(fw.richRules.length, 2); + assertEquals(zones[0].target, "%%REJECT%%"); + assertEquals(zones[2].sources, ["192.168.50.0/24"]); + assertEquals(zones[2].masquerade, true); + assertEquals(zones[2].icmpBlocks, ["echo-request"]); + assertEquals(zones[2].richRules, []); + assertEquals(sortZones(zones).map((z) => z.name), ["FedoraWorkstation", "block", "public"]); + assertEquals(describeRich(fw.richRules[0]), { + action: "Allow", + port: "8080/tcp", + service: undefined, + from: "192.168.1.0/24", + }); + assertEquals(describeRich(fw.richRules[1]).action, "Drop"); +}); + +Deno.test("new firewalld rules", () => { + assertEquals(firewalldAddArgs({ action: "allow", port: "8080", protocol: "tcp", from: "" }), ["--add-port=8080/tcp"]); + assertEquals(firewalldAddArgs({ action: "allow", port: "https", protocol: "tcp", from: "" }), [ + "--add-service=https", + ]); + assertEquals(firewalldAddArgs({ action: "allow", port: "6000:6010", protocol: "any", from: "" }), [ + "--add-port=6000-6010/tcp", + "--add-port=6000-6010/udp", + ]); + assertEquals(richRules({ action: "allow", port: "22", protocol: "tcp", from: "192.168.1.0/24" }), [ + 'rule family="ipv4" source address="192.168.1.0/24" port port="22" protocol="tcp" accept', + ]); + assertEquals(richRules({ action: "deny", port: "25", protocol: "tcp", from: "" }), [ + 'rule port port="25" protocol="tcp" drop', + ]); + assertEquals( + firewalldScript("public", ["--add-port=8080/tcp"]), + "firewall-cmd --zone=public --add-port=8080/tcp && firewall-cmd --permanent --zone=public --add-port=8080/tcp", + ); + assert( + firewalldScript("public", firewalldAddArgs({ action: "reject", port: "22", protocol: "tcp", from: "10.0.0.5" })) + .includes(`'--add-rich-rule=rule family="ipv4" source address="10.0.0.5" port port="22" protocol="tcp" reject'`), + ); +}); + +Deno.test("install hints and pkexec", () => { + assertEquals(installHint("ID=fedora").tool, "firewalld"); + assertEquals(installHint("ID=ubuntu\nID_LIKE=debian").command, "sudo apt install ufw && sudo ufw enable"); + assertEquals(installHint('ID="opensuse-tumbleweed"').tool, "firewalld"); + assertEquals(installHint("ID=arch").tool, "ufw"); + assertEquals(pkexecError(126, ""), "Authentication was cancelled"); + assertEquals(pkexecError(127, "Error executing command as another user: Not authorized"), "Not authorized"); + assertEquals(pkexecError(1, "ERROR: Could not find a profile matching 'foo'"), undefined); + assertThrows(() => ufwDeleteArgs({ to: "x", action: "ALLOW", direction: "IN", from: "Anywhere", v6: false })); +}); diff --git a/extensions/gists/api.ts b/extensions/gists/api.ts new file mode 100644 index 0000000..90d4573 --- /dev/null +++ b/extensions/gists/api.ts @@ -0,0 +1,82 @@ +// GitHub REST helpers: the token (preferences, else `gh auth token`) and the setup row. +// Same sign-in as the GitHub and GitHub Actions extensions. + +import { exec, Icons, type Item, open } from "@backslash/api"; + +export const API = "https://api.github.com"; +export const TOKEN_URL = "https://github.com/settings/tokens/new?scopes=gist&description=Backslash%20Gists"; + +const HEADERS = { "User-Agent": "backslash-launcher", "X-GitHub-Api-Version": "2022-11-28" }; + +export class AuthError extends Error {} + +/** The token from the preferences, else the GitHub CLI's (`gh auth token`), if any. */ +export async function token(preferences: Record): Promise { + const saved = String(preferences.token ?? "").trim(); + if (saved) return saved; + try { + const t = await exec("gh", ["auth", "token"]); + if (t) return t; + } catch { /* gh missing or signed out */ } + throw new AuthError("Sign in to GitHub"); +} + +function check(res: Response) { + if (res.status === 401) throw new AuthError("GitHub rejected the token"); + if (res.status === 403 || res.status === 429) { + if (res.headers.get("x-ratelimit-remaining") === "0") { + throw new Error("GitHub rate limit reached: try again in a few minutes"); + } + throw new AuthError("The token can't manage gists: it needs the gist scope"); + } + if (res.status === 404) throw new Error("Gist not found: it may have been deleted"); + if (res.status === 422) throw new Error("GitHub refused the gist: check the file name and content"); + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); +} + +export async function github( + t: string, + path: string, + options: { method?: string; signal?: AbortSignal; body?: unknown } = {}, +): Promise { + const res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + ...HEADERS, + Accept: "application/vnd.github+json", + Authorization: `Bearer ${t}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + check(res); + if (res.status === 202 || res.status === 204) 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: "GitHub CLI or personal access token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect GitHub", + "", + "**With the GitHub CLI** (simplest): run `gh auth login` in a terminal. Its sign-in is used automatically.", + "", + "**Or with a token:**", + "", + "1. Create a classic token with the **gist** scope (Enter opens the page).", + "2. Paste it in **Settings → Extensions → Gists → Configure**.", + "", + "A fine-grained token works too: give it the account permission **Gists: Read and write**.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Token", { icon: Icons.key })], + }]; +} diff --git a/extensions/gists/backslash.toml b/extensions/gists/backslash.toml new file mode 100644 index 0000000..0b73bd3 --- /dev/null +++ b/extensions/gists/backslash.toml @@ -0,0 +1,35 @@ +[extension] +id = "gists" +title = "Gists" +description = "Your GitHub gists with their code in the preview: copy, paste, create from the clipboard, delete" +version = "1.0.0" +author = "Backslash" +icon = "bs-code" +license = "MIT" + +# The REST API, avatars, and raw files for contents over 1 MB (which the API truncates). +# gh gives its sign-in when no token is set; the clipboard is read to create a gist from it. +[permissions] +net = ["api.github.com", "avatars.githubusercontent.com", "gist.githubusercontent.com"] +run = ["gh"] +clipboard-read = true + +[[commands]] +name = "gists" +title = "Search Gists" +description = "Your gists, public, secret or starred, with their files' contents" +icon = "bs-code" +keywords = ["gist", "gists", "github", "snippet", "snippets", "code", "paste", "extrait", "bout de code"] + +[[commands]] +name = "new" +title = "Create Gist from Clipboard" +description = "A new public or secret gist with the copied text" +icon = "bs-plus" +keywords = ["gist", "new gist", "share", "paste", "clipboard", "snippet", "nouveau", "partager", "presse-papiers"] + +[[preferences]] +name = "token" +title = "Personal access token" +description = "Optional when the GitHub CLI is signed in (gh auth login). Needs the gist scope" +type = "secret" diff --git a/extensions/gists/commands/gists.ts b/extensions/gists/commands/gists.ts new file mode 100644 index 0000000..10d5596 --- /dev/null +++ b/extensions/gists/commands/gists.ts @@ -0,0 +1,86 @@ +import { action, type Context, Icons, type Item, list, open } from "@backslash/api"; +import { AuthError, github, setupItems, token } from "../api.ts"; +import { createForm, gistActions, nextChange, withContent } from "../gists.ts"; +import { type Gist, gistItem } from "../lib.ts"; + +/** Contents come from one call per gist: only for the most recent ones. */ +const WITH_CONTENT = 30; + +let counts = { total: 0, secret: 0 }; +let current = "all"; + +async function load(t: string, filter: string, signal: AbortSignal): Promise { + if (filter === "starred") return await github(t, "/gists/starred?per_page=100", { signal }); + const pages = await Promise.all( + [1, 2, 3].map((page) => github(t, `/gists?per_page=100&page=${page}`, { signal }).catch(() => [])), + ); + const all = pages.flat(); + counts = { total: all.length, secret: all.filter((g) => !g.public).length }; + if (filter === "public") return all.filter((g) => g.public); + if (filter === "secret") return all.filter((g) => !g.public); + return all; +} + +export default (_ctx: Context) => + list({ + placeholder: "Search gists by description, file name, language…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "public", title: "Public" }, + { value: "secret", title: "Secret" }, + { value: "starred", title: "Starred" }, + ], + }, + empty: { + title: "No gists here", + description: "Press Ctrl+N to create one from the clipboard.", + icon: Icons.code, + }, + status: (items) => + items[0]?.id === "setup" + ? "Not connected" + : `${items.length} gist${items.length === 1 ? "" : "s"}${ + current !== "starred" && counts.secret ? ` · ${counts.secret} secret` : "" + }`, + actions: [ + action( + "Create Gist from Clipboard", + async ({ preferences }) => createForm(await token(preferences), { standalone: false }), + { icon: Icons.plus, shortcut: "n" }, + ), + open("https://gist.github.com/", "Open gist.github.com"), + ], + // Reloads after a gist is created, deleted or unstarred from this command. + async *items({ preferences, signal, filter }) { + let t: string; + try { + t = await token(preferences); + } catch (e) { + if (e instanceof AuthError) return yield setupItems(e); + throw e; + } + const view = filter ?? "all"; + current = view; + const options = { own: view !== "starred", starred: view === "starred" }; + while (!signal.aborted) { + let gists: Gist[]; + try { + gists = await load(t, view, signal); + } catch (e) { + if (e instanceof AuthError) return yield setupItems(e); + throw e; + } + const now = new Date(); + const items = (list: Gist[]): Item[] => list.map((g) => gistItem(g, gistActions(t, g, options), now)); + yield items(gists); + // Then the files' contents, for the previews. + const full = await Promise.all( + gists.map((g, i) => (i < WITH_CONTENT ? withContent(t, g, signal).catch(() => g) : g)), + ); + if (signal.aborted) return; + yield items(full); + await nextChange(signal); + } + }, + }); diff --git a/extensions/gists/commands/new.ts b/extensions/gists/commands/new.ts new file mode 100644 index 0000000..583fbc5 --- /dev/null +++ b/extensions/gists/commands/new.ts @@ -0,0 +1,20 @@ +import { type Context, detail, Icons, open } from "@backslash/api"; +import { AuthError, setupItems, token } from "../api.ts"; +import { createForm } from "../gists.ts"; + +export default async (ctx: Context) => { + try { + return await createForm(await token(ctx.preferences), { standalone: true }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const [setup] = setupItems(e); + return detail({ + title: "Connect GitHub", + markdown: setup.detail?.markdown ?? e.message, + actions: [ + ...(setup.actions ?? []), + open("https://cli.github.com/", "Get the GitHub CLI", { icon: Icons.terminal }), + ], + }); + } +}; diff --git a/extensions/gists/gists.ts b/extensions/gists/gists.ts new file mode 100644 index 0000000..c55498a --- /dev/null +++ b/extensions/gists/gists.ts @@ -0,0 +1,156 @@ +// Loading gists, the creation form and the actions, shared by the commands. + +import { + type Action, + action, + clipboard, + close, + copy, + field, + form, + type FormSpec, + Icons, + open, + pop, + submenu, + toast, +} from "@backslash/api"; +import { github } from "./api.ts"; +import { files, type Gist, type GistFile, gistTitle, guessFilename, newGist } from "./lib.ts"; + +// --- Live reload: the views of a command share one process ------------------------ + +const changes = new EventTarget(); + +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Waits for the next change made from this command, or for `signal` to abort. */ +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); + }); +} + +// --- Contents ----------------------------------------------------------------------- + +/** Full gists by id, kept while their `updated_at` doesn't change. */ +const full = new Map(); + +export async function withContent(t: string, g: Gist, signal?: AbortSignal): Promise { + const known = full.get(g.id); + if (known && known.updated_at === g.updated_at) return known; + const loaded = await github(t, `/gists/${g.id}`, { signal }); + full.set(g.id, loaded); + return loaded; +} + +/** A file's whole text, even when the gist API truncated it (over 1 MB). */ +async function contentOf(t: string, g: Gist, file: GistFile): Promise { + const loaded = (await withContent(t, g)).files[file.filename] ?? file; + if (!loaded.truncated && loaded.content !== undefined) return loaded.content; + const res = await fetch(loaded.raw_url); + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); + return await res.text(); +} + +// --- Actions ------------------------------------------------------------------------ + +export function gistActions(t: string, g: Gist, options: { own: boolean; starred: boolean }): Action[] { + const all = files(g); + const single = all.length === 1 ? all[0] : undefined; + const copyFile = (file: GistFile, title: string, o: { shortcut?: string } = {}) => + action(title, async () => { + await clipboard.write(await contentOf(t, g, file)); + return close(`Copied ${file.filename}`); + }, { icon: Icons.copy, ...o }); + const pasteFile = (file: GistFile, title: string) => + action(title, async () => { + await clipboard.paste(await contentOf(t, g, file)); + }, { icon: Icons.paste, shortcut: "v" }); + return [ + ...(single ? [copyFile(single, "Copy Content")] : [submenu( + "Copy File Content", + all.map((f) => copyFile(f, f.filename)), + { icon: Icons.copy }, + )]), + open(g.html_url, "Open in Browser"), + ...(single ? [pasteFile(single, "Paste Content")] : [submenu( + "Paste File Content", + () => all.map((f) => pasteFile(f, f.filename)), + { icon: Icons.paste, shortcut: "v" }, + )]), + copy(g.html_url, "Copy Gist URL", { shortcut: "c", section: "Copy" }), + ...(single ? [copy(single.raw_url, "Copy Raw URL", { icon: Icons.link, section: "Copy" })] : []), + copy(`git clone ${g.git_pull_url ?? `https://gist.github.com/${g.id}.git`}`, "Copy git clone", { + icon: Icons.terminal, + section: "Copy", + }), + copy(g.id, "Copy Gist ID", { icon: Icons.hash, section: "Copy" }), + action("Create Gist from Clipboard", () => createForm(t, { standalone: false }), { + icon: Icons.plus, + shortcut: "n", + section: "Gist", + }), + ...(options.starred + ? [action("Unstar", async () => { + await github(t, `/gists/${g.id}/star`, { method: "DELETE" }); + await toast("Unstarred", { style: "success" }); + notifyChanged(); + }, { icon: Icons.star, section: "Gist" })] + : []), + ...(options.own + ? [action("Delete Gist", async () => { + await github(t, `/gists/${g.id}`, { method: "DELETE" }); + full.delete(g.id); + await toast(`Deleted “${gistTitle(g)}”`, { style: "success" }); + notifyChanged(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${gistTitle(g)}” for good?`, + shortcut: "x", + section: "Danger", + })] + : []), + ]; +} + +// --- Creating ----------------------------------------------------------------------- + +/** `standalone`: the form is the command itself, so creating closes the launcher instead of going back. */ +export async function createForm(t: string, { standalone }: { standalone: boolean }): Promise { + const text = await clipboard.read().catch(() => ""); + return form({ + title: "New Gist", + submitTitle: "Create Gist", + fields: [ + field.text("description", "Description", { placeholder: "What it is, for search later" }), + field.text("filename", "File name", { value: text.trim() ? guessFilename(text) : "", required: true }), + field.textArea("content", "Content", { value: text, required: true, placeholder: "Copied text appears here" }), + field.dropdown("visibility", "Visibility", [ + { value: "secret", title: "Secret (anyone with the link)" }, + { value: "public", title: "Public (listed on your profile)" }, + ], { value: "secret" }), + ], + async submit(values) { + const body = newGist({ + description: String(values.description ?? ""), + filename: String(values.filename ?? ""), + content: String(values.content ?? ""), + visibility: String(values.visibility ?? "secret"), + }); + const created = await github(t, "/gists", { method: "POST", body }); + await clipboard.write(created.html_url); + await toast("Gist created", { style: "success", message: "Its URL is in the clipboard" }); + if (standalone) return close("Gist created, URL copied"); + notifyChanged(); + return pop(); + }, + }); +} diff --git a/extensions/gists/lib.ts b/extensions/gists/lib.ts new file mode 100644 index 0000000..0ca2b2e --- /dev/null +++ b/extensions/gists/lib.ts @@ -0,0 +1,230 @@ +// Gists: how they look, code fences and file names. Tested in lib_test.ts. + +import { type Action, type Color, Icons, type Item, meta, type Metadata } from "@backslash/api"; + +export interface GistFile { + filename: string; + type?: string; + language?: string | null; + raw_url: string; + size: number; + /** Only from `GET /gists/{id}`. */ + content?: string; + truncated?: boolean; +} + +/** `GET /gists` (no contents) or `GET /gists/{id}` (with contents). */ +export interface Gist { + id: string; + html_url: string; + git_pull_url?: string; + description: string | null; + public: boolean; + created_at: string; + updated_at: string; + comments: number; + owner?: { login: string; avatar_url?: string } | null; + files: Record; + truncated?: boolean; +} + +export const files = (g: Gist) => Object.values(g.files ?? {}); + +export const gistTitle = (g: Gist) => g.description?.trim() || files(g)[0]?.filename || g.id; + +// --- Formatting --------------------------------------------------------------------- + +export function size(bytes: number): string { + if (bytes < 1000) return `${bytes} B`; + if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)} KB`; + return `${(bytes / 1_000_000).toFixed(1)} MB`; +} + +/** "today", "yesterday", "3d", "25 Oct", "Mar 2024". */ +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" }); +} + +/** GitHub language names → code fence info strings. */ +const FENCES: Record = { + "C++": "cpp", + "C#": "csharp", + "F#": "fsharp", + "Objective-C": "objectivec", + Shell: "bash", + "Bourne Shell": "bash", + "Bourne Again Shell": "bash", + PowerShell: "powershell", + "Vim Script": "vim", + "Emacs Lisp": "elisp", + "Git Config": "ini", + INI: "ini", + "Jupyter Notebook": "json", + "JSON with Comments": "jsonc", + "Protocol Buffer": "protobuf", + TSX: "tsx", + JSX: "jsx", + Text: "text", + Dockerfile: "dockerfile", + Makefile: "makefile", +}; + +export function fence(file: GistFile): string { + const lang = file.language ?? ""; + if (FENCES[lang]) return FENCES[lang]; + if (lang) return lang.toLowerCase().replace(/\s+/g, "-"); + const ext = /\.([a-z0-9]+)$/i.exec(file.filename)?.[1]?.toLowerCase(); + return ext ?? "text"; +} + +const LANGUAGE_COLORS: Record = { + TypeScript: "blue", + JavaScript: "yellow", + Python: "blue", + Rust: "orange", + Go: "teal", + Shell: "green", + Ruby: "red", + Java: "orange", + Kotlin: "purple", + PHP: "purple", + "C++": "pink", + C: "gray", + "C#": "green", + Markdown: "gray", + JSON: "gray", + YAML: "red", + HTML: "red", + CSS: "purple", + SQL: "orange", + Lua: "blue", + Nix: "blue", + Swift: "orange", + Dart: "teal", + Elixir: "purple", + Haskell: "purple", + TOML: "orange", + Dockerfile: "blue", +}; + +export const languageColor = (lang?: string | null): Color => LANGUAGE_COLORS[lang ?? ""] ?? "gray"; + +/** A file as a code block, cut after `maxLines` lines. */ +export function fileBlock(file: GistFile, maxLines = 60, maxChars = 4000): string { + const header = `**${file.filename}** · ${[file.language, size(file.size)].filter(Boolean).join(" · ")}`; + if (file.content === undefined) return `${header}\n\n_Loading…_`; + let lines = file.content.replace(/\s+$/, "").split("\n"); + let cut = lines.length > maxLines; + lines = lines.slice(0, maxLines); + let text = lines.join("\n"); + if (text.length > maxChars) { + text = text.slice(0, maxChars); + cut = true; + } + // A fence longer than any run of backticks inside. + const ticks = Math.max(3, ...[...text.matchAll(/`+/g)].map((m) => m[0].length + 1)); + const f = "`".repeat(ticks); + const more = cut || file.truncated ? "\n\n_… more in the full file._" : ""; + return `${header}\n\n${f}${fence(file)}\n${text}\n${f}${more}`; +} + +export function gistMarkdown(g: Gist, maxFiles = 5): string { + const all = files(g); + const parts = all.slice(0, maxFiles).map((f) => fileBlock(f)); + if (all.length > maxFiles) parts.push(`_And ${all.length - maxFiles} more files._`); + return parts.join("\n\n"); +} + +export function gistMetadata(g: Gist, now = new Date()): Metadata[] { + const all = files(g); + const languages = [...new Set(all.map((f) => f.language).filter((l): l is string => !!l))]; + return [ + meta.text("Visibility", g.public ? "Public" : "Secret", { + icon: g.public ? Icons.globe : Icons.lock, + color: g.public ? "blue" : "orange", + }), + meta.text("Files", all.length === 1 ? all[0].filename : `${all.length} files`, { icon: Icons.file }), + ...(languages.length ? [meta.tags("Languages", languages.map((l) => ({ text: l, color: languageColor(l) })))] : []), + meta.text("Size", size(all.reduce((n, f) => n + (f.size ?? 0), 0))), + meta.separator(), + ...(g.owner ? [meta.person("Owner", g.owner.login, g.owner.avatar_url)] : []), + ...(g.comments ? [meta.text("Comments", g.comments, { icon: Icons.comment })] : []), + meta.text("Created", ago(g.created_at, now), { icon: Icons.calendar }), + meta.text("Updated", ago(g.updated_at, now), { icon: Icons.clock }), + meta.link("Gist", `gist.github.com/${g.id.slice(0, 8)}`, g.html_url), + ]; +} + +export function gistItem(g: Gist, actions: Action[], now = new Date()): Item { + const all = files(g); + const first = all[0]; + const described = !!g.description?.trim(); + const names = all.map((f) => f.filename); + return { + id: g.id, + title: gistTitle(g), + subtitle: described ? (names.length > 1 ? `${names[0]} +${names.length - 1}` : names[0]) : undefined, + icon: { source: Icons.code, tint: languageColor(first?.language) }, + keywords: [...names, ...all.map((f) => f.language ?? ""), g.public ? "public" : "secret"], + accessories: [ + ...(first?.language ? [{ tag: first.language, color: languageColor(first.language) }] : []), + ...(all.length > 1 ? [{ icon: Icons.file, text: String(all.length), tooltip: `${all.length} files` }] : []), + ...(g.public ? [] : [{ icon: { source: Icons.lock, tint: "orange" as const }, tooltip: "Secret" }]), + ...(g.comments ? [{ icon: Icons.comment, text: String(g.comments), tooltip: "Comments" }] : []), + { text: ago(g.updated_at, now), tooltip: "Updated" }, + ], + detail: { markdown: gistMarkdown(g), metadata: gistMetadata(g, now) }, + actions, + }; +} + +// --- Creating ----------------------------------------------------------------------- + +/** A file name matching what the clipboard looks like. */ +export function guessFilename(content: string): string { + const t = content.trim(); + const first = t.split("\n")[0]; + if (/^#!.*\b(bash|sh|zsh)\b/.test(first)) return "script.sh"; + if (/^#!.*\bpython/.test(first)) return "script.py"; + if (/^#!.*\b(node|deno|bun)\b/.test(first)) return "script.js"; + if (/^[{[]/.test(t)) { + try { + JSON.parse(t); + return "snippet.json"; + } catch { /* not JSON */ } + } + if (/^<\?php/.test(t)) return "snippet.php"; + if (/^<(!doctype html|html)/i.test(t)) return "page.html"; + if (/^<\?xml|^ /^\s*[\w-]+:( |$)/.test(l)).length >= 2) { + return "config.yaml"; + } + return "snippet.txt"; +} + +/** Body of `POST /gists`. */ +export function newGist(values: { description?: string; filename?: string; content: string; visibility?: string }) { + const content = values.content; + if (!content.trim()) throw new Error("The gist is empty: copy some text first, or type it"); + const filename = (values.filename ?? "").trim().replace(/[/\\]/g, "-") || guessFilename(content); + return { + description: (values.description ?? "").trim(), + public: values.visibility === "public", + files: { [filename]: { content } }, + }; +} diff --git a/extensions/gists/lib_test.ts b/extensions/gists/lib_test.ts new file mode 100644 index 0000000..b560d1c --- /dev/null +++ b/extensions/gists/lib_test.ts @@ -0,0 +1,98 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { fence, fileBlock, type Gist, gistItem, gistMarkdown, guessFilename, newGist, size } from "./lib.ts"; + +const now = new Date("2026-10-28T12:00:00Z"); + +// Shaped like GET /gists/{id}. +const gist: Gist = { + id: "aa5a315d61ae9438b18d", + html_url: "https://gist.github.com/ada/aa5a315d61ae9438b18d", + git_pull_url: "https://gist.github.com/aa5a315d61ae9438b18d.git", + description: "Kitty paste workaround", + public: false, + created_at: "2026-10-01T08:00:00Z", + updated_at: "2026-10-27T09:00:00Z", + comments: 2, + owner: { login: "ada", avatar_url: "https://avatars.githubusercontent.com/u/1?v=4" }, + files: { + "paste.sh": { + filename: "paste.sh", + type: "application/x-sh", + language: "Shell", + raw_url: "https://gist.githubusercontent.com/ada/aa5a/raw/paste.sh", + size: 61, + content: '#!/bin/sh\nkitty @ send-text --match recent:0 "$(wl-paste)"\n', + truncated: false, + }, + "README.md": { + filename: "README.md", + type: "text/markdown", + language: "Markdown", + raw_url: "https://gist.githubusercontent.com/ada/aa5a/raw/README.md", + size: 40, + content: "Use it like this:\n\n```sh\n./paste.sh\n```\n", + }, + }, +}; + +Deno.test("code fences", () => { + assertEquals(fence(gist.files["paste.sh"]), "bash"); + assertEquals(fence({ filename: "a.cpp", language: "C++", raw_url: "", size: 1 }), "cpp"); + assertEquals(fence({ filename: "main.rs", language: "Rust", raw_url: "", size: 1 }), "rust"); + assertEquals(fence({ filename: "notes.org", language: null, raw_url: "", size: 1 }), "org"); + assertEquals([size(61), size(4321), size(52_000), size(2_400_000)], ["61 B", "4.3 KB", "52 KB", "2.4 MB"]); +}); + +Deno.test("file blocks", () => { + assertEquals( + fileBlock(gist.files["paste.sh"]), + '**paste.sh** · Shell · 61 B\n\n```bash\n#!/bin/sh\nkitty @ send-text --match recent:0 "$(wl-paste)"\n```', + ); + // Backticks inside get a longer fence. + assertStringIncludes(fileBlock(gist.files["README.md"]), "````markdown\nUse it like this:"); + const long = { ...gist.files["paste.sh"], content: Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n") }; + const block = fileBlock(long, 10); + assertStringIncludes(block, "line 9\n```"); + assertEquals(block.includes("line 10"), false); + assertStringIncludes(block, "more in the full file"); + assertStringIncludes(fileBlock({ ...long, content: undefined }), "_Loading…_"); + assertEquals(gistMarkdown(gist).split("**").length, 5); +}); + +Deno.test("gist item", () => { + const item = gistItem(gist, [], now); + assertEquals(item.title, "Kitty paste workaround"); + assertEquals(item.subtitle, "paste.sh +1"); + assertEquals(item.icon, { source: "bs-code", tint: "green" }); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text ?? a.tooltip), ["Shell", "2", "Secret", "2", "yesterday"]); + assertEquals(item.detail?.metadata?.[0].value, "Secret"); + assertEquals(item.detail?.metadata?.[2].tags?.map((t) => t.text), ["Shell", "Markdown"]); + const untitled = gistItem({ ...gist, description: "", public: true }, [], now); + assertEquals([untitled.title, untitled.subtitle], ["paste.sh", undefined]); +}); + +Deno.test("file names from the clipboard", () => { + assertEquals(guessFilename("#!/usr/bin/env bash\necho hi"), "script.sh"); + assertEquals(guessFilename('{"a": 1}'), "snippet.json"); + assertEquals(guessFilename("{ not json"), "snippet.txt"); + assertEquals(guessFilename('fn main() {\n println!("hi");\n}'), "main.rs"); + assertEquals(guessFilename("package main\n\nfunc main() {}"), "main.go"); + assertEquals(guessFilename("from pathlib import Path\nprint(Path.home())"), "snippet.py"); + assertEquals(guessFilename('import { assert } from "@std/assert";\nconst x: number = 1;'), "snippet.ts"); + assertEquals(guessFilename("SELECT * FROM users WHERE id = 1;"), "query.sql"); + assertEquals(guessFilename("# Title\n\nSome notes"), "notes.md"); + assertEquals(guessFilename("name: CI\non: push\njobs:\n test: {}"), "config.yaml"); + assertEquals(guessFilename('[package]\nname = "x"'), "config.toml"); + assertEquals(guessFilename("just some words"), "snippet.txt"); +}); + +Deno.test("new gist body", () => { + assertEquals(newGist({ description: " Paste ", filename: "a/b.sh", content: "echo", visibility: "public" }), { + description: "Paste", + public: true, + files: { "a-b.sh": { content: "echo" } }, + }); + assertEquals(newGist({ content: '{"a":1}' }).files, { "snippet.json": { content: '{"a":1}' } }); + assertEquals(newGist({ content: "x" }).public, false); + assertThrows(() => newGist({ content: " \n" }), Error, "empty"); +}); diff --git a/extensions/gitea/api.ts b/extensions/gitea/api.ts new file mode 100644 index 0000000..e893068 --- /dev/null +++ b/extensions/gitea/api.ts @@ -0,0 +1,137 @@ +// The Gitea / Forgejo API (`/api/v1`) of the user's server, with a personal access token. + +import { Icons, type Item, open } from "@backslash/api"; + +export const DOCS_URL = "https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens"; + +export class AuthError extends Error { + constructor(message: string, readonly base?: string, readonly kind: "url" | "token" = "token") { + super(message); + } +} + +/** `codeberg.org/`, `https://git.example.com/explore/repos` → `https://…` without path or slash. */ +export function normalizeUrl(input: unknown): string { + let url = String(input ?? "").trim(); + if (!url) return ""; + if (!/^https?:\/\//i.test(url)) { + // Local servers rarely have a certificate. + const local = /^(localhost|\d{1,3}(\.\d{1,3}){3}|[^/:]+\.(local|lan|home\.arpa))(:|\/|$)/i.test(url); + url = `${local ? "http" : "https"}://${url}`; + } + url = url.replace(/\/+$/, "") + .replace(/\/api(\/v1)?(\/.*)?$/, "") + .replace(/\/(explore|user|issues|pulls|notifications|-)(\/.*)?$/, ""); + return url; +} + +export interface Server { + base: string; + token: string; + /** `git.example.com`, for titles. */ + host: string; +} + +export function server(preferences: Record): Server { + const base = normalizeUrl(preferences.url); + if (!base) throw new AuthError("Add the address of your Gitea or Forgejo server", undefined, "url"); + let host: string; + try { + host = new URL(base).host; + } catch { + throw new AuthError(`“${preferences.url}” is not an address`, undefined, "url"); + } + const token = String(preferences.token ?? "").trim(); + if (!token) throw new AuthError(`Add an access token for ${host}`, base); + return { base, token, host }; +} + +export async function api( + s: Server, + path: string, + options: { method?: string; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${s.base}/api/v1${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { Authorization: `token ${s.token}`, Accept: "application/json" }, + }); + } catch (e) { + if (options.signal?.aborted) throw e; + throw new AuthError(`Can't reach ${s.host}: check the address`, s.base, "url"); + } + if (res.status === 401) throw new AuthError(`${s.host} rejected the access token`, s.base); + 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}` + : `${s.host} refused the request${body.message ? `: ${body.message}` : ""}`, + s.base, + ); + } + if (res.status === 429) throw new Error(`${s.host} is rate limiting: try again in a minute`); + if (!res.ok) throw new Error(`${s.host} answered ${res.status}`); + if (res.status === 204 || res.status === 205) return undefined as T; + const text = await res.text(); + if (/^\s* Promise): Promise { + 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): AsyncGenerator { + try { + yield* load(); + } catch (e) { + if (e instanceof AuthError) yield setupItems(e); + else throw e; + } +} + +export const countStatus = (items: Item[], one: string, host?: string, many = `${one}s`) => + items[0]?.id === "setup" + ? "Not connected" + : `${items.length} ${items.length === 1 ? one : many}${host ? ` · ${host}` : ""}`; diff --git a/extensions/gitea/backslash.toml b/extensions/gitea/backslash.toml new file mode 100644 index 0000000..3e21abe --- /dev/null +++ b/extensions/gitea/backslash.toml @@ -0,0 +1,57 @@ +[extension] +id = "gitea" +title = "Gitea & Forgejo" +description = "Pull requests to review, issues, repositories and notifications of your Gitea or Forgejo server, self-hosted or Codeberg-like" +version = "1.0.0" +author = "Backslash" +icon = "bs-repo" +license = "MIT" + +# The server is yours: any address, chosen in the preferences, so its host can't be +# listed ahead. Unlike Home Assistant, Gitea and Forgejo usually live on a public custom +# domain (git.example.com), not only the local network, hence "*": the extension only +# ever calls the address you set. The exact hosts after it let avatars of the public +# instances and Gravatar/Libravatar show (pictures need an exact host). +[permissions] +net = ["*", "codeberg.org", "gitea.com", "secure.gravatar.com", "seccdn.libravatar.org"] + +[[commands]] +name = "pull-requests" +title = "My Gitea Pull Requests" +description = "Review requested, created by you, assigned or mentioning you: with branches, diff and checks" +icon = "bs-pr-open" +keywords = ["gitea", "forgejo", "codeberg", "pr", "pull request", "review", "merge request", "relecture", "demande de fusion"] + +[[commands]] +name = "issues" +title = "My Gitea Issues" +description = "Open issues assigned to you, created by you or mentioning you" +icon = "bs-issue-open" +keywords = ["gitea", "forgejo", "issues", "bugs", "tickets", "problème", "anomalie"] + +[[commands]] +name = "repositories" +title = "Search Gitea Repositories" +description = "Your repositories, or search the whole server as you type" +icon = "bs-repo" +keywords = ["gitea", "forgejo", "repo", "git", "code", "dépôt", "projets"] + +[[commands]] +name = "notifications" +title = "Gitea Notifications" +description = "Unread and pinned notifications; mark them read or pin them" +icon = "bs-bell" +keywords = ["gitea", "forgejo", "notifications", "inbox", "alerts", "alertes", "boîte de réception"] + +[[preferences]] +name = "url" +title = "Server address" +description = "As you open it in the browser: https://git.example.com, https://codeberg.org, http://192.168.1.20:3000" +required = true + +[[preferences]] +name = "token" +title = "Access token" +description = "Your server → Settings → Applications → Generate new token: read access to issue, repository and user, read/write to notification" +type = "secret" +required = true diff --git a/extensions/gitea/commands/issues.ts b/extensions/gitea/commands/issues.ts new file mode 100644 index 0000000..ebf86aa --- /dev/null +++ b/extensions/gitea/commands/issues.ts @@ -0,0 +1,34 @@ +import { list } from "@backslash/api"; +import { api, countStatus, guarded, server } from "../api.ts"; + +import { groupByRepo, type Issue, issueItem } from "../lib.ts"; + +/** The server shown in the footer. */ +let host = ""; + +const FILTERS: Record = { 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", host), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const t = server(preferences); + host = t.host; + const key = filter && FILTERS[filter] ? filter : "assigned"; + const issues = await api( + 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/gitea/commands/notifications.ts b/extensions/gitea/commands/notifications.ts new file mode 100644 index 0000000..8d83214 --- /dev/null +++ b/extensions/gitea/commands/notifications.ts @@ -0,0 +1,89 @@ +import { + action, + close, + copy, + Icons, + type Item, + list, + open, + openExternal, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { api, countStatus, guarded, type Server, server } from "../api.ts"; + +import { ago, type Notification, notificationIcon, notificationMeta, notificationUrl, subjectNumber } from "../lib.ts"; + +/** The server shown in the footer. */ +let host = ""; + +function notificationItem(n: Notification, t: Server): Item { + const number = subjectNumber(n); + const markRead = action("Mark as Read", async () => { + await api(t, `/notifications/threads/${n.id}?to-status=read`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.check, shortcut: "r" }); + const markUnread = action("Mark as Unread", async () => { + await api(t, `/notifications/threads/${n.id}?to-status=unread`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.dot, shortcut: "u" }); + const pin = action(n.pinned ? "Unpin" : "Pin", async () => { + await api(t, `/notifications/threads/${n.id}?to-status=${n.pinned ? "read" : "pinned"}`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.pin, shortcut: "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, t.base)); + if (n.unread) { + await api(t, `/notifications/threads/${n.id}?to-status=read`, { method: "PATCH" }).catch(() => {}); + } + return close(); + }, { icon: Icons.external }), + n.unread ? markRead : markUnread, + pin, + open(`${t.base}/${n.repository.full_name}`, "Open Repository", { icon: Icons.repository }), + copy(notificationUrl(n, t.base), "Copy URL", { shortcut: "c", section: "Copy" }), + action("Mark All as Read", async () => { + await api(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", host), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const t = server(preferences); + host = t.host; + 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 api(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/gitea/commands/pull-requests.ts b/extensions/gitea/commands/pull-requests.ts new file mode 100644 index 0000000..84e56aa --- /dev/null +++ b/extensions/gitea/commands/pull-requests.ts @@ -0,0 +1,60 @@ +import { list, updateCommandMetadata } from "@backslash/api"; +import { api, countStatus, guardedSteps, type Server, server } from "../api.ts"; + +import { type CombinedStatus, groupByRepo, type Issue, issueItem, type Pull, type PullDetail } from "../lib.ts"; + +/** The server shown in the footer. */ +let host = ""; + +const FILTERS: Record = { + 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: Server, pr: Issue, signal: AbortSignal): Promise { + const base = `/repos/${pr.repository.full_name}`; + const detail = await api(t, `${base}/pulls/${pr.number}`, { signal }).catch(() => undefined); + const ci = detail?.head.sha + ? await api(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", host), + items: ({ preferences, signal, filter }) => + guardedSteps(async function* () { + const t = server(preferences); + host = t.host; + const key = filter && FILTERS[filter] ? filter : "review"; + const found = await api( + 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/gitea/commands/repositories.ts b/extensions/gitea/commands/repositories.ts new file mode 100644 index 0000000..0dad82c --- /dev/null +++ b/extensions/gitea/commands/repositories.ts @@ -0,0 +1,47 @@ +import { type Item, list, storage } from "@backslash/api"; +import { api, guarded, server } from "../api.ts"; + +import { type Repo, repoItem } from "../lib.ts"; + +/** The server shown in the footer. */ +let host = ""; + +let mine = 0; + +export default list({ + placeholder: "Search repositories on your server, 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 · ${host}` + : `${items.length} repositories · ${host}`, + search: (query, { preferences, signal }) => + guarded(async () => { + const t = server(preferences); + host = t.host; + const q = query.trim(); + if (!q) { + const repos = await api(t, "/user/repos?limit=50", { signal }); + mine = repos.length; + await storage.set(`mine:${t.base}`, 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([ + api<{ ok: boolean; data: Repo[] }>( + t, + `/repos/search?q=${encodeURIComponent(q)}&limit=30&sort=updated&order=desc`, + { + signal, + }, + ), + storage.get(`mine:${t.base}`).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" : t.host)); + }), +}); diff --git a/extensions/gitea/lib.ts b/extensions/gitea/lib.ts new file mode 100644 index 0000000..d762172 --- /dev/null +++ b/extensions/gitea/lib.ts @@ -0,0 +1,456 @@ +// Gitea and 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(//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 }, + ] + : []; + +/** `https://git.example.com/owner/repo` from an issue or pull request page. */ +export const repoUrl = (i: Issue) => i.html_url.replace(/\/(pulls|issues)\/\d+.*$/, ""); + +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: "f" })] + : []), + copy(i.html_url, "Copy URL", { shortcut: "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 ${repoUrl(i)}.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(//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 the server orders them. */ +export function groupByRepo(issues: Pull[]): Pull[] { + const order = new Map(); + 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 = { + 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: "i" }), + open(`${r.html_url}/pulls`, "Open Pull Requests", { icon: Icons.pullRequest, shortcut: "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: "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, base: string): string { + return n.subject.latest_comment_html_url || n.subject.html_url || `${base}/${n.repository.full_name}`; +} + +const TYPE_NAMES: Record = { + 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/gitea/lib_test.ts b/extensions/gitea/lib_test.ts new file mode 100644 index 0000000..9ea235e --- /dev/null +++ b/extensions/gitea/lib_test.ts @@ -0,0 +1,328 @@ +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, + repoUrl, + stateIcon, + subjectNumber, +} from "./lib.ts"; +import { AuthError, normalizeUrl, server, setupItems } from "./api.ts"; + +// Realistic Forgejo 13 / Gitea 1.24 responses (shapes from the API docs; codeberg.org runs Forgejo). +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", + 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("self-hosted pull request links", () => { + const self = { ...pr, html_url: "https://git.example.com/infra/ansible/pulls/3", number: 3, detail: pull57 }; + assertEquals(repoUrl(self), "https://git.example.com/infra/ansible"); + const checkout = issueItem(self).actions?.find((a) => a.title === "Copy Checkout Command") as { text: string }; + assertEquals( + checkout.text, + "git fetch https://git.example.com/infra/ansible.git pull/3/head:pr-3 && git switch pr-3", + ); +}); + +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"), + "https://codeberg.org/backslash-app/backslash/pulls/57#issuecomment-99", + ); + assertEquals( + notificationUrl(pinned, "https://codeberg.org"), + "https://codeberg.org/backslash-app/backslash/issues/40", + ); + assertEquals([subjectNumber(unread), subjectNumber(pinned)], ["57", "40"]); +}); + +Deno.test("server address and token", () => { + assertEquals( + [ + normalizeUrl("codeberg.org/"), + normalizeUrl("https://git.example.com/api/v1/"), + normalizeUrl("http://192.168.1.20:3000/explore/repos"), + normalizeUrl("https://example.com/gitea/user/settings/applications"), + normalizeUrl(""), + normalizeUrl("192.168.1.20:3000"), + normalizeUrl("forgejo.local"), + ], + [ + "https://codeberg.org", + "https://git.example.com", + "http://192.168.1.20:3000", + "https://example.com/gitea", + "", + "http://192.168.1.20:3000", + "http://forgejo.local", + ], + ); + assertEquals(server({ url: "git.example.com", token: " abc " }), { + base: "https://git.example.com", + token: "abc", + host: "git.example.com", + }); + const fails = (prefs: Record) => { + try { + server(prefs); + } catch (e) { + return e instanceof AuthError ? [e.kind, e.base ?? null] : ["other"]; + } + return []; + }; + assertEquals(fails({ token: "abc" }), ["url", null]); + assertEquals(fails({ url: "https://git.example.com" }), ["token", "https://git.example.com"]); + const [row] = setupItems(new AuthError("Add an access token", "https://git.example.com")); + assertEquals([row.id, row.actions?.[0].title], ["setup", "Create Access Token"]); + assertEquals((row.actions?.[0] as { target: string }).target, "https://git.example.com/user/settings/applications"); + assertEquals(setupItems(new AuthError("Add the address", undefined, "url"))[0].actions?.map((a) => a.title), [ + "How to Create a 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/github-actions/api.ts b/extensions/github-actions/api.ts new file mode 100644 index 0000000..c38ea93 --- /dev/null +++ b/extensions/github-actions/api.ts @@ -0,0 +1,86 @@ +// GitHub REST helpers: the token (preferences, else `gh auth token`) and the setup row. + +import { exec, Icons, type Item, open } from "@backslash/api"; + +export const API = "https://api.github.com"; +export const TOKEN_URL = + "https://github.com/settings/tokens/new?scopes=repo,workflow&description=Backslash%20GitHub%20Actions"; + +const HEADERS = { "User-Agent": "backslash-launcher", "X-GitHub-Api-Version": "2022-11-28" }; + +export class AuthError extends Error {} + +/** The token from the preferences, else the GitHub CLI's (`gh auth token`), if any. */ +export async function token(preferences: Record): Promise { + const saved = String(preferences.token ?? "").trim(); + if (saved) return saved; + try { + const t = await exec("gh", ["auth", "token"]); + if (t) return t; + } catch { /* gh missing or signed out */ } + throw new AuthError("Sign in to GitHub"); +} + +function check(res: Response) { + if (res.status === 401) throw new AuthError("GitHub rejected the token"); + if (res.status === 403 || res.status === 429) { + if (res.headers.get("x-ratelimit-remaining") === "0") { + throw new Error("GitHub rate limit reached: try again in a few minutes"); + } + throw new Error(`GitHub refused the request (${res.status}): the token may lack the repo or workflow scope`); + } + if (res.status === 409) throw new Error("GitHub: the run is not in a state that allows this"); + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); +} + +export async function github( + t: string, + path: string, + options: { method?: string; signal?: AbortSignal; accept?: string } = {}, +): Promise { + const res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { ...HEADERS, Accept: options.accept ?? "application/vnd.github+json", Authorization: `Bearer ${t}` }, + }); + check(res); + if (res.status === 202 || res.status === 204) return undefined as T; + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +/** A job's log as plain text (GitHub redirects to a signed storage URL). */ +export async function jobLog(t: string, repo: string, jobId: number, signal?: AbortSignal): Promise { + const res = await fetch(`${API}/repos/${repo}/actions/jobs/${jobId}/logs`, { + signal, + headers: { ...HEADERS, Authorization: `Bearer ${t}` }, + }); + if (res.status === 404 || res.status === 410) return ""; + check(res); + return await res.text(); +} + +/** The row shown instead of results until a token works. */ +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "GitHub CLI or personal access token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect GitHub", + "", + "**With the GitHub CLI** (simplest): run `gh auth login` in a terminal. Its sign-in is used automatically.", + "", + "**Or with a token:**", + "", + "1. Create a classic token with the **repo** and **workflow** scopes (Enter opens the page).", + "2. Paste it in **Settings → Extensions → GitHub Actions → Configure**.", + "", + "A fine-grained token works too: give it **Actions: Read and write** and **Contents: Read** on your repositories.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Token", { icon: Icons.key })], + }]; +} diff --git a/extensions/github-actions/backslash.toml b/extensions/github-actions/backslash.toml new file mode 100644 index 0000000..ffd6971 --- /dev/null +++ b/extensions/github-actions/backslash.toml @@ -0,0 +1,59 @@ +[extension] +id = "github-actions" +title = "GitHub Actions" +description = "Workflow runs of your repositories, failing and running first, with jobs, steps and logs; re-run or cancel them" +version = "1.0.0" +author = "Backslash" +icon = "bs-activity" +license = "MIT" + +# The REST API and the avatars it links to. Job logs are served from a signed +# redirect to GitHub's log storage (Azure blobs, or the Actions results +# service); their hosts vary per region, hence the two wildcards. +# gh gives its sign-in when no token is set. +[permissions] +net = [ + "api.github.com", + "avatars.githubusercontent.com", + "*.actions.githubusercontent.com", + "*.blob.core.windows.net", +] +run = ["gh"] + +[[commands]] +name = "runs" +title = "GitHub Actions Runs" +description = "Failing, in progress and recent workflow runs: jobs, steps, logs, re-run and cancel" +icon = "bs-activity" +keywords = [ + "github", + "actions", + "ci", + "workflow", + "runs", + "build", + "pipeline", + "checks", + "failing", + "intégration continue", + "exécutions", + "échec", +] + +[[preferences]] +name = "repositories" +title = "Repositories" +description = "Comma-separated owner/name, e.g. backslash-app/backslash, octocat/hello-world" + +[[preferences]] +name = "recent" +title = "Include repositories you pushed to recently" +description = "Adds the repositories you pushed to in the last 30 days (up to 10)" +type = "boolean" +default = true + +[[preferences]] +name = "token" +title = "Personal access token" +description = "Optional when the GitHub CLI is signed in (gh auth login). Needs the repo and workflow scopes to re-run or cancel" +type = "secret" diff --git a/extensions/github-actions/commands/runs.ts b/extensions/github-actions/commands/runs.ts new file mode 100644 index 0000000..7cef01c --- /dev/null +++ b/extensions/github-actions/commands/runs.ts @@ -0,0 +1,276 @@ +import { + type Action, + action, + clipboard, + close, + type Context, + copy, + detail, + Icons, + type Item, + list, + type ListSpec, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { AuthError, github, jobLog, setupItems, token } from "../api.ts"; +import { + arrange, + isActive, + isFailed, + type Job, + jobItem, + jobToShow, + logMarkdown, + logTail, + parseRepositories, + recentRepos, + type Repo, + type Run, + runItem, + summary, + workflowUrl, +} from "../lib.ts"; + +interface Prefs { + repositories?: string; + recent?: boolean; + token?: string; +} + +class SetupError extends Error {} + +let shown = { total: 0, line: "" }; + +// --- Loading -------------------------------------------------------------------------- + +async function repositories(t: string, prefs: Prefs, signal: AbortSignal): Promise { + const names = parseRepositories(prefs.repositories); + if (prefs.recent !== false) { + const mine = await github( + t, + "/user/repos?sort=pushed&per_page=40&affiliation=owner,collaborator,organization_member", + { signal }, + ); + for (const name of recentRepos(mine)) { + if (!names.some((n) => n.toLowerCase() === name.toLowerCase())) names.push(name); + } + } + if (!names.length) { + throw new SetupError( + prefs.recent === false ? "Add repositories to watch" : "You haven't pushed to any repository in 30 days", + ); + } + return names; +} + +async function loadRuns(t: string, prefs: Prefs, signal: AbortSignal) { + const names = await repositories(t, prefs, signal); + const results = await Promise.allSettled( + names.map((name) => github<{ workflow_runs: Run[] }>(t, `/repos/${name}/actions/runs?per_page=30`, { signal })), + ); + const runs: Run[] = []; + const problems: { repo: string; error: string }[] = []; + results.forEach((res, i) => { + if (res.status === "fulfilled") runs.push(...(res.value?.workflow_runs ?? [])); + else if (res.reason instanceof AuthError) throw res.reason; + else { + problems.push({ + repo: names[i], + error: /404/.test(String(res.reason)) ? "Not found, or no access" : String(res.reason?.message ?? res.reason), + }); + } + }); + return { runs, problems, repos: names }; +} + +const logs = new Map>(); + +function cachedLog(t: string, repo: string, job: Job): Promise { + const key = `${repo}/${job.id}/${job.status}`; + if (!logs.has(key)) { + const p = jobLog(t, repo, job.id); + p.catch(() => logs.delete(key)); + logs.set(key, p); + } + return logs.get(key)!; +} + +const jobsOf = (t: string, run: Run, signal?: AbortSignal) => + github<{ jobs: Job[] }>(t, `/repos/${run.repository.full_name}/actions/runs/${run.id}/jobs?per_page=100`, { signal }) + .then((r) => r?.jobs ?? []); + +// --- Views ---------------------------------------------------------------------------- + +function logView(t: string, run: Run, job: Job) { + const repo = run.repository.full_name; + return detail({ + title: job.name, + markdown: async () => logMarkdown(job, await cachedLog(t, repo, job)), + status: `${repo} · ${run.display_title}`, + actions: [ + open(job.html_url, "Open Job in Browser"), + action("Copy Log Tail", async () => { + const { tail } = logTail(await cachedLog(t, repo, job), 200); + await clipboard.write(tail.join("\n")); + return close("Log copied"); + }, { icon: Icons.copy, shortcut: "c", section: "Copy" }), + copy(job.html_url, "Copy Job URL", { section: "Copy" }), + ], + }); +} + +async function failedLog(t: string, run: Run) { + const job = jobToShow(await jobsOf(t, run)); + if (!job) throw new Error("This run has no jobs"); + return logView(t, run, job); +} + +function jobsView(t: string, run: Run): ListSpec { + const repo = run.repository.full_name; + return list({ + title: `${run.display_title} · #${run.run_number}`, + placeholder: "Filter jobs and steps…", + empty: { title: "No jobs yet", description: "The run hasn't started any job.", icon: Icons.layers }, + status: (items) => `${items.length} job${items.length === 1 ? "" : "s"} · ${repo}`, + actions: [open(run.html_url, "Open Run in Browser")], + async items({ signal }) { + const jobs = await jobsOf(t, run, signal); + // Failed first, then running, then the rest, each in GitHub's order. + const rank = (j: Job) => (isFailed(j) ? 0 : isActive(j) ? 1 : 2); + return [...jobs].sort((a, b) => rank(a) - rank(b)).map((job) => + jobItem(job, [ + ...(isFailed(job) ? [action("View Log", () => logView(t, run, job), { icon: Icons.text })] : []), + open(job.html_url, "Open Job in Browser"), + ...(isFailed(job) ? [] : [action("View Log", () => logView(t, run, job), { icon: Icons.text })]), + ...(job.status === "completed" + ? [action("Re-run Job", async () => { + await github(t, `/repos/${repo}/actions/jobs/${job.id}/rerun`, { method: "POST" }); + await toast(`Re-running “${job.name}”`, { style: "success" }); + return refresh(); + }, { icon: Icons.refresh, shortcut: "r", section: "Job" })] + : []), + copy(job.html_url, "Copy Job URL", { shortcut: "c", section: "Copy" }), + ]) + ); + }, + }); +} + +function runActions(t: string, run: Run): Action[] { + const repo = run.repository.full_name; + const post = (path: string, done: string) => async () => { + await github(t, `/repos/${repo}/actions/runs/${run.id}/${path}`, { method: "POST" }); + await toast(done, { style: "success" }); + return refresh(); + }; + const failed = isFailed(run); + const wf = workflowUrl(run); + return [ + open(run.html_url, "Open in Browser"), + ...(failed + ? [action("View Failed Job Log", () => failedLog(t, run), { icon: Icons.text, shortcut: "l" })] + : []), + action("Show Jobs", () => jobsView(t, run), { icon: Icons.layers, shortcut: "j" }), + ...(failed + ? [action("Re-run Failed Jobs", post("rerun-failed-jobs", "Re-running the failed jobs"), { + icon: Icons.refresh, + shortcut: "r", + section: "Run", + })] + : []), + ...(run.status === "completed" + ? [action("Re-run All Jobs", post("rerun", "Re-running all jobs"), { + icon: Icons.refresh, + shortcut: failed ? "r" : "r", + section: "Run", + })] + : []), + ...(wf ? [open(wf, "Open Workflow Runs", { icon: Icons.activity, section: "Run" })] : []), + open(`${run.repository.html_url}/actions`, "Open Repository Actions", { icon: Icons.repository, section: "Run" }), + copy(run.html_url, "Copy Run URL", { shortcut: "c", section: "Copy" }), + copy(run.head_sha, "Copy Commit SHA", { icon: Icons.commit, section: "Copy" }), + ...(run.head_branch ? [copy(run.head_branch, "Copy Branch Name", { icon: Icons.branch, section: "Copy" })] : []), + ...(isActive(run) + ? [action("Cancel Run", post("cancel", "Cancelling the run"), { + icon: Icons.stop, + destructive: true, + confirm: `Cancel run #${run.run_number} of ${run.name ?? "this workflow"}?`, + shortcut: "x", + section: "Danger", + })] + : []), + ]; +} + +function problemItem(p: { repo: string; error: string }): Item { + return { + id: `problem:${p.repo}`, + title: p.repo, + subtitle: p.error, + icon: { source: Icons.alert, tint: "orange" }, + section: "Not Loaded", + detail: { + markdown: + `**${p.repo}** couldn't be loaded: ${p.error}.\n\nCheck the name in **Settings → Extensions → GitHub Actions**, and that your token can read this repository.`, + }, + actions: [open(`https://github.com/${p.repo}`, "Open on GitHub")], + }; +} + +function setupRow(message: string): Item[] { + return [{ + id: "setup", + title: message, + subtitle: "Settings → Extensions → GitHub Actions", + icon: { source: Icons.settings, tint: "orange" }, + detail: { + markdown: [ + "## Choose repositories", + "", + "In **Settings → Extensions → GitHub Actions → Configure**:", + "", + "- **Repositories**: `owner/name` separated by commas, e.g. `backslash-app/backslash, octocat/hello-world`.", + "- **Include repositories you pushed to recently**: adds those you pushed to in the last 30 days.", + ].join("\n"), + }, + actions: [open("https://github.com/settings/repositories", "Open Your Repositories")], + }]; +} + +export default (_ctx: Context) => + list({ + placeholder: "Filter runs by commit, repository, workflow, branch, actor…", + filter: { + options: [ + { value: "failing", title: "Failing" }, + { value: "running", title: "In progress" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "Nothing here", description: "No workflow run matches this view.", icon: Icons.activity }, + status: (items) => + items[0]?.id === "setup" ? "Not set up" : [shown.line, `${shown.total} runs`].filter(Boolean).join(" · "), + async items({ preferences, signal, filter }) { + const prefs = preferences as Prefs; + try { + const t = await token(preferences); + const { runs, problems } = await loadRuns(t, prefs, signal); + const line = summary(runs); + shown = { total: runs.length, line }; + updateCommandMetadata({ subtitle: line || null }).catch(() => {}); + const now = new Date(); + return [ + ...arrange(runs, filter ?? "all").map(({ run, section }) => runItem(run, section, runActions(t, run), now)), + ...problems.map(problemItem), + ]; + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + if (e instanceof SetupError) return setupRow(e.message); + throw e; + } + }, + }); diff --git a/extensions/github-actions/lib.ts b/extensions/github-actions/lib.ts new file mode 100644 index 0000000..363b495 --- /dev/null +++ b/extensions/github-actions/lib.ts @@ -0,0 +1,419 @@ +// Workflow runs, jobs and logs: parsing and how they look. Tested in lib_test.ts. + +import { type Action, type Color, Icons, type Item, meta, type Metadata, type Stat } from "@backslash/api"; + +// --- GitHub objects ---------------------------------------------------------------- + +export interface Actor { + login: string; + avatar_url?: string; +} + +/** `GET /repos/{owner}/{repo}/actions/runs`. */ +export interface Run { + id: number; + name?: string | null; + display_title: string; + run_number: number; + run_attempt?: number; + event: string; + status: string | null; + conclusion: string | null; + workflow_id: number; + path?: string; + head_branch: string | null; + head_sha: string; + html_url: string; + created_at: string; + updated_at: string; + run_started_at?: string; + actor?: Actor | null; + triggering_actor?: Actor | null; + head_commit?: { id: string; message: string; author?: { name: string } } | null; + repository: { full_name: string; html_url: string }; + pull_requests?: { number: number }[]; +} + +export interface Step { + name: string; + number: number; + status: string; + conclusion: string | null; + started_at?: string | null; + completed_at?: string | null; +} + +/** `GET /repos/{owner}/{repo}/actions/runs/{id}/jobs`. */ +export interface Job { + id: number; + run_id: number; + name: string; + status: string; + conclusion: string | null; + html_url: string; + started_at?: string | null; + completed_at?: string | null; + runner_name?: string | null; + labels?: string[]; + steps?: Step[]; +} + +export interface Repo { + full_name: string; + pushed_at?: string | null; + archived?: boolean; + disabled?: boolean; +} + +// --- Preferences -------------------------------------------------------------------- + +/** "a/b, https://github.com/c/d.git\ne/f" → ["a/b", "c/d", "e/f"]. */ +export function parseRepositories(text: unknown): string[] { + const out: string[] = []; + for (const raw of String(text ?? "").split(/[\s,;]+/)) { + const name = raw.trim() + .replace(/^(https?:\/\/)?(www\.)?github\.com\//i, "") + .replace(/^git@github\.com:/i, "") + .replace(/\.git$/, "") + .replace(/\/+$/, "") + .split("/").slice(0, 2).join("/"); + if (/^[\w.-]+\/[\w.-]+$/.test(name) && !out.some((n) => n.toLowerCase() === name.toLowerCase())) out.push(name); + } + return out; +} + +/** Repositories pushed to in the last `days`, most recent first. */ +export function recentRepos(repos: Repo[], now = new Date(), days = 30, max = 10): string[] { + const since = now.getTime() - days * 86_400_000; + return repos + .filter((r) => !r.archived && !r.disabled && r.pushed_at && Date.parse(r.pushed_at) >= since) + .sort((a, b) => Date.parse(b.pushed_at!) - Date.parse(a.pushed_at!)) + .slice(0, max) + .map((r) => r.full_name); +} + +// --- States ------------------------------------------------------------------------- + +export type Kind = "failure" | "running" | "queued" | "waiting" | "success" | "cancelled" | "skipped" | "neutral"; + +export interface State { + kind: Kind; + icon: string; + tint: Color; + text: string; +} + +const FAILED = new Set(["failure", "timed_out", "startup_failure"]); + +/** What a run, job or step is: the icon and words GitHub would show. */ +export function stateOf(o: { status: string | null; conclusion: string | null }): State { + switch (o.status) { + case "in_progress": + return { kind: "running", icon: Icons.inProgress, tint: "yellow", text: "In progress" }; + case "queued": + case "requested": + case "pending": + return { kind: "queued", icon: Icons.todo, tint: "yellow", text: "Queued" }; + case "waiting": + return { kind: "waiting", icon: Icons.hourglass, tint: "orange", text: "Waiting for approval" }; + } + switch (o.conclusion) { + case "success": + return { kind: "success", icon: Icons.done, tint: "green", text: "Succeeded" }; + case "failure": + return { kind: "failure", icon: Icons.canceled, tint: "red", text: "Failed" }; + case "timed_out": + return { kind: "failure", icon: Icons.canceled, tint: "red", text: "Timed out" }; + case "startup_failure": + return { kind: "failure", icon: Icons.canceled, tint: "red", text: "Startup failure" }; + case "action_required": + return { kind: "waiting", icon: Icons.alert, tint: "orange", text: "Action required" }; + case "cancelled": + return { kind: "cancelled", icon: Icons.stop, tint: "gray", text: "Cancelled" }; + case "skipped": + return { kind: "skipped", icon: Icons.backlog, tint: "gray", text: "Skipped" }; + case "stale": + return { kind: "neutral", icon: Icons.history, tint: "gray", text: "Stale" }; + default: + return { kind: "neutral", icon: Icons.done, tint: "gray", text: "Neutral" }; + } +} + +export const isActive = (r: { status: string | null }) => !!r.status && r.status !== "completed"; +export const isFailed = (o: { conclusion: string | null; status: string | null }) => + o.status === "completed" && FAILED.has(o.conclusion ?? ""); + +/** + * Runs that are failing now: the latest completed run of a workflow on a + * branch failed. A failure fixed by a later green run is history, not failing. + */ +export function failing(runs: Run[]): Run[] { + const latest = new Map(); + for (const r of runs) { + if (r.status !== "completed" || r.conclusion === "cancelled" || r.conclusion === "skipped") continue; + const key = `${r.repository.full_name}|${r.workflow_id}|${r.head_branch ?? ""}`; + const seen = latest.get(key); + if (!seen || Date.parse(r.created_at) > Date.parse(seen.created_at)) latest.set(key, r); + } + return [...latest.values()].filter(isFailed).sort((a, b) => b.created_at.localeCompare(a.created_at)); +} + +export type Section = "Failing" | "In Progress" | "Recent"; + +/** The runs a filter shows, in order, with their section. */ +export function arrange(runs: Run[], filter: string, max = 60): { run: Run; section: Section }[] { + const byDate = [...runs].sort((a, b) => b.created_at.localeCompare(a.created_at)); + const fail = failing(runs); + const active = byDate.filter(isActive); + if (filter === "failing") return fail.map((run) => ({ run, section: "Failing" })); + if (filter === "running") return active.map((run) => ({ run, section: "In Progress" })); + const shown = new Set([...fail, ...active].map((r) => r.id)); + return [ + ...fail.map((run) => ({ run, section: "Failing" as const })), + ...active.map((run) => ({ run, section: "In Progress" as const })), + ...byDate.filter((r) => !shown.has(r.id)).slice(0, max).map((run) => ({ run, section: "Recent" as const })), + ]; +} + +/** "2 failing · 1 running", for the footer and the root search. */ +export function summary(runs: Run[]): string { + const f = failing(runs).length; + const a = runs.filter(isActive).length; + return [f ? `${f} failing` : "", a ? `${a} running` : ""].filter(Boolean).join(" · "); +} + +// --- Formatting --------------------------------------------------------------------- + +/** 45 s → "45s", 192 s → "3m 12s", 3840 s → "1h 04m". */ +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}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +function span(start: string | null | undefined, end: string | null | undefined, now: Date): number | undefined { + if (!start) return undefined; + const s = Date.parse(start); + const e = end ? Date.parse(end) : now.getTime(); + return Number.isFinite(s) && Number.isFinite(e) ? e - s : undefined; +} + +export function runDuration(r: Run, now = new Date()): string | undefined { + const ms = span(r.run_started_at ?? r.created_at, r.status === "completed" ? r.updated_at : undefined, now); + return ms === undefined ? undefined : duration(ms); +} + +export function jobDuration(j: Job | Step, now = new Date()): string | undefined { + if (!j.started_at) return undefined; + const ms = span(j.started_at, j.status === "completed" ? j.completed_at : undefined, now); + return ms === undefined ? undefined : duration(ms); +} + +/** "just now", "5 min ago", "3 h ago", "yesterday", "4 days ago", "12 Oct". */ +export function ago(iso: string, now = new Date()): string { + const s = (now.getTime() - Date.parse(iso)) / 1000; + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86_400) return `${Math.floor(s / 3600)} h ago`; + const days = Math.floor(s / 86_400); + if (days === 1) return "yesterday"; + if (days < 7) return `${days} days ago`; + return new Date(iso).toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +const EVENTS: Record = { + push: "Push", + pull_request: "Pull request", + pull_request_target: "Pull request (target)", + schedule: "Schedule", + workflow_dispatch: "Manual", + workflow_run: "After another workflow", + release: "Release", + merge_group: "Merge queue", + repository_dispatch: "Repository dispatch", + dynamic: "Dynamic", +}; + +export const eventName = (e: string) => EVENTS[e] ?? e.replace(/_/g, " "); + +export const headline = (message: string | undefined | null) => (message ?? "").split("\n")[0].trim(); + +const workflowName = (r: Run) => r.name || r.path?.split("/").pop() || "Workflow"; + +/** The workflow file on GitHub: `.github/workflows/ci.yml` at the run's commit. */ +export function workflowUrl(r: Run): string | undefined { + const file = r.path?.split("@")[0]; + return file ? `${r.repository.html_url}/actions/workflows/${file.split("/").pop()}` : undefined; +} + +// --- Runs --------------------------------------------------------------------------- + +export function runStats(r: Run, now = new Date()): Stat[] { + const d = runDuration(r, now); + return [ + ...(d ? [{ label: isActive(r) ? "running for" : "duration", value: d }] : []), + { label: "run", value: `#${r.run_number}` }, + ...((r.run_attempt ?? 1) > 1 ? [{ label: "attempt", value: r.run_attempt!, color: "orange" as Color }] : []), + ]; +} + +export function runMetadata(r: Run, now = new Date()): Metadata[] { + const s = stateOf(r); + const lines: Metadata[] = [ + meta.text("Status", s.text, { icon: s.icon, color: s.tint }), + meta.text("Workflow", workflowName(r), { icon: Icons.activity }), + meta.text("Repository", r.repository.full_name, { icon: Icons.repository }), + ]; + if (r.head_branch) lines.push(meta.text("Branch", r.head_branch, { icon: Icons.branch })); + lines.push(meta.text("Event", eventName(r.event), { icon: Icons.bolt })); + if (r.pull_requests?.length) { + lines.push(meta.tags("Pull requests", r.pull_requests.map((p) => `#${p.number}`))); + } + lines.push(meta.text("Commit", r.head_sha.slice(0, 7), { icon: Icons.commit })); + lines.push(meta.separator()); + if (r.actor) lines.push(meta.person("Actor", r.actor.login, r.actor.avatar_url)); + if (r.triggering_actor && r.triggering_actor.login !== r.actor?.login) { + lines.push(meta.person("Re-run by", r.triggering_actor.login, r.triggering_actor.avatar_url)); + } + lines.push(meta.text("Started", ago(r.run_started_at ?? r.created_at, now), { icon: Icons.clock })); + const d = runDuration(r, now); + if (d) lines.push(meta.text("Duration", d, { icon: Icons.timer })); + lines.push(meta.link("Run", `${r.repository.full_name.split("/")[1]} #${r.run_number}`, r.html_url)); + return lines; +} + +export function runItem(r: Run, section: string | undefined, actions: Action[], now = new Date()): Item { + const s = stateOf(r); + const d = runDuration(r, now); + const message = r.head_commit?.message?.trim(); + return { + id: String(r.id), + title: r.display_title || headline(message) || workflowName(r), + subtitle: `${r.repository.full_name.split("/")[1]} · ${workflowName(r)}`, + icon: { source: s.icon, tint: s.tint }, + section, + keywords: [r.repository.full_name, workflowName(r), r.head_branch ?? "", r.actor?.login ?? "", r.event, s.text], + accessories: [ + ...(r.head_branch ? [{ icon: Icons.branch, text: r.head_branch, tooltip: "Branch" }] : []), + ...(d ? [{ icon: Icons.timer, text: d, tooltip: isActive(r) ? "Running for" : "Duration" }] : []), + ...(r.actor ? [{ avatar: r.actor.avatar_url ?? r.actor.login, tooltip: r.actor.login }] : []), + { text: ago(r.created_at, now), tooltip: s.text }, + ], + detail: { + stats: runStats(r, now), + markdown: message ? `**${headline(message)}**${message.includes("\n") ? `\n\n${body(message)}` : ""}` : undefined, + metadata: runMetadata(r, now), + }, + actions, + }; +} + +function body(message: string): string { + const rest = message.split("\n").slice(1).join("\n").trim(); + return rest.length > 600 ? `${rest.slice(0, 600).trimEnd()}…` : rest; +} + +// --- Jobs and steps ----------------------------------------------------------------- + +export function stepLines(job: Job, now = new Date()): Metadata[] { + return (job.steps ?? []).map((step) => { + const s = stateOf(step); + const d = jobDuration(step, now); + return meta.text(step.name, d ? `${s.text} · ${d}` : s.text, { icon: s.icon, color: s.tint }); + }); +} + +export function failedStep(job: Job): Step | undefined { + return job.steps?.find((s) => isFailed(s)); +} + +export function jobItem(job: Job, actions: Action[], now = new Date()): Item { + const s = stateOf(job); + const d = jobDuration(job, now); + const steps = job.steps ?? []; + const done = steps.filter((st) => st.status === "completed").length; + const bad = failedStep(job); + return { + id: String(job.id), + title: job.name, + subtitle: bad ? `Failed at “${bad.name}”` : undefined, + icon: { source: s.icon, tint: s.tint }, + section: s.kind === "failure" ? "Failed" : s.kind === "running" || s.kind === "queued" ? "Running" : "Jobs", + keywords: [s.text, ...steps.map((st) => st.name)], + accessories: [ + ...(steps.length ? [{ text: `${done}/${steps.length} steps` }] : []), + ...(d ? [{ icon: Icons.timer, text: d }] : []), + ], + detail: { + stats: [ + { label: "status", value: s.text, color: s.tint }, + ...(d ? [{ label: "duration", value: d }] : []), + ...(steps.length ? [{ label: "steps", value: `${done}/${steps.length}` }] : []), + ], + metadata: [ + ...stepLines(job, now), + ...(job.runner_name || job.labels?.length ? [meta.separator()] : []), + ...(job.labels?.length ? [meta.tags("Runs on", job.labels)] : []), + ...(job.runner_name ? [meta.text("Runner", job.runner_name, { icon: Icons.server })] : []), + ], + }, + actions, + }; +} + +// --- Logs --------------------------------------------------------------------------- + +/** One readable line per log line: no timestamps, colors or workflow commands. */ +export function cleanLog(text: string): string[] { + const out: string[] = []; + for (const raw of text.replace(/^\uFEFF/, "").split(/\r?\n/)) { + let line = raw.replace(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z ?/, "") + // deno-lint-ignore no-control-regex + .replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + if (line.startsWith("##[endgroup]")) continue; + if (line.startsWith("##[group]")) line = `▸ ${line.slice(9)}`; + else if (line.startsWith("##[error]")) line = `Error: ${line.slice(9)}`; + else if (line.startsWith("##[warning]")) line = `Warning: ${line.slice(11)}`; + else if (line.startsWith("##[notice]")) line = `Notice: ${line.slice(10)}`; + else if (line.startsWith("##[debug]")) continue; + out.push(line); + } + while (out.length && !out[out.length - 1].trim()) out.pop(); + return out; +} + +/** The end of a log, and its error lines. */ +export function logTail(text: string, max = 80): { errors: string[]; tail: string[]; total: number } { + const lines = cleanLog(text); + // GitHub's own clean-up steps come after the failure: stop before them. + let end = lines.length; + const post = lines.findIndex((l) => /^▸ Post job cleanup\.|^Cleaning up orphan processes/.test(l)); + if (post > 0) end = post; + const errors = lines.slice(0, end).filter((l) => /^Error: /.test(l)).map((l) => l.slice(7)).slice(-10); + return { errors, tail: lines.slice(Math.max(0, end - max), end), total: lines.length }; +} + +export function logMarkdown(job: Job, text: string, max = 80): string { + if (!text.trim()) return `# ${job.name}\n\n_The log is not available (expired, or the job did not start)._`; + const { errors, tail, total } = logTail(text, max); + const bad = failedStep(job); + const fence = tail.some((l) => l.includes("```")) ? "~~~" : "```"; + return [ + `# ${job.name}`, + "", + ...(bad ? [`Failed at step **${bad.number}. ${bad.name}**`, ""] : []), + ...(errors.length ? ["## Errors", "", ...errors.map((e) => `- ${e.replace(/([*_`[\]])/g, "\\$1")}`), ""] : []), + `## Last ${tail.length} of ${total} lines`, + "", + `${fence}text`, + ...tail, + fence, + ].join("\n"); +} + +/** The failed job a log action should show, else the first one. */ +export function jobToShow(jobs: Job[]): Job | undefined { + return jobs.find(isFailed) ?? jobs[0]; +} diff --git a/extensions/github-actions/lib_test.ts b/extensions/github-actions/lib_test.ts new file mode 100644 index 0000000..d95f391 --- /dev/null +++ b/extensions/github-actions/lib_test.ts @@ -0,0 +1,242 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + ago, + arrange, + cleanLog, + duration, + failing, + isActive, + type Job, + jobItem, + jobToShow, + logMarkdown, + logTail, + parseRepositories, + recentRepos, + type Run, + runDuration, + runItem, + stateOf, + summary, + workflowUrl, +} from "./lib.ts"; + +const now = new Date("2026-10-28T12:00:00Z"); +const repository = { + full_name: "backslash-app/backslash", + html_url: "https://github.com/backslash-app/backslash", +}; +const ada = { login: "ada", avatar_url: "https://avatars.githubusercontent.com/u/1?v=4" }; + +// Shaped like GET /repos/{owner}/{repo}/actions/runs. +function run(over: Partial): Run { + return { + id: 1, + name: "CI", + display_title: "Detect terminals before pasting", + run_number: 812, + run_attempt: 1, + event: "push", + status: "completed", + conclusion: "success", + workflow_id: 100, + path: ".github/workflows/ci.yml", + head_branch: "main", + head_sha: "8f3c2a1d9e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a", + html_url: "https://github.com/backslash-app/backslash/actions/runs/1", + created_at: "2026-10-28T11:00:00Z", + updated_at: "2026-10-28T11:04:12Z", + run_started_at: "2026-10-28T11:00:05Z", + actor: ada, + triggering_actor: ada, + head_commit: { id: "8f3c2a1", message: "Detect terminals before pasting\n\nKitty needs --single-instance." }, + repository, + pull_requests: [], + ...over, + }; +} + +const runs: Run[] = [ + // main: failed, then fixed: history. + run({ id: 1, conclusion: "failure", created_at: "2026-10-28T08:00:00Z" }), + run({ id: 2, conclusion: "success", created_at: "2026-10-28T09:00:00Z" }), + // feature branch: latest failed → failing. + run({ id: 3, head_branch: "fix/kitty", conclusion: "success", created_at: "2026-10-28T07:00:00Z" }), + run({ id: 4, head_branch: "fix/kitty", conclusion: "timed_out", created_at: "2026-10-28T10:00:00Z" }), + // Release workflow on main: failed, then cancelled (cancelled doesn't count) → still failing. + run({ id: 5, workflow_id: 200, name: "Release", conclusion: "failure", created_at: "2026-10-27T10:00:00Z" }), + run({ id: 6, workflow_id: 200, name: "Release", conclusion: "cancelled", created_at: "2026-10-27T11:00:00Z" }), + // Running now. + run({ id: 7, status: "in_progress", conclusion: null, created_at: "2026-10-28T11:55:00Z" }), + run({ id: 8, status: "queued", conclusion: null, created_at: "2026-10-28T11:58:00Z" }), +]; + +Deno.test("repositories from the preference", () => { + assertEquals( + parseRepositories( + "backslash-app/backslash, https://github.com/octocat/hello-world.git\ngit@github.com:denoland/deno.git, nope, BACKSLASH-APP/backslash", + ), + ["backslash-app/backslash", "octocat/hello-world", "denoland/deno"], + ); + assertEquals(parseRepositories(undefined), []); + assertEquals( + recentRepos([ + { full_name: "a/old", pushed_at: "2026-08-01T00:00:00Z" }, + { full_name: "a/new", pushed_at: "2026-10-27T00:00:00Z" }, + { full_name: "a/archived", pushed_at: "2026-10-27T00:00:00Z", archived: true }, + { full_name: "a/mid", pushed_at: "2026-10-20T00:00:00Z" }, + ], now), + ["a/new", "a/mid"], + ); +}); + +Deno.test("states", () => { + assertEquals(stateOf({ status: "completed", conclusion: "failure" }).tint, "red"); + assertEquals(stateOf({ status: "in_progress", conclusion: null }).text, "In progress"); + assertEquals(stateOf({ status: "waiting", conclusion: null }).kind, "waiting"); + assertEquals(stateOf({ status: "completed", conclusion: "cancelled" }).icon, "bs-stop"); + assertEquals(stateOf({ status: "completed", conclusion: "skipped" }).kind, "skipped"); + assertEquals(isActive(runs[6]), true); + assertEquals(isActive(runs[0]), false); +}); + +Deno.test("failing means the latest run of a workflow on a branch failed", () => { + assertEquals(failing(runs).map((r) => r.id), [4, 5]); + assertEquals(summary(runs), "2 failing · 2 running"); + assertEquals(summary([runs[1]]), ""); +}); + +Deno.test("arranging runs by filter", () => { + assertEquals(arrange(runs, "failing").map((r) => r.run.id), [4, 5]); + assertEquals(arrange(runs, "running").map((r) => r.run.id), [8, 7]); + const all = arrange(runs, "all"); + assertEquals(all.map((r) => `${r.section}:${r.run.id}`), [ + "Failing:4", + "Failing:5", + "In Progress:8", + "In Progress:7", + "Recent:2", + "Recent:1", + "Recent:3", + "Recent:6", + ]); +}); + +Deno.test("durations and dates", () => { + assertEquals([duration(45_000), duration(192_000), duration(3_840_000)], ["45s", "3m 12s", "1h 04m"]); + assertEquals(runDuration(runs[1], now), "4m 07s"); + assertEquals(runDuration(run({ status: "in_progress", run_started_at: "2026-10-28T11:58:30Z" }), now), "1m 30s"); + assertEquals( + [ago("2026-10-28T11:59:30Z", now), ago("2026-10-28T11:15:00Z", now), ago("2026-10-27T09:00:00Z", now)], + ["just now", "45 min ago", "yesterday"], + ); + assertEquals(workflowUrl(runs[0]), "https://github.com/backslash-app/backslash/actions/workflows/ci.yml"); +}); + +Deno.test("run item", () => { + const item = runItem(runs[3], "Failing", [], now); + assertEquals(item.title, "Detect terminals before pasting"); + assertEquals(item.subtitle, "backslash · CI"); + assertEquals(item.icon, { source: "bs-circle-x", tint: "red" }); + assertEquals(item.accessories?.map((a) => a.text ?? a.avatar), [ + "fix/kitty", + "4m 07s", + "https://avatars.githubusercontent.com/u/1?v=4", + "2 h ago", + ]); + assertEquals(item.detail?.markdown, "**Detect terminals before pasting**\n\nKitty needs --single-instance."); + const labels = item.detail?.metadata?.map((m) => m.label); + assertEquals(labels?.slice(0, 6), ["Status", "Workflow", "Repository", "Branch", "Event", "Commit"]); + assertEquals(item.detail?.metadata?.find((m) => m.label === "Status")?.value, "Timed out"); + assertEquals(item.detail?.stats?.map((s) => s.value), ["4m 07s", "#812"]); +}); + +// Shaped like GET /repos/{owner}/{repo}/actions/runs/{id}/jobs. +const jobs: Job[] = [ + { + id: 11, + run_id: 4, + name: "lint", + status: "completed", + conclusion: "success", + html_url: "https://github.com/backslash-app/backslash/actions/runs/4/job/11", + started_at: "2026-10-28T10:00:10Z", + completed_at: "2026-10-28T10:00:58Z", + labels: ["ubuntu-latest"], + runner_name: "GitHub Actions 3", + steps: [ + { name: "Set up job", number: 1, status: "completed", conclusion: "success" }, + { name: "deno lint", number: 2, status: "completed", conclusion: "success" }, + ], + }, + { + id: 12, + run_id: 4, + name: "test (ubuntu-latest)", + status: "completed", + conclusion: "failure", + html_url: "https://github.com/backslash-app/backslash/actions/runs/4/job/12", + started_at: "2026-10-28T10:00:10Z", + completed_at: "2026-10-28T10:03:40Z", + labels: ["ubuntu-latest"], + steps: [ + { + name: "Set up job", + number: 1, + status: "completed", + conclusion: "success", + started_at: "2026-10-28T10:00:10Z", + completed_at: "2026-10-28T10:00:12Z", + }, + { name: "cargo test", number: 2, status: "completed", conclusion: "failure" }, + { name: "Upload coverage", number: 3, status: "completed", conclusion: "skipped" }, + ], + }, +]; + +Deno.test("job item with its steps", () => { + const item = jobItem(jobs[1], [], now); + assertEquals(item.subtitle, "Failed at “cargo test”"); + assertEquals(item.section, "Failed"); + assertEquals(item.accessories?.map((a) => a.text), ["3/3 steps", "3m 30s"]); + assertEquals(item.detail?.metadata?.slice(0, 3).map((m) => [m.label, m.value, m.color]), [ + ["Set up job", "Succeeded · 2s", "green"], + ["cargo test", "Failed", "red"], + ["Upload coverage", "Skipped", "gray"], + ]); + assertEquals(jobToShow(jobs)?.id, 12); +}); + +const LOG = [ + "2026-10-28T10:00:10.1234567Z ##[group]Run cargo test", + "2026-10-28T10:00:10.1234567Z \x1b[36;1mcargo test --workspace\x1b[0m", + "2026-10-28T10:00:10.1234567Z ##[endgroup]", + "2026-10-28T10:03:30.0000000Z ##[debug]Evaluating condition", + "2026-10-28T10:03:31.0000000Z test paste::kitty ... \x1b[31mFAILED\x1b[0m", + "2026-10-28T10:03:31.0000000Z thread 'paste::kitty' panicked at src/paste.rs:42:5", + "2026-10-28T10:03:39.0000000Z ##[error]Process completed with exit code 101.", + "2026-10-28T10:03:40.0000000Z Post job cleanup.", + "2026-10-28T10:03:40.0000000Z ##[group]Post job cleanup.", + "2026-10-28T10:03:40.0000000Z Cleaning up orphan processes", + "", +].join("\n"); + +Deno.test("log tail", () => { + assertEquals(cleanLog(LOG).slice(0, 3), [ + "▸ Run cargo test", + "cargo test --workspace", + "test paste::kitty ... FAILED", + ]); + const tail = logTail(LOG, 3); + assertEquals(tail.errors, ["Process completed with exit code 101."]); + assertEquals(tail.tail, [ + "thread 'paste::kitty' panicked at src/paste.rs:42:5", + "Error: Process completed with exit code 101.", + "Post job cleanup.", + ]); + const md = logMarkdown(jobs[1], LOG); + assertStringIncludes(md, "Failed at step **2. cargo test**"); + assertStringIncludes(md, "## Errors\n\n- Process completed with exit code 101."); + assertStringIncludes(md, "```text\n▸ Run cargo test"); + assertStringIncludes(logMarkdown(jobs[1], ""), "not available"); +}); diff --git a/extensions/google-workspace/api.ts b/extensions/google-workspace/api.ts new file mode 100644 index 0000000..a96a363 --- /dev/null +++ b/extensions/google-workspace/api.ts @@ -0,0 +1,108 @@ +// Authenticated calls to the Drive and Tasks APIs, and the "connect" row shown until the client is set up. + +import { action, copy, Icons, type Item, oauth, open, refresh } from "@backslash/api"; +import { AuthError, CONSENT_URL, CONSOLE_URL, ENABLE_URL, googleError, provider } from "./lib.ts"; + +export interface Client { + get(url: string, params?: Record, signal?: AbortSignal): Promise; + send(method: "POST" | "PATCH" | "DELETE", url: string, body?: unknown): 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); + const call = async (method: string, url: string, body?: unknown, signal?: AbortSignal) => { + let res: Response; + try { + res = await fetch(url, { + 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(`Google is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (!res.ok) { + const error = googleError(res.status, text); + if (error instanceof AuthError) await oauth.signOut(p).catch(() => {}); + throw error; + } + return text ? JSON.parse(text) : undefined; + }; + return { + get: (url, params, signal) => + call("GET", params ? `${url}?${new URLSearchParams(params)}` : url, undefined, signal), + send: (method, url, body) => call(method, url, body), + signOut: () => oauth.signOut(p), + }; +} + +export function setupItems(error: AuthError): Item[] { + if (error.message === "Google signed you out" || error.message.startsWith("Sign in again")) { + return [{ + id: "setup", + title: error.message, + subtitle: "Sign in again in the browser", + icon: { source: Icons.key, tint: "orange" }, + actions: [action("Sign In", () => refresh(), { icon: Icons.key })], + }]; + } + return [{ + id: "setup", + title: error.message, + subtitle: "Google signs in with an OAuth client of your own (free)", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Google Drive & Tasks", + "", + "Backslash has no shared Google app, so you sign in through a client in your own Google Cloud project:", + "", + "1. In **Google Cloud Console**, pick or create a project, then **enable the Google Drive API and", + " the Google Tasks API** (Ctrl+Enter opens that page).", + "2. **Google Auth platform → Audience**: if it asks, configure the consent screen (External),", + " and add your own address under **Test users**.", + "3. **APIs & Services → Credentials** (Enter) → **Create credentials → OAuth client ID**,", + " **Application type: Desktop app**, name it *Backslash*. No redirect URI to add: desktop", + " clients accept the loopback address Backslash uses.", + "4. Copy the **Client ID** and the **Client secret** to", + " **Settings → Extensions → Google Drive & Tasks → Configure**.", + "", + "The first command you open asks you to sign in and to allow read-only Drive access and Tasks.", + "Google says “Google hasn't verified this app” for a test app: it's yours, **Continue**.", + ].join("\n"), + }, + actions: [ + open(CONSOLE_URL, "Open Google Cloud Credentials", { icon: Icons.key }), + open(ENABLE_URL, "Enable Drive and Tasks APIs", { icon: Icons.plug }), + open(CONSENT_URL, "Open Consent Screen Settings", { icon: Icons.shield }), + copy(CONSOLE_URL, "Copy Console Link", { section: "Copy" }), + ], + }]; +} + +/** Runs `load`, or shows how to connect when the client isn't set up or the token was refused. */ +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); + throw e; + } +} + +export function signOutAction(prefs: Record, after: () => void) { + return action("Sign Out of Google", async () => { + await oauth.signOut(provider(prefs)); + after(); + }, { icon: Icons.power, section: "Account" }); +} diff --git a/extensions/google-workspace/backslash.toml b/extensions/google-workspace/backslash.toml new file mode 100644 index 0000000..e6d825d --- /dev/null +++ b/extensions/google-workspace/backslash.toml @@ -0,0 +1,67 @@ +[extension] +id = "google-workspace" +title = "Google Drive & Tasks" +description = "Search Google Drive and manage Google Tasks with your own Google sign-in" +version = "1.0.0" +author = "Backslash" +icon = "bs-drive" +license = "MIT" + +# The Drive and Tasks APIs, the sign-in (token endpoint), and owners' profile +# pictures (Drive's photoLink is served from lh3.googleusercontent.com). +# Google Calendar is in the Calendar extension. +[permissions] +net = ["www.googleapis.com", "tasks.googleapis.com", "oauth2.googleapis.com", "lh3.googleusercontent.com"] + +[[commands]] +name = "drive" +title = "Search Google Drive" +description = "Files by name or content; recent, starred and shared with you" +icon = "bs-drive" +keywords = [ + "google drive", + "drive", + "docs", + "sheets", + "slides", + "files", + "documents", + "google", + "gdrive", + "fichiers", + "rechercher", + "partagés avec moi", + "suivis", +] + +[[commands]] +name = "tasks" +title = "Google Tasks" +description = "Tasks due today and coming up across your lists; Enter completes" +icon = "bs-square-check" +keywords = ["google tasks", "tasks", "todo", "to do", "google", "tâches", "à faire", "aujourd'hui"] + +[[commands]] +name = "add-task" +title = "Add Google Task" +description = "“Send invoice friday #Work”: due date and list are understood" +icon = "bs-plus" +mode = "no-view" +keywords = ["google tasks", "add", "new task", "quick add", "ajouter", "nouvelle tâche"] +[[commands.arguments]] +name = "task" +placeholder = "Send invoice friday #Work" +required = true + +[[preferences]] +name = "clientId" +title = "OAuth client ID" +description = "console.cloud.google.com → APIs & Services: enable the Google Drive API and the Google Tasks API, then Credentials → Create credentials → OAuth client ID → Application type: Desktop app. Paste its Client ID (…apps.googleusercontent.com)" +required = true + +[[preferences]] +name = "clientSecret" +title = "OAuth client secret" +description = "The Client secret of the same Desktop app client (Google wants it even for desktop apps)" +type = "secret" +required = true diff --git a/extensions/google-workspace/commands/add-task.ts b/extensions/google-workspace/commands/add-task.ts new file mode 100644 index 0000000..b41994c --- /dev/null +++ b/extensions/google-workspace/commands/add-task.ts @@ -0,0 +1,17 @@ +import { run } from "@backslash/api"; +import { client } from "../api.ts"; +import { AuthError } from "../lib.ts"; +import { createTask, loadLists } from "../tasks.ts"; + +// "Add Google Task Send invoice friday #Work": added at once, no form. +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); + return await createTask(api, await loadLists(api, ctx.signal), { text }); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Google Tasks to set it up`); + throw e; + } +}); diff --git a/extensions/google-workspace/commands/drive.ts b/extensions/google-workspace/commands/drive.ts new file mode 100644 index 0000000..e6883a4 --- /dev/null +++ b/extensions/google-workspace/commands/drive.ts @@ -0,0 +1,107 @@ +import { action, type Context, copy, Icons, type Item, list, open } from "@backslash/api"; +import { type Client, guarded, signOutAction } from "../api.ts"; +import { + avatarOf, + DRIVE, + type DriveFile, + driveMetadata, + driveQuery, + type DriveView, + fileKind, + fileUrl, + FOLDER, + folderUrl, + formatSize, + ownerName, + ownerOf, + plural, + shortAgo, +} from "../lib.ts"; + +let summary = ""; + +function fileItem(api: Client, f: DriveFile, now: Date, section?: string): Item { + const isFolder = f.mimeType === FOLDER || f.shortcutDetails?.targetMimeType === FOLDER; + const kind = fileKind(f.shortcutDetails?.targetMimeType ?? f.mimeType); + const modified = f.modifiedTime ? new Date(f.modifiedTime) : undefined; + const owner = ownerOf(f); + const url = fileUrl(f); + return { + id: f.id, + title: f.name, + subtitle: kind.label, + icon: { source: kind.icon, tint: kind.tint }, + section, + keywords: [ownerName(f), kind.label], + accessories: [ + ...(f.starred ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Starred" }] : []), + ...(f.shared ? [{ icon: Icons.person, tooltip: "Shared" }] : []), + ...(f.size ? [{ text: formatSize(f.size) }] : []), + ...(modified ? [{ text: shortAgo(modified, now), tooltip: `Modified ${modified.toLocaleString()}` }] : []), + ...(owner && !owner.me ? [{ avatar: avatarOf(owner), tooltip: `Owner: ${ownerName(f)}` }] : []), + ], + detail: { + stats: [ + { label: "Size", value: isFolder ? "—" : formatSize(f.size) }, + ...(modified ? [{ label: "Modified", value: shortAgo(modified, now) }] : []), + ], + metadata: driveMetadata(f, now), + }, + actions: [ + ...(isFolder + ? [ + action("Browse Folder", () => folderView(api, f.shortcutDetails?.targetId ?? f.id, f.name), { + icon: Icons.folder, + }), + open(url, "Open in Google Drive"), + ] + : [open(url, "Open in Browser")]), + ...(f.parents?.[0] && !isFolder + ? [open(folderUrl(f.parents[0]), "Open Containing Folder", { icon: Icons.folder, shortcut: "o" })] + : []), + copy(url, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(f.name, "Copy Name", { shortcut: "period", section: "Copy" }), + copy(`[${f.name}](${url})`, "Copy as Markdown Link", { section: "Copy" }), + ], + }; +} + +function folderView(api: Client, id: string, name: string) { + return list({ + title: name, + placeholder: `Filter ${name}…`, + empty: { title: "Empty folder", icon: Icons.folder }, + status: (items) => `${plural(items.length, "item")} · ${name}`, + actions: [open(folderUrl(id), "Open in Google Drive")], + items: async ({ signal }) => { + const res = await api.get<{ files: DriveFile[] }>(`${DRIVE}/files`, driveQuery("recent", "", id), signal); + const now = new Date(); + return res.files.map((f) => fileItem(api, f, now, f.mimeType === FOLDER ? "Folders" : "Files")); + }, + }); +} + +const VIEWS: Record = { recent: "Recent", starred: "Starred", shared: "Shared with me" }; + +export default (ctx: Context) => + list({ + placeholder: "Search Google Drive by name or content…", + filter: { + options: Object.entries(VIEWS).map(([value, title]) => ({ value, title })), + }, + empty: { title: "No file found", description: "Drive searches names and content.", icon: Icons.search }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + open("https://drive.google.com/drive/my-drive", "Open Google Drive", { icon: Icons.drive }), + signOutAction(ctx.preferences, () => {}), + ], + search: (query, { signal, filter }) => + guarded(ctx.preferences, async (api) => { + const view = (filter && filter in VIEWS ? filter : "recent") as DriveView; + const q = query.trim(); + const res = await api.get<{ files: DriveFile[] }>(`${DRIVE}/files`, driveQuery(view, q), signal); + summary = `${plural(res.files.length, "file")} · ${VIEWS[view]}${q ? ` · “${q}”` : ""}`; + const now = new Date(); + return res.files.map((f) => fileItem(api, f, now)); + }), + }); diff --git a/extensions/google-workspace/commands/tasks.ts b/extensions/google-workspace/commands/tasks.ts new file mode 100644 index 0000000..8e7cfa0 --- /dev/null +++ b/extensions/google-workspace/commands/tasks.ts @@ -0,0 +1,86 @@ +import { action, type Context, Icons, type Item, list, open, updateCommandMetadata } from "@backslash/api"; +import { client, guarded, signOutAction } from "../api.ts"; +import { AuthError, dueBucket, dueOf, plural, sortTasks, type TaskList, TASKS_URL, withSubtasks } from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; +import { loadLists, loadTasks, taskForm, taskItem } from "../tasks.ts"; + +const VIEWS = [ + { value: "today", title: "Today" }, + { value: "week", title: "Next 7 Days" }, + { value: "all", title: "All" }, +]; + +let summary = ""; +let lists: TaskList[] = []; + +export default async (ctx: Context) => { + // The lists become chips after the date views; without a sign-in there are none yet. + try { + lists = await loadLists(await client(ctx.preferences), ctx.signal); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + } + return list({ + placeholder: "Filter tasks by title, notes or list…", + filter: { + options: [...VIEWS, ...lists.map((l) => ({ value: `list:${l.id}`, title: l.title }))], + }, + empty: { title: "Nothing to do", description: "Press Ctrl+N to add a task.", icon: Icons.squareCheck }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async () => taskForm(await client(ctx.preferences), lists, { title: ctx.query }), { + icon: Icons.plus, + shortcut: "n", + }), + open(TASKS_URL, "Open Google Tasks", { icon: Icons.squareCheck }), + signOutAction(ctx.preferences, notifyChanged), + ], + items: ({ signal, filter }) => + live(() => + guarded(ctx.preferences, async (api): Promise => { + lists = await loadLists(api, signal); + const tasks = await loadTasks(api, lists, signal); + const now = new Date(); + const bucket = (t: (typeof tasks)[number]) => dueBucket(dueOf(t), now); + const today = tasks.filter((t) => bucket(t) === "Today").length; + const overdue = tasks.filter((t) => bucket(t) === "Overdue").length; + updateCommandMetadata({ + subtitle: [today ? `${plural(today, "task")} today` : "", overdue ? `${overdue} overdue` : ""] + .filter(Boolean).join(" · ") || null, + }).catch(() => {}); + + const view = filter ?? "today"; + const listId = view.startsWith("list:") ? view.slice(5) : undefined; + const known = listId ? lists.find((l) => l.id === listId) : undefined; + const byId = new Map(tasks.map((t) => [t.id, t])); + const keep = (t: (typeof tasks)[number]) => { + if (listId) return !known || t.listId === listId; + if (view === "all") return true; + const b = bucket(t); + if (view === "week") return b !== "Later" && b !== "No Due Date"; + return b === "Today" || b === "Overdue"; + }; + const shown = tasks.filter(keep); + summary = [ + plural(shown.length, "task"), + overdue ? `${overdue} overdue` : "", + known ? known.title : plural(lists.length, "list"), + ].filter(Boolean).join(" · "); + // In one list, keep Google's order with subtasks under their parent; elsewhere, by due date. + if (known) { + return withSubtasks(shown.sort((a, b) => (a.position ?? "").localeCompare(b.position ?? ""))).map(( + { task }, + ) => + taskItem(api, task, lists, { + parent: task.parent ? byId.get(task.parent) : undefined, + now, + section: known.title, + }) + ); + } + return sortTasks(shown).map((t) => + taskItem(api, t, lists, { parent: t.parent ? byId.get(t.parent) : undefined, now }) + ); + }), signal), + }); +}; diff --git a/extensions/google-workspace/lib.ts b/extensions/google-workspace/lib.ts new file mode 100644 index 0000000..876dbe6 --- /dev/null +++ b/extensions/google-workspace/lib.ts @@ -0,0 +1,503 @@ +// Google Drive and Tasks: the sign-in, response types and pure logic. + +import { type Color, Icons, meta, type Metadata, type OAuthProvider } from "@backslash/api"; + +export const DRIVE = "https://www.googleapis.com/drive/v3"; +export const TASKS = "https://tasks.googleapis.com/tasks/v1"; +export const CONSOLE_URL = "https://console.cloud.google.com/apis/credentials"; +export const ENABLE_URL = + "https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com,tasks.googleapis.com"; +export const CONSENT_URL = "https://console.cloud.google.com/auth/audience"; +export const SCOPES = ["https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/tasks"]; + +/** The OAuth client isn't set up, or Google refused the token: lists show how to connect. */ +export class AuthError extends Error {} + +/** + * The user's own "Desktop app" OAuth client. Google wants its secret even + * with PKCE; for installed apps it isn't confidential (Google says so too). + * Desktop clients accept any loopback port and path, so nothing is registered. + */ +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 Google account"); + if (!clientId.endsWith(".apps.googleusercontent.com")) { + throw new AuthError("The client ID ends with .apps.googleusercontent.com: check the preference"); + } + if (!clientSecret) throw new AuthError("Add the client secret of your Google OAuth client"); + return { + id: "google", + title: "Google Drive & Tasks", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + clientId, + clientSecret, + scopes: SCOPES, + // A refresh token every time, so the sign-in lasts. + params: { access_type: "offline", prompt: "consent" }, + }; +} + +/** Google's error body: `{ error: { code, message, errors: [{ reason }] } }`. */ +export function googleError(status: number, text: string): Error { + let message = ""; + let reason = ""; + try { + const body = JSON.parse(text); + message = body?.error?.message ?? body?.error_description ?? ""; + reason = body?.error?.errors?.[0]?.reason ?? body?.error?.status ?? ""; + } catch { + message = text.slice(0, 160); + } + if (status === 401) return new AuthError("Google signed you out"); + if ( + status === 403 && /accessNotConfigured|SERVICE_DISABLED|has not been used|is disabled/i.test(`${reason} ${message}`) + ) { + const api = /tasks/i.test(message) ? "Google Tasks API" : /drive/i.test(message) ? "Google Drive API" : "API"; + return new Error(`Enable the ${api} in your Google Cloud project, then try again`); + } + if (status === 403 && /insufficient/i.test(`${reason} ${message}`)) { + return new AuthError("Sign in again to allow Drive and Tasks access"); + } + if (status === 403 && /rateLimit|userRateLimit/i.test(reason) || status === 429) { + return new Error("Google is rate limiting: try again in a minute"); + } + if (status === 404) return new Error("Not found: it may have been moved or deleted"); + return new Error(`Google answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Shared formatting + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + +/** Whole days from `now`'s day to `date`'s day (negative in the past). */ +export function dayDiff(date: Date, now = new Date()): number { + return Math.round((startOfDay(date).getTime() - startOfDay(now).getTime()) / 86_400_000); +} + +/** "now", "5 min", "3 h", "yesterday", "Mon", "12 Oct", "12 Oct 2024". */ +export function shortAgo(date: Date, now = new Date()): string { + const s = (now.getTime() - date.getTime()) / 1000; + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)} min`; + const d = dayDiff(date, now); + if (d === 0) return `${Math.floor(s / 3600)} h`; + if (d === -1) return "yesterday"; + if (d > -7) return date.toLocaleDateString("en-GB", { weekday: "short" }); + return date.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }), + }); +} + +export function fullDate(date: Date): string { + return date.toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function mdEscape(text: string): string { + return text.replace(/([\\`*_[\]#<>|])/g, "\\$1"); +} + +// --------------------------------------------------------------------------- +// Drive + +export interface User { + displayName?: string; + emailAddress?: string; + photoLink?: string; + me?: boolean; +} + +export interface DriveFile { + id: string; + name: string; + mimeType: string; + modifiedTime?: string; + viewedByMeTime?: string; + sharedWithMeTime?: string; + size?: string; + shared?: boolean; + starred?: boolean; + ownedByMe?: boolean; + webViewLink?: string; + parents?: string[]; + owners?: User[]; + lastModifyingUser?: User; + sharingUser?: User; + driveId?: string; + shortcutDetails?: { targetId?: string; targetMimeType?: string }; +} + +export const FILE_FIELDS = + "id,name,mimeType,modifiedTime,viewedByMeTime,sharedWithMeTime,size,shared,starred,ownedByMe,webViewLink,parents,owners(displayName,emailAddress,photoLink,me),lastModifyingUser(displayName,photoLink,me),sharingUser(displayName,photoLink),driveId,shortcutDetails"; + +export const FOLDER = "application/vnd.google-apps.folder"; + +export type DriveView = "recent" | "starred" | "shared"; + +/** A string inside a Drive query: backslashes and single quotes escaped. */ +export const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; + +/** + * The `files.list` parameters for a view and a query. Drive refuses + * `orderBy` with `fullText` searches, so typed searches come by relevance. + */ +export function driveQuery(view: DriveView, query: string, folder?: string): Record { + const terms = ["trashed = false"]; + const q = query.trim(); + if (folder) terms.push(`${quote(folder)} in parents`); + else if (view === "starred") terms.push("starred = true"); + else if (view === "shared") terms.push("sharedWithMe = true"); + if (q) terms.push(`(name contains ${quote(q)} or fullText contains ${quote(q)})`); + const params: Record = { + q: terms.join(" and "), + pageSize: folder ? "200" : "40", + fields: `files(${FILE_FIELDS})`, + supportsAllDrives: "true", + includeItemsFromAllDrives: "true", + spaces: "drive", + }; + if (!q) { + params.orderBy = folder + ? "folder,name_natural" + : view === "recent" + ? "viewedByMeTime desc" + : view === "shared" + ? "sharedWithMeTime desc" + : "modifiedTime desc"; + } + return params; +} + +export interface Kind { + label: string; + icon: string; + tint?: Color; +} + +const GOOGLE_KINDS: Record = { + [FOLDER]: { label: "Folder", icon: Icons.folder, tint: "gray" }, + "application/vnd.google-apps.document": { label: "Google Docs", icon: Icons.text, tint: "blue" }, + "application/vnd.google-apps.spreadsheet": { label: "Google Sheets", icon: Icons.chartUp, tint: "green" }, + "application/vnd.google-apps.presentation": { label: "Google Slides", icon: Icons.layers, tint: "yellow" }, + "application/vnd.google-apps.form": { label: "Google Forms", icon: Icons.squareCheck, tint: "purple" }, + "application/vnd.google-apps.drawing": { label: "Google Drawings", icon: Icons.palette, tint: "red" }, + "application/vnd.google-apps.site": { label: "Google Sites", icon: Icons.globe, tint: "purple" }, + "application/vnd.google-apps.script": { label: "Apps Script", icon: Icons.code, tint: "blue" }, + "application/vnd.google-apps.map": { label: "My Maps", icon: Icons.pin, tint: "red" }, + "application/vnd.google-apps.jam": { label: "Jamboard", icon: Icons.palette, tint: "orange" }, + "application/vnd.google-apps.shortcut": { label: "Shortcut", icon: Icons.link, tint: "gray" }, + "application/pdf": { label: "PDF", icon: Icons.file, tint: "red" }, +}; + +/** A glyph and a word for a MIME type. */ +export function fileKind(mime: string): Kind { + if (GOOGLE_KINDS[mime]) return GOOGLE_KINDS[mime]; + if (mime.startsWith("image/")) return { label: "Image", icon: Icons.image, tint: "red" }; + if (mime.startsWith("video/")) return { label: "Video", icon: Icons.film, tint: "red" }; + if (mime.startsWith("audio/")) return { label: "Audio", icon: Icons.music, tint: "pink" }; + if (/zip|compressed|x-tar|x-7z|x-rar|gzip/.test(mime)) return { label: "Archive", icon: Icons.archive, tint: "gray" }; + if (/wordprocessingml|msword|opendocument\.text|rtf/.test(mime)) { + return { label: "Word document", icon: Icons.text, tint: "blue" }; + } + if (/spreadsheetml|ms-excel|opendocument\.spreadsheet|text\/csv/.test(mime)) { + return { label: "Spreadsheet", icon: Icons.chartUp, tint: "green" }; + } + if (/presentationml|ms-powerpoint|opendocument\.presentation/.test(mime)) { + return { label: "Presentation", icon: Icons.layers, tint: "orange" }; + } + if (/json|xml|yaml|toml/.test(mime)) return { label: "Data", icon: Icons.braces, tint: "teal" }; + if (/javascript|typescript|x-python|x-sh|x-c|x-java/.test(mime)) { + return { label: "Code", icon: Icons.code, tint: "teal" }; + } + if (mime.startsWith("text/")) return { label: "Text", icon: Icons.note }; + const short = mime.split("/").pop()?.replace(/^x-/, "") ?? ""; + return { label: short ? `${short.toUpperCase()} file` : "File", icon: Icons.file, tint: "gray" }; +} + +export function formatSize(bytes?: string | number): string { + if (bytes === undefined || bytes === null || bytes === "") return "—"; + const n0 = Number(bytes); + if (!Number.isFinite(n0)) return "—"; + if (n0 < 1024) return `${n0} B`; + const units = ["KB", "MB", "GB", "TB"]; + let n = n0 / 1024; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`; +} + +/** Where to open a file: its own link, else Drive's generic one. */ +export function fileUrl(f: DriveFile): string { + if (f.webViewLink) return f.webViewLink; + return f.mimeType === FOLDER + ? `https://drive.google.com/drive/folders/${f.id}` + : `https://drive.google.com/file/d/${f.id}/view`; +} + +export const folderUrl = (id: string) => `https://drive.google.com/drive/folders/${id}`; + +export const ownerOf = (f: DriveFile): User | undefined => f.owners?.[0]; + +/** "me", the owner's name, or the shared drive. */ +export function ownerName(f: DriveFile): string { + const o = ownerOf(f); + if (!o) return f.driveId ? "Shared drive" : ""; + return o.me ? "me" : o.displayName || o.emailAddress || ""; +} + +/** Google profile pictures, when Drive gives one; else the name (initials). */ +export function avatarOf(u?: User): string | undefined { + if (!u) return undefined; + if (u.photoLink && /^https:\/\/lh3\.googleusercontent\.com\//.test(u.photoLink)) return u.photoLink; + return u.displayName || u.emailAddress; +} + +export function driveMetadata(f: DriveFile, now = new Date()): Metadata[] { + const kind = fileKind(f.mimeType); + const lines: Metadata[] = [ + meta.text("Kind", kind.label, { icon: { source: kind.icon, tint: kind.tint } }), + ]; + if (f.mimeType !== FOLDER) lines.push(meta.text("Size", formatSize(f.size))); + const owner = ownerOf(f); + if (owner) { + const name = owner.me ? "me" : owner.displayName || owner.emailAddress || ""; + lines.push(meta.person("Owner", name, avatarOf(owner))); + } else if (f.driveId) lines.push(meta.text("Owner", "Shared drive", { icon: Icons.drive })); + if (f.modifiedTime) { + const at = new Date(f.modifiedTime); + lines.push(meta.text("Modified", `${fullDate(at)} (${shortAgo(at, now)})`, { icon: Icons.clock })); + } + if (f.lastModifyingUser?.displayName) { + lines.push(meta.person( + "Modified by", + f.lastModifyingUser.me ? "me" : f.lastModifyingUser.displayName, + avatarOf(f.lastModifyingUser), + )); + } + if (f.viewedByMeTime) lines.push(meta.text("Opened by me", shortAgo(new Date(f.viewedByMeTime), now))); + if (f.sharingUser?.displayName && !f.ownedByMe) { + lines.push(meta.person("Shared by", f.sharingUser.displayName, avatarOf(f.sharingUser))); + } + const tags: { text: string; color?: Color }[] = []; + if (f.starred) tags.push({ text: "Starred", color: "yellow" }); + if (f.shared) tags.push({ text: "Shared", color: "blue" }); + if (!f.ownedByMe && f.sharedWithMeTime) tags.push({ text: "Shared with me", color: "purple" }); + if (tags.length) lines.push(meta.separator(), meta.tags("Labels", tags)); + return lines; +} + +// --------------------------------------------------------------------------- +// Tasks + +export interface TaskList { + id: string; + title: string; + updated?: string; +} + +export interface Task { + id: string; + title: string; + notes?: string; + status: "needsAction" | "completed"; + /** RFC 3339, but only the date is kept by Google ("2026-10-28T00:00:00.000Z"). */ + due?: string; + parent?: string; + position?: string; + updated?: string; + webViewLink?: string; + links?: { type?: string; description?: string; link: string }[]; + /** Set by us: the list the task came from. */ + listId?: string; +} + +/** The due *date*: read the calendar day from the string, never through a time zone. */ +export function dueOf(task: Pick): Date | undefined { + const m = task.due?.match(/^(\d{4})-(\d{2})-(\d{2})/); + return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : undefined; +} + +/** A due date as Google Tasks writes it (midnight UTC of that day). */ +export function googleDue(date: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}T00:00:00.000Z`; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"] as const; +export type Bucket = typeof BUCKETS[number]; + +export function dueBucket(due: Date | undefined, now = new Date()): Bucket { + if (!due) return "No Due Date"; + const d = dayDiff(due, now); + if (d < 0) return "Overdue"; + if (d === 0) return "Today"; + if (d === 1) return "Tomorrow"; + if (d < 7) return "This Week"; + return "Later"; +} + +export function shortDue(due: Date, now = new Date()): string { + const d = dayDiff(due, now); + if (d === 0) return "today"; + if (d === 1) return "tomorrow"; + if (d === -1) return "yesterday"; + if (d < 0 && d > -7) return `${-d} days ago`; + if (d > 1 && d < 7) return due.toLocaleDateString("en-GB", { weekday: "short" }); + return due.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + ...(due.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }), + }); +} + +/** By due date (undated last), then in the list's own order. */ +export function sortTasks(tasks: Task[]): Task[] { + return [...tasks].sort((a, b) => { + const da = dueOf(a)?.getTime() ?? Infinity; + const db = dueOf(b)?.getTime() ?? Infinity; + return da - db || (a.position ?? "").localeCompare(b.position ?? ""); + }); +} + +/** Subtasks after their parent, so the indentation reads right. */ +export function withSubtasks(tasks: Task[]): { task: Task; depth: number }[] { + const ids = new Set(tasks.map((t) => t.id)); + const top = tasks.filter((t) => !t.parent || !ids.has(t.parent)); + const out: { task: Task; depth: number }[] = []; + for (const t of top) { + out.push({ task: t, depth: 0 }); + for (const c of tasks.filter((c) => c.parent === t.id)) out.push({ task: c, depth: 1 }); + } + return out; +} + +export function taskMarkdown(task: Task): string { + const lines = [`### ${mdEscape(task.title || "Untitled")}`]; + if (task.notes?.trim()) lines.push("", mdEscape(task.notes.trim())); + const links = (task.links ?? []).filter((l) => l.link); + if (links.length) { + lines.push("", ...links.map((l) => `- [${mdEscape(l.description || l.type || "Link")}](${l.link})`)); + } + return lines.join("\n"); +} + +export function taskMetadata(task: Task, list: TaskList | undefined, parent?: Task, now = new Date()): Metadata[] { + const due = dueOf(task); + const lines: Metadata[] = [ + meta.text("List", list?.title ?? "My Tasks", { icon: Icons.inbox }), + meta.text( + "Due", + due + ? `${due.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })} (${ + shortDue(due, now) + })` + : "No due date", + { icon: Icons.calendar, color: dueBucket(due, now) === "Overdue" ? "red" : undefined }, + ), + ]; + if (parent) lines.push(meta.text("Subtask of", parent.title, { icon: Icons.chevronRight })); + if (task.updated) { + lines.push(meta.separator(), meta.text("Updated", fullDate(new Date(task.updated)), { icon: Icons.clock })); + } + return lines; +} + +export const TASKS_URL = "https://tasks.google.com/"; + +const WEEKDAYS: Record = { + sunday: 0, + dimanche: 0, + monday: 1, + lundi: 1, + tuesday: 2, + mardi: 2, + wednesday: 3, + mercredi: 3, + thursday: 4, + jeudi: 4, + friday: 5, + vendredi: 5, + saturday: 6, + samedi: 6, +}; + +export interface QuickAdd { + title: string; + due?: Date; + list?: string; +} + +/** + * "Send invoice friday #Work" → title, due date, list name. Dates: today, + * tomorrow, a weekday (the next one), "next week", "in 3 days", 2026-11-02, + * 2/11 (day/month), and their French words. + */ +export function parseQuickAdd(text: string, now = new Date()): QuickAdd { + let rest = ` ${text.trim()} `; + const out: QuickAdd = { title: "" }; + const take = (re: RegExp, fn: (m: RegExpMatchArray) => boolean | void) => { + const m = rest.match(re); + if (m && fn(m) !== false) rest = rest.replace(m[0], " "); + }; + const day = (offset: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset); + + take(/\s#("[^"]+"|\S+)/, (m) => void (out.list = m[1].replace(/^"|"$/g, ""))); + take(/\s(today|aujourd'hui)(?=\s)/i, () => void (out.due = day(0))); + if (!out.due) take(/\s(tomorrow|demain)(?=\s)/i, () => void (out.due = day(1))); + if (!out.due) take(/\s(?:after tomorrow|après-demain)(?=\s)/i, () => void (out.due = day(2))); + if (!out.due) { + take(/\s(?:next week|la semaine prochaine|semaine prochaine)(?=\s)/i, () => { + out.due = day((8 - now.getDay()) % 7 || 7); + }); + } + if (!out.due) { + take(/\s(?:in|dans) (\d{1,3}) (days?|jours?|weeks?|semaines?)(?=\s)/i, (m) => { + out.due = day(Number(m[1]) * (/^(w|s)/i.test(m[2]) ? 7 : 1)); + }); + } + if (!out.due) { + take(/\s(\d{4})-(\d{2})-(\d{2})(?=\s)/, (m) => { + out.due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + }); + } + if (!out.due) { + take(/\s(\d{1,2})\/(\d{1,2})(?=\s)/, (m) => { + const d = Number(m[1]), mo = Number(m[2]); + if (d < 1 || d > 31 || mo < 1 || mo > 12) return false; + let date = new Date(now.getFullYear(), mo - 1, d); + if (dayDiff(date, now) < 0) date = new Date(now.getFullYear() + 1, mo - 1, d); + out.due = date; + }); + } + if (!out.due) { + take(new RegExp(`\\s(?:on |next )?(${Object.keys(WEEKDAYS).join("|")})(?=\\s)`, "i"), (m) => { + out.due = day((WEEKDAYS[m[1].toLowerCase()] - now.getDay() + 7) % 7 || 7); + }); + } + out.title = rest.replace(/\s+/g, " ").trim(); + return out; +} + +/** The list a quick add names, by exact then prefix match, ignoring case and accents. */ +export function findList(lists: TaskList[], name?: string): TaskList | undefined { + if (!name) return undefined; + const norm = (s: string) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().trim(); + const n = norm(name); + return lists.find((l) => norm(l.title) === n) ?? lists.find((l) => norm(l.title).startsWith(n)); +} diff --git a/extensions/google-workspace/lib_test.ts b/extensions/google-workspace/lib_test.ts new file mode 100644 index 0000000..1d963d1 --- /dev/null +++ b/extensions/google-workspace/lib_test.ts @@ -0,0 +1,205 @@ +import { assertEquals, assertInstanceOf, assertMatch, assertThrows } from "@std/assert"; +import { + AuthError, + avatarOf, + type DriveFile, + driveMetadata, + driveQuery, + dueBucket, + dueOf, + fileKind, + fileUrl, + findList, + formatSize, + googleDue, + googleError, + ownerName, + parseQuickAdd, + provider, + quote, + shortAgo, + sortTasks, + type Task, + withSubtasks, +} from "./lib.ts"; + +const CLIENT = "1234-abc.apps.googleusercontent.com"; + +Deno.test("provider: the user's Desktop client, its secret, offline access", () => { + assertThrows(() => provider({}), AuthError, "Connect"); + assertThrows(() => provider({ clientId: "abc" }), AuthError, "apps.googleusercontent.com"); + assertThrows(() => provider({ clientId: CLIENT }), AuthError, "client secret"); + const p = provider({ clientId: ` ${CLIENT} `, clientSecret: " GOCSPX-x " }); + assertEquals([p.clientId, p.clientSecret], [CLIENT, "GOCSPX-x"]); + assertEquals(p.tokenUrl, "https://oauth2.googleapis.com/token"); + assertEquals(p.scopes, ["https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/tasks"]); + assertEquals(p.params?.access_type, "offline"); + assertEquals(p.redirectPort, undefined); +}); + +Deno.test("googleError: sign-out, disabled API, rate limits", () => { + assertInstanceOf(googleError(401, ""), AuthError); + const disabled = JSON.stringify({ + error: { + code: 403, + message: + "Google Tasks API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=123", + errors: [{ reason: "accessNotConfigured" }], + status: "PERMISSION_DENIED", + }, + }); + assertEquals( + googleError(403, disabled).message, + "Enable the Google Tasks API in your Google Cloud project, then try again", + ); + const scopes = JSON.stringify({ + error: { + code: 403, + message: "Request had insufficient authentication scopes.", + errors: [{ reason: "insufficientPermissions" }], + }, + }); + assertInstanceOf(googleError(403, scopes), AuthError); + assertMatch( + googleError(403, JSON.stringify({ error: { errors: [{ reason: "userRateLimitExceeded" }] } })).message, + /rate/, + ); + assertEquals( + googleError(400, JSON.stringify({ error: { message: "Invalid Value" } })).message, + "Google answered 400: Invalid Value", + ); +}); + +Deno.test("drive: queries per view, escaped search, no orderBy with full text", () => { + assertEquals(quote("Bob's \\ plan"), "'Bob\\'s \\\\ plan'"); + const recent = driveQuery("recent", ""); + assertEquals(recent.q, "trashed = false"); + assertEquals(recent.orderBy, "viewedByMeTime desc"); + assertEquals(driveQuery("starred", "").q, "trashed = false and starred = true"); + assertEquals(driveQuery("shared", "").orderBy, "sharedWithMeTime desc"); + const search = driveQuery("shared", "budget"); + assertEquals( + search.q, + "trashed = false and sharedWithMe = true and (name contains 'budget' or fullText contains 'budget')", + ); + assertEquals(search.orderBy, undefined); + const folder = driveQuery("starred", "", "F1"); + assertEquals([folder.q, folder.orderBy], ["trashed = false and 'F1' in parents", "folder,name_natural"]); + assertMatch(recent.fields, /^files\(.*owners\(displayName,emailAddress,photoLink,me\)/); +}); + +Deno.test("drive: kinds, sizes and links", () => { + assertEquals(fileKind("application/vnd.google-apps.spreadsheet").label, "Google Sheets"); + assertEquals( + fileKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document").label, + "Word document", + ); + assertEquals(fileKind("image/png").label, "Image"); + assertEquals(fileKind("application/x-zip-compressed").label, "Archive"); + assertEquals(fileKind("application/octet-stream").label, "OCTET-STREAM file"); + assertEquals(formatSize(undefined), "—"); + assertEquals(formatSize("2048"), "2.0 KB"); + assertEquals(formatSize("5368709120"), "5.0 GB"); + assertEquals( + fileUrl({ id: "x", name: "a", mimeType: "application/vnd.google-apps.folder" }), + "https://drive.google.com/drive/folders/x", + ); + assertEquals( + fileUrl({ + id: "x", + name: "a", + mimeType: "image/png", + webViewLink: "https://drive.google.com/file/d/x/view?usp=drivesdk", + }), + "https://drive.google.com/file/d/x/view?usp=drivesdk", + ); +}); + +const file: DriveFile = { + id: "1", + name: "Roadmap", + mimeType: "application/vnd.google-apps.document", + modifiedTime: "2026-10-28T08:00:00Z", + shared: true, + starred: true, + ownedByMe: false, + sharedWithMeTime: "2026-10-20T08:00:00Z", + owners: [{ + displayName: "Grace Hopper", + emailAddress: "grace@example.com", + photoLink: "https://lh3.googleusercontent.com/a/abc=s64", + }], +}; + +Deno.test("drive: owners, avatars from Google's photo host only", () => { + assertEquals(ownerName(file), "Grace Hopper"); + assertEquals(ownerName({ ...file, owners: [{ me: true, displayName: "Me" }] }), "me"); + assertEquals(ownerName({ ...file, owners: undefined, driveId: "0A" }), "Shared drive"); + assertEquals(avatarOf(file.owners![0]), "https://lh3.googleusercontent.com/a/abc=s64"); + assertEquals(avatarOf({ displayName: "Ada", photoLink: "https://evil.example/x.png" }), "Ada"); + const lines = driveMetadata(file, new Date("2026-10-28T10:00:00Z")); + assertEquals(lines.find((l) => l.label === "Owner")?.avatar, "https://lh3.googleusercontent.com/a/abc=s64"); + assertEquals(lines.find((l) => l.label === "Labels")?.tags?.map((t) => t.text), [ + "Starred", + "Shared", + "Shared with me", + ]); + assertEquals(lines.some((l) => l.label === "Size"), true); +}); + +Deno.test("shortAgo", () => { + const now = new Date(2026, 9, 28, 12, 0); + assertEquals(shortAgo(new Date(2026, 9, 28, 11, 59, 30), now), "now"); + assertEquals(shortAgo(new Date(2026, 9, 28, 11, 15), now), "45 min"); + assertEquals(shortAgo(new Date(2026, 9, 28, 8, 0), now), "4 h"); + assertEquals(shortAgo(new Date(2026, 9, 27, 8, 0), now), "yesterday"); + assertEquals(shortAgo(new Date(2025, 0, 2), now), "2 Jan 2025"); +}); + +const NOW = new Date(2026, 9, 28, 9, 30); // Wednesday + +const task = (id: string, due?: string, extra: Partial = {}): Task => ({ + id, + title: id, + status: "needsAction", + ...(due ? { due: `${due}T00:00:00.000Z` } : {}), + ...extra, +}); + +Deno.test("tasks: due dates are calendar days", () => { + assertEquals(dueOf(task("a", "2026-10-28")), new Date(2026, 9, 28)); + assertEquals(googleDue(new Date(2026, 10, 3)), "2026-11-03T00:00:00.000Z"); + assertEquals(dueBucket(dueOf(task("a", "2026-10-27")), NOW), "Overdue"); + assertEquals(dueBucket(dueOf(task("a", "2026-10-28")), NOW), "Today"); + assertEquals(dueBucket(undefined, NOW), "No Due Date"); +}); + +Deno.test("tasks: by due date then position; subtasks under their parent", () => { + const sorted = sortTasks([ + task("b", undefined, { position: "00000000000000000002" }), + task("a", undefined, { position: "00000000000000000001" }), + task("due", "2026-10-29"), + ]); + assertEquals(sorted.map((t) => t.id), ["due", "a", "b"]); + const nested = withSubtasks([ + task("p"), + task("q"), + task("c", undefined, { parent: "p" }), + task("orphan", undefined, { parent: "gone" }), + ]); + assertEquals(nested.map((n) => `${n.task.id}:${n.depth}`), ["p:0", "c:1", "q:0", "orphan:0"]); +}); + +Deno.test("quick add: dates and list", () => { + assertEquals(parseQuickAdd("Send invoice friday #Work", NOW), { + title: "Send invoice", + due: new Date(2026, 9, 30), + list: "Work", + }); + assertEquals(parseQuickAdd("Appeler maman demain", NOW), { title: "Appeler maman", due: new Date(2026, 9, 29) }); + assertEquals(parseQuickAdd("Renew passport in 2 weeks", NOW).due, new Date(2026, 10, 11)); + assertEquals(parseQuickAdd("Buy milk", NOW), { title: "Buy milk" }); + const lists = [{ id: "1", title: "My Tasks" }, { id: "2", title: "École" }]; + assertEquals(findList(lists, "ecole")?.id, "2"); + assertEquals(findList(lists, "my")?.id, "1"); +}); diff --git a/extensions/google-workspace/live.ts b/extensions/google-workspace/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/google-workspace/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/google-workspace/tasks.ts b/extensions/google-workspace/tasks.ts new file mode 100644 index 0000000..201a6cf --- /dev/null +++ b/extensions/google-workspace/tasks.ts @@ -0,0 +1,224 @@ +// Google Tasks: loading, 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 { + type Bucket, + dueBucket, + dueOf, + findList, + googleDue, + parseQuickAdd, + shortDue, + type Task, + type TaskList, + taskMarkdown, + taskMetadata, + TASKS, + TASKS_URL, +} from "./lib.ts"; +import { notifyChanged } from "./live.ts"; + +/** 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; +} + +export async function loadLists(api: Client, signal?: AbortSignal): Promise { + const res = await api.get<{ items?: TaskList[] }>(`${TASKS}/users/@me/lists`, { maxResults: "100" }, signal); + return res.items ?? []; +} + +/** Every open task of every list, each tagged with its list. */ +export async function loadTasks(api: Client, lists: TaskList[], signal?: AbortSignal): Promise { + const per = await pool(lists, 4, async (l) => { + const out: Task[] = []; + let pageToken = ""; + do { + const res: { items?: Task[]; nextPageToken?: string } = await api.get( + `${TASKS}/lists/${encodeURIComponent(l.id)}/tasks`, + { showCompleted: "false", showHidden: "false", maxResults: "100", ...(pageToken ? { pageToken } : {}) }, + signal, + ); + out.push(...(res.items ?? []).map((t) => ({ ...t, listId: l.id }))); + pageToken = res.nextPageToken ?? ""; + } while (pageToken && out.length < 500); + return out; + }); + return per.flat(); +} + +const taskUrl = (t: Task) => `${TASKS}/lists/${encodeURIComponent(t.listId ?? "")}/tasks/${encodeURIComponent(t.id)}`; + +async function edit(api: Client, task: Task, body: unknown, message: string) { + await api.send("PATCH", taskUrl(task), body); + notifyChanged(); + await toast(message, { style: "success" }); +} + +const day = (offset: number) => { + const d = new Date(); + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset); +}; + +export const DUE_CHOICES = [ + { value: "none", title: "No due date" }, + { value: "0", title: "Today" }, + { value: "1", title: "Tomorrow" }, + { value: "next-week", title: "Next week" }, +] as const; + +function dueChoice(value: string): Date | undefined { + if (value === "none" || value === "") return undefined; + if (value === "next-week") return day((8 - new Date().getDay()) % 7 || 7); + return day(Number(value)); +} + +export function taskActions(api: Client, task: Task, lists: TaskList[]): Action[] { + return [ + action("Complete Task", () => edit(api, task, { status: "completed" }, `Completed “${task.title}”`), { + icon: Icons.squareCheck, + }), + open(task.webViewLink || TASKS_URL, "Open in Google Tasks"), + submenu( + "Set Due Date", + DUE_CHOICES.map((c) => + action(c.title, () => { + const due = dueChoice(c.value); + return edit(api, task, { due: due ? googleDue(due) : null }, due ? `Due ${shortDue(due)}` : "No due date"); + }, { icon: Icons.calendar }) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ), + ...(lists.length > 1 && !task.parent + ? [submenu( + "Move to List", + lists.filter((l) => l.id !== task.listId).map((l) => + action(l.title, async () => { + // Tasks.move takes a destination list. + await api.send( + "POST", + `${taskUrl(task)}/move?destinationTasklist=${encodeURIComponent(l.id)}`, + ); + notifyChanged(); + await toast(`Moved to ${l.title}`, { style: "success" }); + }, { icon: Icons.inbox }) + ), + { icon: Icons.inbox, section: "Task" }, + )] + : []), + action("New Task…", () => taskForm(api, lists, { list: task.listId }), { + icon: Icons.plus, + shortcut: "n", + section: "Task", + }), + copy(task.title, "Copy Title", { shortcut: "period", section: "Copy" }), + ...(task.notes?.trim() ? [copy(task.notes.trim(), "Copy Notes", { section: "Copy" })] : []), + action("Delete Task", async () => { + await api.send("DELETE", taskUrl(task)); + 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, + lists: TaskList[], + options: { parent?: Task; now?: Date; section?: string } = {}, +): Item { + const now = options.now ?? new Date(); + const list = lists.find((l) => l.id === task.listId); + const due = dueOf(task); + const bucket: Bucket = dueBucket(due, now); + const note = (task.notes ?? "").split("\n")[0].trim(); + return { + id: task.id, + title: `${options.parent ? "↳ " : ""}${task.title || "Untitled"}`, + subtitle: note.slice(0, 80) || undefined, + icon: { source: Icons.square, tint: bucket === "Overdue" ? "red" : undefined }, + section: options.section ?? bucket, + keywords: [list?.title ?? "", options.parent?.title ?? ""].filter(Boolean), + accessories: [ + ...(task.notes?.trim() ? [{ icon: Icons.note, tooltip: "Notes" }] : []), + ...((task.links ?? []).length ? [{ icon: Icons.link, tooltip: "Linked" }] : []), + ...(due + ? [{ + icon: Icons.calendar, + text: shortDue(due, now), + color: bucket === "Overdue" ? "red" as const : bucket === "Today" ? "blue" as const : undefined, + tooltip: "Due", + }] + : []), + ...(list && lists.length > 1 ? [{ icon: Icons.inbox, text: list.title }] : []), + ], + detail: { markdown: taskMarkdown(task), metadata: taskMetadata(task, list, options.parent, now) }, + actions: taskActions(api, task, lists), + }; +} + +/** Adds a task from quick-add text ("Send invoice friday #Work"). */ +export async function createTask( + api: Client, + lists: TaskList[], + values: { text: string; list?: string; due?: Date; notes?: string }, +): Promise { + const q = parseQuickAdd(values.text); + if (!q.title) throw new Error("Type the task's title"); + const named = findList(lists, q.list); + if (q.list && !named) throw new Error(`No Google Tasks list “${q.list}”`); + const target = named ?? lists.find((l) => l.id === values.list) ?? lists[0]; + if (!target) throw new Error("No Google Tasks list found"); + const due = q.due ?? values.due; + await api.send("POST", `${TASKS}/lists/${encodeURIComponent(target.id)}/tasks`, { + title: q.title, + ...(due ? { due: googleDue(due) } : {}), + ...(values.notes?.trim() ? { notes: values.notes.trim() } : {}), + }); + notifyChanged(); + return `Added “${q.title}” to ${target.title}${due ? ` · due ${shortDue(due)}` : ""}`; +} + +export function taskForm(api: Client, lists: TaskList[], options: { list?: string; title?: string } = {}) { + return form({ + title: "New Google task", + submitTitle: "Add Task", + fields: [ + field.text("title", "Task", { + required: true, + value: options.title ?? "", + placeholder: "Send invoice friday: dates are understood", + }), + field.dropdown("list", "List", lists.map((l) => ({ value: l.id, title: l.title })), { + value: options.list ?? lists[0]?.id, + }), + field.dropdown("due", "Due", DUE_CHOICES.map((c) => ({ value: c.value, title: c.title })), { value: "none" }), + field.textArea("notes", "Notes"), + ], + async submit(values) { + const summary = await createTask(api, lists, { + text: String(values.title), + list: String(values.list), + due: dueChoice(String(values.due)), + notes: String(values.notes ?? ""), + }); + await toast(summary, { style: "success" }); + return pop(); + }, + }); +} diff --git a/extensions/grafana/api.ts b/extensions/grafana/api.ts new file mode 100644 index 0000000..38d00ba --- /dev/null +++ b/extensions/grafana/api.ts @@ -0,0 +1,116 @@ +// Grafana HTTP API with a service account token, on Grafana Cloud or a self-hosted server. + +import { action, copy, Icons, type Item, open, refresh } from "@backslash/api"; +import { hostAllowed, normalizeUrl } from "./lib.ts"; + +export class SetupError extends Error {} + +export interface Account { + base: string; + token: string; +} + +export function account(preferences: Record): Account { + const base = normalizeUrl(preferences.url); + const token = String(preferences.token ?? "").trim(); + if (!base) throw new SetupError("Add your Grafana URL and a service account token"); + if (!token) throw new SetupError("Add a Grafana service account token"); + if (!hostAllowed(new URL(base).hostname)) { + throw new SetupError(`${new URL(base).hostname} is not a host this extension may reach`); + } + return { base, token }; +} + +export async function grafana( + a: Account, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${a.base}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${a.token}`, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw e; + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new SetupError(`${new URL(a.base).hostname} is not a host this extension may reach`); + } + throw new Error(`Cannot reach ${a.base}: ${e instanceof Error ? e.message : e}`); + } + if (res.status === 401) throw new SetupError("Grafana rejected the service account token"); + const text = await res.text(); + let body: any; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + if (!res.ok) { + const message = body?.message ?? body?.error ?? ""; + if (res.status === 403) { + throw new Error(`Grafana refused it${message ? `: ${message}` : ""}. The service account needs the Editor role`); + } + throw new Error(`Grafana answered ${res.status}${message ? `: ${message}` : ""}`); + } + if (body === undefined && text && !text.trimStart().startsWith("{")) { + throw new SetupError(`${a.base} does not look like Grafana`); + } + return body as T; +} + +export function setupItems(error: SetupError, base?: string): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Grafana URL and service account token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Grafana", + "", + "1. In Grafana, open **Administration → Users and access → Service accounts** and add one", + " (role **Viewer** to browse, **Editor** to create and expire silences).", + "2. **Add service account token** and copy it.", + "3. In **Settings → Extensions → Grafana → Configure**, set the URL of your stack", + " (`https://acme.grafana.net`) or server (`http://grafana.local:3000`) and paste the token.", + "", + "Reachable hosts: Grafana Cloud (`*.grafana.net`), this computer, the local network", + "(`*.local`, `*.lan`, `*.home.arpa`, `*.internal`, private IP addresses) and Tailscale (`*.ts.net`).", + ].join("\n"), + }, + actions: base + ? [open(`${base}/org/serviceaccounts`, "Open Service Accounts", { icon: Icons.key })] + : [open("https://grafana.com/docs/grafana/latest/administration/service-accounts/", "Open Grafana Docs")], + }]; +} + +export async function guarded(preferences: Record, load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof SetupError) return setupItems(e, normalizeUrl(preferences.url) || undefined); + if (e instanceof DOMException && e.name === "AbortError") throw e; + const message = e instanceof Error ? e.message : String(e); + const base = normalizeUrl(preferences.url); + return [{ + id: "error", + title: message.startsWith("Cannot reach") ? "Grafana is unreachable" : "Grafana failed", + subtitle: message, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`\`\`\n${message.replace(/```/g, "ʼʼʼ")}\n\`\`\`` }, + actions: [ + action("Retry", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...(base ? [open(base, "Open Grafana")] : []), + copy(message, "Copy Error", { section: "Copy" }), + ], + }]; + } +} diff --git a/extensions/grafana/backslash.toml b/extensions/grafana/backslash.toml new file mode 100644 index 0000000..9bbcd95 --- /dev/null +++ b/extensions/grafana/backslash.toml @@ -0,0 +1,80 @@ +[extension] +id = "grafana" +title = "Grafana" +description = "Search Grafana dashboards and follow firing alerts; silence an alert for an hour or a day, expire silences" +version = "1.0.0" +author = "Backslash" +icon = "bs-activity" +license = "MIT" + +# Grafana Cloud stacks, and self-hosted Grafana where it usually lives: this +# computer, the local network (mDNS names and private IPv4 ranges, which Deno +# accepts as CIDR) and Tailscale. Hosts must be declared ahead, so a Grafana +# on another public domain is not reachable. Same list as HOSTS in api.ts. +[permissions] +net = [ + "*.grafana.net", + "localhost", + "127.0.0.1", + "grafana", + "grafana.local", + "*.local", + "*.lan", + "*.home.arpa", + "*.internal", + "*.ts.net", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", +] + +[[commands]] +name = "dashboards" +title = "Search Grafana Dashboards" +description = "Starred first, then by folder, with their tags" +icon = "bs-chart-up" +keywords = [ + "grafana", + "dashboard", + "dashboards", + "metrics", + "monitoring", + "observability", + "graphs", + "tableau de bord", + "métriques", + "supervision", +] + +[[commands]] +name = "alerts" +title = "Grafana Alerts" +description = "Firing alerts by severity with their labels; silence them, expire silences" +icon = "bs-bell" +keywords = [ + "grafana", + "alerts", + "alerting", + "firing", + "silence", + "alertmanager", + "incident", + "on-call", + "alertes", + "silencer", + "supervision", +] + +[[preferences]] +name = "url" +title = "Grafana URL" +description = "Your stack (https://acme.grafana.net) or your server (http://grafana.local:3000)" +required = true + +[[preferences]] +name = "token" +title = "Service account token" +description = "Administration → Users and access → Service accounts → Add service account (Editor role to create silences) → Add token" +type = "secret" +required = true diff --git a/extensions/grafana/commands/alerts.ts b/extensions/grafana/commands/alerts.ts new file mode 100644 index 0000000..0b9757f --- /dev/null +++ b/extensions/grafana/commands/alerts.ts @@ -0,0 +1,228 @@ +import { + type Action, + action, + copy, + Icons, + type Item, + list, + meta, + type Metadata, + open, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { type Account, account, grafana, guarded } from "../api.ts"; +import { + type Alert, + alertName, + duration, + DURATIONS, + matcherText, + panelUrl, + parseAlerts, + parseSilences, + SEVERITIES, + severityOf, + type Silence, + silenceBody, + since, + sortAlerts, + sortSilences, + visibleLabels, +} from "../lib.ts"; + +const AM = "/api/alertmanager/grafana/api/v2"; + +const ago = (d?: Date) => !d ? "" : Date.now() - d.getTime() < 60_000 ? "just now" : `${since(d)} ago`; + +let me: Promise | undefined; +/** Who the silences are created by: the service account's login. */ +const createdBy = (a: Account) => + me ??= grafana<{ login?: string; name?: string }>(a, "/api/user") + .then((u) => u?.login || u?.name || "Backslash") + .catch(() => "Backslash"); + +async function expire(a: Account, id: string) { + await grafana(a, `${AM}/silence/${encodeURIComponent(id)}`, { method: "DELETE" }); + await toast("Silence expired", { style: "success", message: "Its alerts notify again" }); + return refresh(); +} + +const silenceUrl = (a: Account, id: string) => `${a.base}/alerting/silence/${id}/edit?alertmanager=grafana`; + +function alertItem(a: Account, alert: Alert, silenced: boolean): Item { + const sev = SEVERITIES[severityOf(alert)]; + const labels = visibleLabels(alert); + const summary = alert.annotations.summary ?? alert.annotations.message; + const description = alert.annotations.description; + const runbook = alert.annotations.runbook_url; + const panel = panelUrl(a.base, alert); + const folder = alert.labels.grafana_folder; + const metadata: Metadata[] = [ + meta.text("Severity", sev.title, { icon: Icons.alert, color: sev.color }), + meta.text("State", silenced ? "Silenced" : "Firing", { + icon: silenced ? Icons.bellOff : Icons.bell, + color: silenced ? "gray" : "red", + }), + ...(alert.startsAt + ? [meta.text("Since", `${alert.startsAt.toLocaleString("en-GB")} (${since(alert.startsAt)})`, { + icon: Icons.clock, + })] + : []), + ...(folder ? [meta.text("Folder", folder, { icon: Icons.folder })] : []), + ...(alert.receivers.length ? [meta.tags("Contact points", alert.receivers)] : []), + ...(labels.length ? [meta.separator(), ...labels.map(([k, v]) => meta.text(k, v, { icon: Icons.tag }))] : []), + ...(runbook ? [meta.link("Runbook", runbook.replace(/^https?:\/\//, ""), runbook)] : []), + ]; + const actions: Action[] = [ + ...(alert.generatorURL ? [open(alert.generatorURL, "Open Alert Rule")] : []), + ...(panel ? [open(panel, "Open Dashboard Panel", { icon: Icons.chartUp, shortcut: "d" })] : []), + ...(runbook ? [open(runbook, "Open Runbook", { icon: Icons.book, shortcut: "r" })] : []), + silenced + ? submenu( + "Expire Silence", + alert.silencedBy.map((id) => + action(`Silence ${id.slice(0, 8)}`, () => expire(a, id), { + icon: Icons.bell, + confirm: "Expire this silence? Its alerts notify again.", + }) + ), + { icon: Icons.bell, shortcut: "s", section: "Silence" }, + ) + : submenu( + "Silence", + DURATIONS.map((d) => + action(`For ${d.title}`, async () => { + const body = silenceBody(alert, d.hours, await createdBy(a)); + await grafana(a, `${AM}/silences`, { method: "POST", body }); + await toast(`Silenced ${alertName(alert)} for ${d.title.toLowerCase()}`, { style: "success" }); + return refresh(); + }, { icon: Icons.bellOff }) + ), + { icon: Icons.bellOff, shortcut: "s", section: "Silence" }, + ), + open( + `${a.base}/alerting/silence/new?alertmanager=grafana&${ + Object.entries(alert.labels).filter(([k]) => !k.startsWith("__")).map(([k, v]) => + `matcher=${encodeURIComponent(`${k}=${v}`)}` + ).join("&") + }`, + "Custom Silence in Grafana", + { icon: Icons.settings, section: "Silence" }, + ), + copy(alertName(alert), "Copy Alert Name", { shortcut: "period", section: "Copy" }), + copy(labels.map(([k, v]) => `${k}=${v}`).join("\n"), "Copy Labels", { icon: Icons.tag, section: "Copy" }), + open(`${a.base}/alerting/list`, "Open Alert Rules", { icon: Icons.bell, section: "Grafana" }), + ]; + return { + id: alert.fingerprint, + title: alertName(alert), + subtitle: summary ?? labels.slice(0, 2).map(([k, v]) => `${k}=${v}`).join(" "), + icon: { source: silenced ? Icons.bellOff : Icons.alert, tint: silenced ? "gray" : sev.color }, + section: silenced ? "Silenced" : sev.title, + keywords: [...labels.flatMap(([k, v]) => [k, v]), summary ?? "", sev.title], + accessories: [ + ...(alert.labels.instance ? [{ text: alert.labels.instance, tooltip: "Instance" }] : []), + ...(folder ? [{ icon: Icons.folder, text: folder, tooltip: "Folder" }] : []), + { text: since(alert.startsAt), tooltip: "Firing for" }, + ], + detail: { + markdown: [summary && `**${summary}**`, description].filter(Boolean).join("\n\n") || undefined, + metadata, + }, + actions, + }; +} + +function silenceItem(a: Account, s: Silence): Item { + const live = s.state !== "expired"; + const color = s.state === "active" ? "purple" as const : s.state === "pending" ? "blue" as const : "gray" as const; + const name = s.matchers.find((m) => m.name === "alertname")?.value; + const rest = s.matchers.filter((m) => m.name !== "alertname" && !m.name.startsWith("__")); + const left = s.endsAt ? s.endsAt.getTime() - Date.now() : 0; + return { + id: `silence:${s.id}`, + title: name ?? (rest.map(matcherText).join(", ") || s.id), + subtitle: s.comment || undefined, + icon: { source: Icons.bellOff, tint: color }, + section: live ? "Silences" : "Expired Silences", + keywords: [...s.matchers.map(matcherText), s.createdBy, s.state], + accessories: [ + ...(s.state !== "active" ? [{ tag: s.state === "pending" ? "Pending" : "Expired", color }] : []), + ...(s.createdBy ? [{ avatar: s.createdBy, tooltip: `By ${s.createdBy}` }] : []), + { + text: live ? `${duration(left)} left` : ago(s.endsAt), + tooltip: s.endsAt?.toLocaleString("en-GB"), + }, + ], + detail: { + markdown: s.comment || undefined, + metadata: [ + meta.text("State", s.state.charAt(0).toUpperCase() + s.state.slice(1), { icon: Icons.dot, color }), + meta.tags("Matchers", s.matchers.map((m) => ({ text: matcherText(m), color: "purple" as const }))), + ...(s.createdBy ? [meta.person("Created by", s.createdBy)] : []), + ...(s.startsAt ? [meta.text("Starts", s.startsAt.toLocaleString("en-GB"), { icon: Icons.calendar })] : []), + ...(s.endsAt ? [meta.text("Ends", s.endsAt.toLocaleString("en-GB"), { icon: Icons.clock })] : []), + ], + }, + actions: [ + open(silenceUrl(a, s.id), live ? "Edit in Grafana" : "Recreate in Grafana"), + copy(s.id, "Copy Silence ID", { section: "Copy" }), + copy(s.matchers.map(matcherText).join(", "), "Copy Matchers", { section: "Copy" }), + ...(live + ? [action("Expire Silence", () => expire(a, s.id), { + icon: Icons.bell, + destructive: true, + shortcut: "Delete", + confirm: "Expire this silence? Its alerts notify again.", + section: "Danger", + })] + : []), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter alerts by name, label, summary…", + filter: { + options: [ + { value: "firing", title: "Firing" }, + { value: "silenced", title: "Silenced" }, + { value: "silences", title: "Silences" }, + ], + default: "firing", + }, + empty: { title: "All quiet", description: "Nothing in this view of Grafana Alerting", icon: Icons.check }, + status: () => summary, + actions: [], + items: ({ preferences, filter, signal }) => + guarded(preferences, async () => { + summary = ""; + const a = account(preferences); + const view = filter ?? "firing"; + if (view === "silences") { + const silences = sortSilences(parseSilences(await grafana(a, `${AM}/silences`, { signal }))); + const live = silences.filter((s) => s.state !== "expired").length; + summary = `${live} active silence${live === 1 ? "" : "s"}`; + return silences.map((s) => silenceItem(a, s)); + } + const params = view === "silenced" + ? "active=false&silenced=true&inhibited=false" + : "active=true&silenced=false&inhibited=false"; + const alerts = sortAlerts( + parseAlerts(await grafana(a, `${AM}/alerts?${params}`, { signal })).filter((x) => + view === "silenced" ? x.silencedBy.length : x.state === "active" + ), + ); + if (view === "firing") { + const critical = alerts.filter((x) => severityOf(x) === "critical").length; + summary = `${alerts.length} firing${critical ? ` · ${critical} critical` : ""}`; + updateCommandMetadata({ subtitle: alerts.length ? `${alerts.length} firing` : null }).catch(() => {}); + } else summary = `${alerts.length} silenced`; + return alerts.map((x) => alertItem(a, x, view === "silenced")); + }), +}); diff --git a/extensions/grafana/commands/dashboards.ts b/extensions/grafana/commands/dashboards.ts new file mode 100644 index 0000000..f05b509 --- /dev/null +++ b/extensions/grafana/commands/dashboards.ts @@ -0,0 +1,56 @@ +import { copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { type Account, account, grafana, guarded } from "../api.ts"; +import { absolute, type Dashboard, parseDashboards, sortDashboards } from "../lib.ts"; + +function dashboardItem(a: Account, d: Dashboard): Item { + const url = absolute(a.base, d.url); + const folder = d.folderTitle ?? "General"; + return { + id: d.uid, + title: d.title, + subtitle: d.isStarred ? folder : undefined, + icon: { source: d.isStarred ? Icons.starFilled : Icons.chartUp, tint: d.isStarred ? "yellow" : "orange" }, + section: d.isStarred ? "Starred" : folder, + keywords: [folder, ...d.tags, d.uid], + accessories: d.tags.slice(0, 3).map((t) => ({ tag: t })), + detail: { + metadata: [ + meta.text("Folder", folder, { icon: Icons.folder }), + ...(d.tags.length ? [meta.tags("Tags", d.tags.map((t) => ({ text: t, color: "blue" as const })))] : []), + meta.text("UID", d.uid, { icon: Icons.hash }), + ...(d.isStarred ? [meta.text("Starred", "Yes", { icon: Icons.starFilled, color: "yellow" })] : []), + meta.separator(), + meta.link("Open", url.replace(/^https?:\/\//, ""), url), + ], + }, + actions: [ + open(url, "Open Dashboard"), + open(`${url}${url.includes("?") ? "&" : "?"}kiosk`, "Open in Kiosk Mode", { icon: Icons.monitor }), + ...(d.folderUid + ? [open(`${a.base}/dashboards/f/${d.folderUid}`, "Open Folder", { icon: Icons.folder, shortcut: "f" })] + : []), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(d.uid, "Copy UID", { shortcut: "period", section: "Copy" }), + copy(`[${d.title}](${url})`, "Copy as Markdown Link", { icon: Icons.link, section: "Copy" }), + ], + }; +} + +export default list({ + placeholder: "Search Grafana dashboards by title, folder or tag…", + empty: { title: "No dashboard", description: "Nothing visible to this service account", icon: Icons.chartUp }, + status: (items) => + items[0]?.id === "setup" || items[0]?.id === "error" + ? "Not connected" + : `${items.length} dashboard${items.length === 1 ? "" : "s"}${ + items.some((i) => i.section === "Starred") + ? ` · ${items.filter((i) => i.section === "Starred").length} starred` + : "" + }`, + items: ({ preferences, signal }) => + guarded(preferences, async () => { + const a = account(preferences); + const data = await grafana(a, "/api/search?type=dash-db&limit=5000", { signal }); + return sortDashboards(parseDashboards(data)).map((d) => dashboardItem(a, d)); + }), +}); diff --git a/extensions/grafana/lib.ts b/extensions/grafana/lib.ts new file mode 100644 index 0000000..18dcaad --- /dev/null +++ b/extensions/grafana/lib.ts @@ -0,0 +1,291 @@ +// Grafana responses as plain data, silences and hosts. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// The server + +/** The `net` permission, as in backslash.toml. */ +export const HOSTS = [ + "*.grafana.net", + "localhost", + "127.0.0.1", + "grafana", + "grafana.local", + "*.local", + "*.lan", + "*.home.arpa", + "*.internal", + "*.ts.net", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", +]; + +const LOCAL = /^(localhost|\d+\.\d+\.\d+\.\d+|[^.]+)$|\.(local|lan|home\.arpa|internal)$/i; + +/** `acme.grafana.net/` → `https://acme.grafana.net`; `grafana.local:3000/d/x` → `http://grafana.local:3000`. */ +export function normalizeUrl(input: unknown): string { + let text = String(input ?? "").trim(); + if (!text) return ""; + if (!/^https?:\/\//i.test(text)) { + // Local names and addresses are usually plain HTTP, anything else HTTPS. + const host = text.split(/[/:]/)[0]; + text = `${LOCAL.test(host) ? "http" : "https"}://${text}`; + } + try { + const u = new URL(text); + // A Grafana served under a sub-path (root_url) keeps it, minus the page. + const path = u.pathname.replace(/\/(d|dashboards|alerting|explore|login|a)(\/.*)?$/, "").replace(/\/+$/, ""); + return `${u.protocol}//${u.host}${path}`; + } catch { + return ""; + } +} + +const ipv4 = (h: string) => { + const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h); + return m ? m.slice(1).reduce((n, x) => n * 256 + Number(x), 0) : undefined; +}; + +/** Whether `host` matches one of HOSTS (names, `*.` suffixes, IPv4 CIDR ranges). */ +export function hostAllowed(host: string, hosts = HOSTS): boolean { + const h = host.toLowerCase(); + return hosts.some((entry) => { + if (entry.startsWith("*.")) return h.endsWith(entry.slice(1)); + const cidr = /^(.+)\/(\d+)$/.exec(entry); + if (cidr) { + const ip = ipv4(h); + const net = ipv4(cidr[1]); + if (ip === undefined || net === undefined) return false; + const bits = Number(cidr[2]); + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return ((ip & mask) >>> 0) === ((net & mask) >>> 0); + } + return h === entry; + }); +} + +// --------------------------------------------------------------------------- +// Dashboards + +export interface Dashboard { + uid: string; + title: string; + url: string; + folderTitle?: string; + folderUid?: string; + tags: string[]; + isStarred: boolean; +} + +/** `/api/search?type=dash-db`. */ +export function parseDashboards(data: unknown): Dashboard[] { + if (!Array.isArray(data)) return []; + return data.filter((d: any) => d.type === "dash-db" || d.type === undefined).map((d: any) => ({ + uid: String(d.uid), + title: String(d.title), + url: String(d.url ?? `/d/${d.uid}`), + folderTitle: d.folderTitle || undefined, + folderUid: d.folderUid || undefined, + tags: d.tags ?? [], + isStarred: !!d.isStarred, + })); +} + +/** Starred first, then folders by name (General last), dashboards by title. */ +export function sortDashboards(dashboards: Dashboard[]): Dashboard[] { + const folder = (d: Dashboard) => d.folderTitle ?? "￿"; + return [...dashboards].sort((a, b) => + Number(b.isStarred) - Number(a.isStarred) || + (a.isStarred ? 0 : folder(a).localeCompare(folder(b))) || + a.title.localeCompare(b.title) + ); +} + +/** Grafana gives URLs relative to its root; a sub-path root is already in them. */ +export function absolute(base: string, url: string): string { + if (/^https?:\/\//.test(url)) return url; + const origin = new URL(base).origin; + const path = new URL(base).pathname.replace(/\/$/, ""); + return url.startsWith(path + "/") && path ? `${origin}${url}` : `${base}${url.startsWith("/") ? "" : "/"}${url}`; +} + +// --------------------------------------------------------------------------- +// Alerts + +export interface Alert { + fingerprint: string; + labels: Record; + annotations: Record; + startsAt?: Date; + endsAt?: Date; + generatorURL?: string; + state: "active" | "suppressed" | "unprocessed" | string; + silencedBy: string[]; + inhibitedBy: string[]; + receivers: string[]; +} + +/** `/api/alertmanager/grafana/api/v2/alerts`. */ +export function parseAlerts(data: unknown): Alert[] { + if (!Array.isArray(data)) return []; + return data.map((a: any) => ({ + fingerprint: String(a.fingerprint ?? JSON.stringify(a.labels)), + labels: a.labels ?? {}, + annotations: a.annotations ?? {}, + startsAt: validDate(a.startsAt), + endsAt: validDate(a.endsAt), + generatorURL: a.generatorURL || undefined, + state: String(a.status?.state ?? "active"), + silencedBy: a.status?.silencedBy ?? [], + inhibitedBy: a.status?.inhibitedBy ?? [], + receivers: (a.receivers ?? []).map((r: any) => String(r.name ?? r)), + })); +} + +/** Go's zero time (`0001-01-01T00:00:00Z`) means "none". */ +function validDate(text: unknown): Date | undefined { + if (typeof text !== "string" || text.startsWith("0001-")) return undefined; + const d = new Date(text); + return isNaN(d.getTime()) ? undefined : d; +} + +export type Severity = "critical" | "high" | "warning" | "info" | "none"; + +export const SEVERITIES: Record = { + critical: { title: "Critical", color: "red", rank: 0 }, + high: { title: "High", color: "orange", rank: 1 }, + warning: { title: "Warning", color: "yellow", rank: 2 }, + info: { title: "Info", color: "blue", rank: 3 }, + none: { title: "No severity", color: "gray", rank: 4 }, +}; + +/** The `severity` (or `priority`) label, mapped onto five levels. */ +export function severityOf(a: Alert): Severity { + const s = (a.labels.severity ?? a.labels.priority ?? a.labels.level ?? "").toLowerCase(); + if (/^(critical|crit|emergency|fatal|page|p1|sev1|disaster)$/.test(s)) return "critical"; + if (/^(high|error|err|major|p2|sev2)$/.test(s)) return "high"; + if (/^(warning|warn|medium|minor|p3|sev3|average)$/.test(s)) return "warning"; + if (/^(info|low|informational|notice|p4|p5|sev4|none)$/.test(s)) return "info"; + return "none"; +} + +export function sortAlerts(alerts: Alert[]): Alert[] { + return [...alerts].sort((a, b) => + SEVERITIES[severityOf(a)].rank - SEVERITIES[severityOf(b)].rank || + (b.startsAt?.getTime() ?? 0) - (a.startsAt?.getTime() ?? 0) + ); +} + +export const alertName = (a: Alert) => a.labels.alertname ?? a.labels.__alert_rule_title__ ?? "Alert"; + +/** Labels worth showing: not Grafana's internal ones, not the name. */ +export const visibleLabels = (a: Alert) => + Object.entries(a.labels).filter(([k]) => !k.startsWith("__") && k !== "alertname").sort(([x], [y]) => + x.localeCompare(y) + ); + +/** The dashboard panel an alert rule is linked to. */ +export function panelUrl(base: string, a: Alert): string | undefined { + const uid = a.annotations.__dashboardUid__; + if (!uid) return undefined; + const panel = a.annotations.__panelId__; + return `${base}/d/${uid}${panel ? `?viewPanel=${panel}` : ""}`; +} + +// --------------------------------------------------------------------------- +// Silences + +export interface Matcher { + name: string; + value: string; + isRegex: boolean; + isEqual?: boolean; +} + +export interface Silence { + id: string; + state: "active" | "pending" | "expired" | string; + matchers: Matcher[]; + startsAt?: Date; + endsAt?: Date; + createdBy: string; + comment: string; +} + +export function parseSilences(data: unknown): Silence[] { + if (!Array.isArray(data)) return []; + return data.map((s: any) => ({ + id: String(s.id), + state: String(s.status?.state ?? "active"), + matchers: (s.matchers ?? []).map((m: any) => ({ + name: String(m.name), + value: String(m.value), + isRegex: !!m.isRegex, + isEqual: m.isEqual ?? true, + })), + startsAt: validDate(s.startsAt), + endsAt: validDate(s.endsAt), + createdBy: String(s.createdBy ?? ""), + comment: String(s.comment ?? ""), + })); +} + +/** Active, then pending, then the 20 most recently expired. */ +export function sortSilences(silences: Silence[]): Silence[] { + const rank = (s: Silence) => s.state === "active" ? 0 : s.state === "pending" ? 1 : 2; + const sorted = [...silences].sort((a, b) => + rank(a) - rank(b) || + (rank(a) === 2 + ? (b.endsAt?.getTime() ?? 0) - (a.endsAt?.getTime() ?? 0) + : (a.endsAt?.getTime() ?? 0) - (b.endsAt?.getTime() ?? 0)) + ); + const live = sorted.filter((s) => rank(s) < 2); + return [...live, ...sorted.filter((s) => rank(s) === 2).slice(0, 20)]; +} + +/** `alertname=HighCPU, instance=~web-.*` */ +export const matcherText = (m: Matcher) => + `${m.name}${m.isEqual === false ? "!" : ""}${m.isRegex ? "=~" : "="}${m.value}`; + +/** What a silence created from an alert matches: every label, exactly. */ +export function matchersFor(a: Alert): Matcher[] { + return Object.entries(a.labels) + .filter(([k]) => !k.startsWith("__") || k === "__alert_rule_uid__") + .map(([name, value]) => ({ name, value, isRegex: false, isEqual: true })); +} + +export const DURATIONS = [ + { title: "1 Hour", hours: 1 }, + { title: "4 Hours", hours: 4 }, + { title: "1 Day", hours: 24 }, +]; + +export function silenceBody(a: Alert, hours: number, createdBy: string, now = new Date()) { + return { + matchers: matchersFor(a), + startsAt: now.toISOString(), + endsAt: new Date(now.getTime() + hours * 3600_000).toISOString(), + createdBy, + comment: `Silenced from Backslash for ${hours === 24 ? "1 day" : `${hours} h`}: ${alertName(a)}`, + }; +} + +// --------------------------------------------------------------------------- +// Time + +/** "45 s", "12 min", "3 h 20 min", "2 d 4 h". */ +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 < 24) return m % 60 ? `${h} h ${m % 60} min` : `${h} h`; + const d = Math.floor(h / 24); + return h % 24 ? `${d} d ${h % 24} h` : `${d} d`; +} + +export const since = (d: Date | undefined, now = Date.now()) => d ? duration(now - d.getTime()) : ""; diff --git a/extensions/grafana/lib_test.ts b/extensions/grafana/lib_test.ts new file mode 100644 index 0000000..674693f --- /dev/null +++ b/extensions/grafana/lib_test.ts @@ -0,0 +1,199 @@ +import { assertEquals } from "@std/assert"; +import { parse } from "@std/toml"; +import { + absolute, + alertName, + duration, + hostAllowed, + HOSTS, + matchersFor, + matcherText, + normalizeUrl, + panelUrl, + parseAlerts, + parseDashboards, + parseSilences, + severityOf, + silenceBody, + sortAlerts, + sortDashboards, + sortSilences, + visibleLabels, +} from "./lib.ts"; + +Deno.test("URLs", () => { + assertEquals(normalizeUrl("acme.grafana.net/"), "https://acme.grafana.net"); + assertEquals(normalizeUrl("https://acme.grafana.net/d/abc/node?orgId=1"), "https://acme.grafana.net"); + assertEquals(normalizeUrl("grafana.local:3000"), "http://grafana.local:3000"); + assertEquals(normalizeUrl("192.168.1.20:3000/alerting/list"), "http://192.168.1.20:3000"); + assertEquals(normalizeUrl("grafana:3000"), "http://grafana:3000"); + assertEquals(normalizeUrl("https://nas.tail1234.ts.net/grafana/d/x"), "https://nas.tail1234.ts.net/grafana"); + assertEquals(normalizeUrl(""), ""); + assertEquals(absolute("https://acme.grafana.net", "/d/abc/node"), "https://acme.grafana.net/d/abc/node"); + assertEquals(absolute("https://h.ts.net/grafana", "/grafana/d/abc/node"), "https://h.ts.net/grafana/d/abc/node"); + assertEquals(absolute("https://h.ts.net/grafana", "/d/abc"), "https://h.ts.net/grafana/d/abc"); +}); + +Deno.test("hosts match the manifest", async () => { + const manifest = parse(await Deno.readTextFile(new URL("./backslash.toml", import.meta.url))) as any; + assertEquals(manifest.permissions.net, HOSTS); + assertEquals(hostAllowed("acme.grafana.net"), true); + assertEquals(hostAllowed("grafana.local"), true); + assertEquals(hostAllowed("192.168.1.20"), true); + assertEquals(hostAllowed("172.20.0.4"), true); + assertEquals(hostAllowed("172.32.0.4"), false); + assertEquals(hostAllowed("100.101.102.103"), true); + assertEquals(hostAllowed("grafana.acme.com"), false); + assertEquals(hostAllowed("8.8.8.8"), false); +}); + +Deno.test("dashboards: starred first, then folders, General last", () => { + const dashboards = parseDashboards([ + { + uid: "a", + title: "Node Exporter", + url: "/d/a/node", + type: "dash-db", + tags: ["linux"], + folderTitle: "Infra", + folderUid: "f1", + isStarred: false, + }, + { uid: "b", title: "Home", url: "/d/b/home", type: "dash-db", tags: [], isStarred: false }, + { + uid: "c", + title: "API latency", + url: "/d/c/api", + type: "dash-db", + tags: ["slo"], + folderTitle: "Shop", + isStarred: true, + }, + { uid: "d", title: "Postgres", url: "/d/d/pg", type: "dash-db", tags: [], folderTitle: "Infra", isStarred: false }, + { uid: "f1", title: "Infra", url: "/dashboards/f/f1", type: "dash-folder" }, + ]); + assertEquals(sortDashboards(dashboards).map((d) => d.title), ["API latency", "Node Exporter", "Postgres", "Home"]); + assertEquals(parseDashboards({ message: "x" }), []); +}); + +const ALERTS = [ + { + annotations: { + summary: "CPU above 90% on web-1", + description: "The 5 min average is 97%.", + runbook_url: "https://wiki.acme.dev/runbooks/cpu", + __dashboardUid__: "node", + __panelId__: "4", + }, + endsAt: "2026-10-28T12:04:00Z", + fingerprint: "f1", + receivers: [{ name: "oncall-pagerduty" }], + startsAt: "2026-10-28T11:15:00Z", + status: { inhibitedBy: [], silencedBy: [], state: "active" }, + updatedAt: "2026-10-28T12:00:00Z", + generatorURL: "https://acme.grafana.net/alerting/grafana/rule1/view", + labels: { + __alert_rule_uid__: "rule1", + alertname: "HighCPU", + grafana_folder: "Infra", + instance: "web-1", + severity: "critical", + }, + }, + { + annotations: {}, + fingerprint: "f2", + receivers: [{ name: "slack" }], + startsAt: "2026-10-28T11:50:00Z", + endsAt: "0001-01-01T00:00:00Z", + status: { inhibitedBy: [], silencedBy: ["s1"], state: "suppressed" }, + labels: { alertname: "DiskFilling", severity: "warning", mountpoint: "/var" }, + }, + { + annotations: { summary: "Errors" }, + fingerprint: "f3", + startsAt: "2026-10-28T10:00:00Z", + status: { state: "active" }, + labels: { alertname: "5xx", priority: "P2" }, + }, +]; + +Deno.test("alerts", () => { + const alerts = parseAlerts(ALERTS); + assertEquals(alerts[1].endsAt, undefined); + assertEquals(alerts[1].silencedBy, ["s1"]); + assertEquals(alerts[0].receivers, ["oncall-pagerduty"]); + assertEquals(alerts.map(severityOf), ["critical", "warning", "high"]); + assertEquals(sortAlerts(alerts).map(alertName), ["HighCPU", "5xx", "DiskFilling"]); + assertEquals(visibleLabels(alerts[0]), [["grafana_folder", "Infra"], ["instance", "web-1"], [ + "severity", + "critical", + ]]); + assertEquals(panelUrl("https://acme.grafana.net", alerts[0]), "https://acme.grafana.net/d/node?viewPanel=4"); + assertEquals(panelUrl("https://acme.grafana.net", alerts[1]), undefined); + assertEquals(parseAlerts(null), []); +}); + +Deno.test("silence bodies", () => { + const [alert] = parseAlerts(ALERTS); + const body = silenceBody(alert, 4, "sa-backslash", new Date("2026-10-28T12:00:00Z")); + assertEquals(body.startsAt, "2026-10-28T12:00:00.000Z"); + assertEquals(body.endsAt, "2026-10-28T16:00:00.000Z"); + assertEquals(body.createdBy, "sa-backslash"); + assertEquals(body.comment, "Silenced from Backslash for 4 h: HighCPU"); + assertEquals(body.matchers.map((m) => m.name), [ + "__alert_rule_uid__", + "alertname", + "grafana_folder", + "instance", + "severity", + ]); + assertEquals(matchersFor(alert)[1], { name: "alertname", value: "HighCPU", isRegex: false, isEqual: true }); + assertEquals(silenceBody(alert, 24, "x").comment.includes("1 day"), true); +}); + +Deno.test("silences", () => { + const silences = parseSilences([ + { + id: "old", + status: { state: "expired" }, + matchers: [{ name: "alertname", value: "X", isRegex: false, isEqual: true }], + startsAt: "2026-10-01T00:00:00Z", + endsAt: "2026-10-02T00:00:00Z", + createdBy: "ada", + comment: "maintenance", + }, + { + id: "s1", + status: { state: "active" }, + matchers: [{ name: "alertname", value: "DiskFilling", isRegex: false, isEqual: true }, { + name: "instance", + value: "web-.*", + isRegex: true, + isEqual: false, + }], + startsAt: "2026-10-28T11:00:00Z", + endsAt: "2026-10-28T15:00:00Z", + createdBy: "bob", + comment: "known", + }, + { + id: "p1", + status: { state: "pending" }, + matchers: [], + startsAt: "2026-10-29T00:00:00Z", + endsAt: "2026-10-29T02:00:00Z", + }, + ]); + assertEquals(sortSilences(silences).map((s) => s.id), ["s1", "p1", "old"]); + assertEquals(matcherText(silences[1].matchers[1]), "instance!=~web-.*"); + assertEquals(matcherText(silences[1].matchers[0]), "alertname=DiskFilling"); +}); + +Deno.test("durations", () => { + assertEquals(duration(45_000), "45 s"); + assertEquals(duration(12 * 60_000), "12 min"); + assertEquals(duration(200 * 60_000), "3 h 20 min"); + assertEquals(duration(52 * 3600_000), "2 d 4 h"); + assertEquals(duration(48 * 3600_000), "2 d"); +}); diff --git a/extensions/helm/backslash.toml b/extensions/helm/backslash.toml new file mode 100644 index 0000000..48a49ec --- /dev/null +++ b/extensions/helm/backslash.toml @@ -0,0 +1,55 @@ +[extension] +id = "helm" +title = "Helm" +description = "Helm releases across namespaces with their chart, status and revision; values, history, rollback and uninstall, in the current kubectl context" +version = "1.0.0" +author = "Backslash" +icon = "bs-package" +license = "MIT" + +# helm does the work; kubectl shows and switches the kubeconfig context; the +# distribution is read for install hints; then the terminals helm status opens in. +[permissions] +read = ["/etc/os-release"] +run = [ + "helm", + "kubectl", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "releases" +title = "Helm Releases" +description = "Releases in every namespace, failed and pending first: values, history, rollback, uninstall" +icon = "bs-package" +keywords = [ + "helm", + "chart", + "release", + "kubernetes", + "k8s", + "rollback", + "values", + "uninstall", + "deploy", + "versions", + "déploiement", + "revenir", + "valeurs", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For helm status. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/helm/commands/releases.ts b/extensions/helm/commands/releases.ts new file mode 100644 index 0000000..b544934 --- /dev/null +++ b/extensions/helm/commands/releases.ts @@ -0,0 +1,276 @@ +import { + type Action, + action, + clipboard, + close, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + age, + formatDate, + HEALTH_COLORS, + historyCommand, + inContext, + manifestCommand, + parseHistory, + parseReleases, + type Release, + type Revision, + rollbackCommand, + rollbackTargets, + sortReleases, + statusCommand, + statusLabel, + uninstallCommand, + valuesCommand, +} from "../lib.ts"; +import { contextActions, errorItem, helm, helmMissing, isMissing, kubeconfig, type Prefs } from "../helm.ts"; +import { holdOpen, runInTerminal, shellLine } from "../terminal.ts"; + +const SECTIONS = { failed: "Failed", pending: "Pending", ok: "Deployed", other: "Other" }; + +const fence = (text: string) => `\`\`\`\n${text.replace(/```/g, "ʼʼʼ") || "(empty)"}\n\`\`\``; + +function releaseMetadata(r: Release, context?: string): Metadata[] { + return [ + meta.text("Status", statusLabel(r.status), { icon: Icons.dot, color: HEALTH_COLORS[r.health] }), + meta.text("Chart", r.chartName, { icon: Icons.package }), + ...(r.chartVersion ? [meta.text("Chart version", r.chartVersion, { icon: Icons.tag })] : []), + ...(r.appVersion ? [meta.text("App version", r.appVersion, { icon: Icons.box })] : []), + meta.text("Revision", r.revision, { icon: Icons.history }), + ...(r.updated ? [meta.text("Updated", formatDate(r.updated), { icon: Icons.calendar })] : []), + meta.separator(), + meta.text("Namespace", r.namespace, { icon: Icons.layers }), + ...(context ? [meta.text("Context", context, { icon: Icons.server })] : []), + ]; +} + +async function valuesView(r: Release, context: string | undefined, all = false): Promise { + const text = await helm(inContext(valuesCommand(r, all), context)); + const body = text.replace(/^(USER-SUPPLIED|COMPUTED) VALUES:\n?/, "").trim(); + return detail({ + title: `${r.name} · ${all ? "all values" : "values"}`, + markdown: body && body !== "null" ? fence(body) : "_No user-supplied values: the chart's defaults apply._", + metadata: releaseMetadata(r, context), + status: all ? "Computed values (chart defaults included)" : "User-supplied values", + actions: [ + copy(body, "Copy Values", { shortcut: "c", concealed: true }), + all + ? action("Show User-Supplied Values", () => valuesView(r, context), { icon: Icons.text }) + : action("Show All Values", () => valuesView(r, context, true), { icon: Icons.braces, shortcut: "a" }), + ], + }); +} + +async function manifestView(r: Release, context: string | undefined): Promise { + const text = await helm(inContext(manifestCommand(r), context)); + const kinds = [...text.matchAll(/^kind:\s*(\S+)/gm)].map((m) => m[1]); + return detail({ + title: `${r.name} · manifest`, + markdown: fence(text), + metadata: releaseMetadata(r, context), + status: `${kinds.length} Kubernetes object${kinds.length === 1 ? "" : "s"}`, + actions: [copy(text, "Copy Manifest", { shortcut: "c", concealed: true })], + }); +} + +async function rollback(r: Release, rev: Revision, context: string | undefined) { + await helm(inContext(rollbackCommand(r, rev.revision), context), undefined, 120); + await toast(`Rolled back ${r.name} to revision ${rev.revision}`, { style: "success", message: rev.chart }); + return refresh(); +} + +const rollbackAction = (r: Release, rev: Revision, context: string | undefined, title?: string) => + action( + title ?? `Revision ${rev.revision} · ${rev.chart} · ${age(rev.updated)} ago`, + () => rollback(r, rev, context), + { + icon: Icons.history, + confirm: `Roll back ${r.name} to revision ${rev.revision} (${rev.chart})?`, + destructive: true, + }, + ); + +function historyView(r: Release, context: string | undefined): ListSpec { + return list({ + title: `${r.name} · history`, + placeholder: `Filter revisions of ${r.name}…`, + status: (items) => `${items.length} revision${items.length === 1 ? "" : "s"} · current ${r.revision}`, + async items({ signal }) { + const history = parseHistory(await helm(inContext(historyCommand(r), context), signal)); + return history.map((rev): Item => { + const current = rev.revision === r.revision; + const color = rev.status === "deployed" + ? "green" + : rev.status === "failed" + ? "red" + : rev.status.startsWith("pending") + ? "yellow" + : "gray"; + return { + id: String(rev.revision), + title: `Revision ${rev.revision}`, + subtitle: rev.description, + icon: { source: current ? Icons.check : Icons.history, tint: color }, + keywords: [rev.chart, rev.status, rev.appVersion ?? ""], + accessories: [ + { tag: statusLabel(rev.status), color }, + { text: rev.chart, icon: Icons.package }, + { text: age(rev.updated), tooltip: formatDate(rev.updated) }, + ], + detail: { + markdown: rev.description ? `> ${rev.description}` : undefined, + metadata: [ + meta.text("Status", statusLabel(rev.status), { icon: Icons.dot, color }), + meta.text("Chart", rev.chart, { icon: Icons.package }), + ...(rev.appVersion ? [meta.text("App version", rev.appVersion, { icon: Icons.box })] : []), + ...(rev.updated ? [meta.text("Updated", formatDate(rev.updated), { icon: Icons.calendar })] : []), + ], + }, + actions: [ + ...(current ? [] : [rollbackAction(r, rev, context, "Roll Back to This Revision")]), + action("Show Values of This Revision", async () => { + const text = await helm( + inContext([...valuesCommand(r), "--revision", String(rev.revision)], context), + ); + const body = text.replace(/^(USER-SUPPLIED|COMPUTED) VALUES:\n?/, "").trim(); + return detail({ + title: `${r.name} · revision ${rev.revision} values`, + markdown: body && body !== "null" ? fence(body) : "_No user-supplied values._", + actions: [copy(body, "Copy Values", { concealed: true })], + }); + }, { icon: Icons.text, shortcut: "v" }), + copy( + shellLine(inContext(rollbackCommand(r, rev.revision), context)), + "Copy Rollback Command", + { icon: Icons.terminal, section: "Copy" }, + ), + ], + }; + }); + }, + }); +} + +function releaseItem(r: Release, context: string | undefined, prefs: Prefs): Item { + const color = HEALTH_COLORS[r.health]; + const actions: Action[] = [ + action("Show Values", () => valuesView(r, context), { icon: Icons.text }), + action("Show History", () => historyView(r, context), { icon: Icons.history, shortcut: "h" }), + action("Show Manifest", () => manifestView(r, context), { icon: Icons.file, shortcut: "m" }), + submenu("Roll Back To", async () => { + const history = parseHistory(await helm(inContext(historyCommand(r), context))); + const targets = rollbackTargets(history, r.revision); + if (!targets.length) throw new Error(`${r.name} has a single revision`); + return targets.map((rev) => rollbackAction(r, rev, context)); + }, { icon: Icons.history, shortcut: "r", section: "Release" }), + action("Status in Terminal", () => { + const term = runInTerminal(holdOpen(inContext(statusCommand(r), context)), prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: Icons.terminal, shortcut: "t", section: "Release" }), + action("Copy Values", async () => { + const text = await helm(inContext(valuesCommand(r), context)); + await clipboard.write(text.replace(/^(USER-SUPPLIED|COMPUTED) VALUES:\n?/, "").trim(), { concealed: true }); + return close("Values copied"); + }, { icon: Icons.copy, shortcut: "c", section: "Copy" }), + copy(r.name, "Copy Release Name", { shortcut: "period", section: "Copy" }), + copy(shellLine(inContext(statusCommand(r), context)), "Copy Status Command", { + icon: Icons.terminal, + section: "Copy", + }), + ...contextActions(), + action("Uninstall Release", async () => { + await helm(inContext(uninstallCommand(r), context), undefined, 120); + await toast(`Uninstalled ${r.name}`, { style: "success", message: r.namespace }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + shortcut: "Delete", + confirm: `Uninstall ${r.name} from ${r.namespace}${context ? ` (${context})` : ""}? Its resources are deleted.`, + section: "Danger", + }), + ]; + return { + id: `${r.namespace}/${r.name}`, + title: r.name, + subtitle: r.chartName, + icon: { source: Icons.package, tint: color }, + section: SECTIONS[r.health], + keywords: [r.namespace, r.chart, r.status, r.appVersion ?? ""], + accessories: [ + ...(r.health !== "ok" ? [{ tag: statusLabel(r.status), color }] : []), + { icon: Icons.layers, text: r.namespace, tooltip: "Namespace" }, + ...(r.chartVersion ? [{ text: r.chartVersion, tooltip: "Chart version" }] : []), + { text: `r${r.revision}`, tooltip: "Revision" }, + { text: age(r.updated), tooltip: r.updated ? `Updated ${formatDate(r.updated)}` : undefined }, + ], + detail: { + stats: [ + { label: "revision", value: r.revision }, + { label: "status", value: statusLabel(r.status), color }, + { label: "updated", value: age(r.updated) || "—" }, + ], + metadata: releaseMetadata(r, context), + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter releases by name, chart, namespace, status…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "problems", title: "Failed & Pending" }, + ], + default: "all", + }, + empty: { title: "No Helm release", description: "Nothing installed with helm in this cluster", icon: Icons.package }, + status: () => summary, + actions: contextActions(), + async items({ preferences, filter, signal }) { + summary = ""; + const prefs = preferences as Prefs; + let context: string | undefined; + try { + context = (await kubeconfig(signal))?.current; + } catch { + context = undefined; + } + let releases: Release[]; + try { + releases = parseReleases(await helm(inContext(["helm", "list", "-A", "-a", "-o", "json"], context), signal)); + } catch (err) { + if (isMissing(err)) return [await helmMissing()]; + return [errorItem(err, context)]; + } + const failed = releases.filter((r) => r.health === "failed").length; + const pending = releases.filter((r) => r.health === "pending").length; + const problems = [failed && `${failed} failed`, pending && `${pending} pending`].filter(Boolean).join(" · "); + summary = [context, `${releases.length} release${releases.length === 1 ? "" : "s"}`, problems] + .filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [context, problems || `${releases.length} release${releases.length === 1 ? "" : "s"}`] + .filter(Boolean).join(" · "), + }).catch(() => {}); + const shown = filter === "problems" + ? releases.filter((r) => r.health === "failed" || r.health === "pending") + : releases; + return sortReleases(shown).map((r) => releaseItem(r, context, prefs)); + }, +}); diff --git a/extensions/helm/helm.ts b/extensions/helm/helm.ts new file mode 100644 index 0000000..4eb5a0c --- /dev/null +++ b/extensions/helm/helm.ts @@ -0,0 +1,103 @@ +// Calling helm and kubectl, and the items the views share. + +import { type Action, action, copy, exec, Icons, type Item, open, refresh, submenu, toast } from "@backslash/api"; +import { classifyError, installCommand, type Kubeconfig, osFamily, parseKubeconfig } from "./lib.ts"; + +export interface Prefs { + terminal?: string; +} + +export class Timeout extends Error {} + +/** helm with a deadline, so an unreachable cluster doesn't hang the launcher. */ +export async function helm(argv: string[], signal?: AbortSignal, seconds = 20): Promise { + const deadline = AbortSignal.timeout(seconds * 1000); + try { + return await exec(argv[0], argv.slice(1), { signal: signal ? AbortSignal.any([signal, deadline]) : deadline }); + } catch (err) { + if (deadline.aborted && !signal?.aborted) throw new Timeout(`helm did not answer within ${seconds} seconds`); + throw err; + } +} + +export const isMissing = (err: unknown) => err instanceof Deno.errors.NotFound; + +/** The kubeconfig, or undefined when kubectl is not installed (helm still works on its own). */ +export async function kubeconfig(signal?: AbortSignal): Promise { + try { + return parseKubeconfig(await exec("kubectl", ["config", "view", "-o", "json"], { signal })); + } catch (err) { + if (isMissing(err)) return undefined; + throw err; + } +} + +export async function useContext(name: string) { + await exec("kubectl", ["config", "use-context", name]); + await toast(`Switched to ${name}`, { style: "success" }); +} + +/** "Switch Context ›", loaded when opened. */ +export function contextActions(): Action[] { + return [ + submenu("Switch Context", async () => { + const config = await kubeconfig(); + if (!config) throw new Error("kubectl is needed to switch contexts"); + if (!config.contexts.length) throw new Error("No context in your kubeconfig"); + return config.contexts.map((c) => + action(c.name, async () => { + await useContext(c.name); + return refresh(); + }, { icon: c.name === config.current ? Icons.check : Icons.server }) + ); + }, { icon: Icons.server, shortcut: "k", section: "Cluster" }), + ]; +} + +let hint: Promise | undefined; +const installHint = () => + hint ??= Deno.readTextFile("/etc/os-release").catch(() => "").then((t) => installCommand(osFamily(t))); + +export async function helmMissing(): Promise { + const cmd = await installHint(); + return { + id: "missing", + title: "Helm is not installed", + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This extension runs **helm** with your kubeconfig (`~/.kube/config` or `$KUBECONFIG`).", + "", + "```sh", + cmd, + "```", + "", + "**kubectl** is optional: with it, the list shows the current context and switches it.", + ].join("\n"), + }, + actions: [ + copy(cmd, "Copy Install Command", { icon: Icons.terminal }), + open("https://helm.sh/docs/intro/install/", "Open Install Guide"), + ], + }; +} + +/** An error as an item, with the way out. */ +export function errorItem(err: unknown, context?: string): Item { + const message = err instanceof Error ? err.message : String(err); + const clean = message.replace(/^(helm|kubectl) exited with \d+: /, ""); + const e = classifyError(clean); + return { + id: "error", + title: err instanceof Timeout ? "Cannot reach the cluster" : e.title, + subtitle: context, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`\`\`\n${clean.replace(/```/g, "ʼʼʼ")}\n\`\`\`` }, + actions: [ + action("Retry", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...contextActions(), + copy(clean, "Copy Error", { section: "Copy" }), + ], + }; +} diff --git a/extensions/helm/lib.ts b/extensions/helm/lib.ts new file mode 100644 index 0000000..6032dd6 --- /dev/null +++ b/extensions/helm/lib.ts @@ -0,0 +1,238 @@ +// Parsing helm and kubectl JSON. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Releases + +export type Health = "failed" | "pending" | "ok" | "other"; + +export interface Release { + name: string; + namespace: string; + revision: number; + updated?: Date; + status: string; + health: Health; + /** `ingress-nginx-4.11.2` as helm prints it. */ + chart: string; + chartName: string; + chartVersion?: string; + appVersion?: string; +} + +/** `ingress-nginx-4.11.2` → name and version; `my-chart-1.0.0-rc.1` too. */ +export function splitChart(chart: string): { name: string; version?: string } { + const m = /^(.+)-(v?\d+\.\d+\.\d+[\w.+-]*)$/.exec(chart); + return m ? { name: m[1], version: m[2] } : { name: chart }; +} + +/** helm's `2026-10-20 09:12:33.123456789 +0000 UTC` (list) or RFC 3339 (history). */ +export function parseHelmDate(text: unknown): Date | undefined { + if (typeof text !== "string" || !text) return undefined; + const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}:?\d{2})?/.exec(text); + if (!m) return undefined; + const ms = (m[3] ?? "0").padEnd(3, "0").slice(0, 3); + let zone = m[4] ?? "Z"; + if (/^[+-]\d{4}$/.test(zone)) zone = `${zone.slice(0, 3)}:${zone.slice(3)}`; + const d = new Date(`${m[1]}T${m[2]}.${ms}${zone}`); + return isNaN(d.getTime()) ? undefined : d; +} + +export function healthOf(status: string): Health { + if (status === "failed") return "failed"; + if (status.startsWith("pending") || status === "uninstalling") return "pending"; + if (status === "deployed") return "ok"; + return "other"; +} + +export const HEALTH_COLORS: Record = { failed: "red", pending: "yellow", ok: "green", other: "gray" }; + +/** `helm list -A -a -o json`. */ +export function parseReleases(json: string): Release[] { + const data = JSON.parse(json || "[]") ?? []; + return (data as any[]).map((r) => { + const chart = String(r.chart ?? ""); + const { name, version } = splitChart(chart); + const status = String(r.status ?? "unknown"); + return { + name: String(r.name), + namespace: String(r.namespace ?? "default"), + revision: Number(r.revision) || 0, + updated: parseHelmDate(r.updated), + status, + health: healthOf(status), + chart, + chartName: name, + chartVersion: version, + appVersion: r.app_version || undefined, + }; + }); +} + +const RANK: Record = { failed: 0, pending: 1, ok: 2, other: 3 }; + +/** Failed, then pending, then the rest; newest change first within each. */ +export function sortReleases(releases: Release[]): Release[] { + return [...releases].sort((a, b) => + RANK[a.health] - RANK[b.health] || (b.updated?.getTime() ?? 0) - (a.updated?.getTime() ?? 0) || + a.name.localeCompare(b.name) + ); +} + +/** `failed` → `Failed`, `pending-upgrade` → `Pending upgrade`. */ +export const statusLabel = (status: string) => { + const s = status.replace(/-/g, " "); + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +// --------------------------------------------------------------------------- +// History + +export interface Revision { + revision: number; + updated?: Date; + status: string; + chart: string; + appVersion?: string; + description: string; +} + +/** `helm history -o json`, newest first. */ +export function parseHistory(json: string): Revision[] { + const data = JSON.parse(json || "[]") ?? []; + return (data as any[]) + .map((r) => ({ + revision: Number(r.revision) || 0, + updated: parseHelmDate(r.updated), + status: String(r.status ?? "unknown"), + chart: String(r.chart ?? ""), + appVersion: r.app_version || undefined, + description: String(r.description ?? ""), + })) + .sort((a, b) => b.revision - a.revision); +} + +/** The revisions one can roll back to: every one but the current. */ +export const rollbackTargets = (history: Revision[], current: number) => + history.filter((r) => r.revision !== current && r.status !== "pending-install"); + +// --------------------------------------------------------------------------- +// Command lines + +const ns = (r: { namespace: string }) => ["--namespace", r.namespace]; + +export const valuesCommand = (r: Release, all = false) => [ + "helm", + "get", + "values", + r.name, + ...ns(r), + "-o", + "yaml", + ...(all ? ["--all"] : []), +]; +export const manifestCommand = (r: Release) => ["helm", "get", "manifest", r.name, ...ns(r)]; +export const historyCommand = (r: Release) => ["helm", "history", r.name, ...ns(r), "-o", "json", "--max", "30"]; +export const statusCommand = (r: Release) => ["helm", "status", r.name, ...ns(r)]; +export const rollbackCommand = (r: Release, revision: number) => [ + "helm", + "rollback", + r.name, + String(revision), + ...ns(r), +]; +export const uninstallCommand = (r: Release) => ["helm", "uninstall", r.name, ...ns(r)]; + +/** Adds `--kube-context` so a command acts on the context the list was loaded from. */ +export const inContext = (argv: string[], context?: string) => + context ? [argv[0], "--kube-context", context, ...argv.slice(1)] : argv; + +// --------------------------------------------------------------------------- +// Kubeconfig + +export interface KubeContext { + name: string; + cluster: string; + namespace?: string; + server?: string; +} + +export interface Kubeconfig { + current?: string; + contexts: KubeContext[]; +} + +/** `kubectl config view -o json`. */ +export function parseKubeconfig(json: string): Kubeconfig { + const c = JSON.parse(json || "{}") ?? {}; + const servers = new Map((c.clusters ?? []).map((cl: any) => [cl.name, cl.cluster?.server])); + return { + current: c["current-context"] || undefined, + contexts: (c.contexts ?? []).map((ctx: any) => ({ + name: ctx.name, + cluster: ctx.context?.cluster ?? "", + namespace: ctx.context?.namespace || undefined, + server: servers.get(ctx.context?.cluster), + })), + }; +} + +// --------------------------------------------------------------------------- +// Formatting and errors + +/** "45 s", "12 min", "3 h", "4 d", "5 mo", "2 y". */ +export function age(date: Date | undefined, now = Date.now()): string { + if (!date) return ""; + const s = Math.max(0, Math.round((now - date.getTime()) / 1000)); + if (s < 60) return `${s} s`; + if (s < 3600) return `${Math.floor(s / 60)} min`; + if (s < 86400) return `${Math.floor(s / 3600)} h`; + const d = Math.floor(s / 86400); + if (d < 60) return `${d} d`; + if (d < 730) return `${Math.floor(d / 30)} mo`; + return `${Math.floor(d / 365)} y`; +} + +export const formatDate = (d?: Date) => + d + ? d.toLocaleString("en-GB", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) + : ""; + +export function classifyError(message: string): { title: string; unreachable: boolean } { + const clean = message.replace(/^(helm|kubectl) exited with \d+: /, ""); + if ( + /cluster unreachable|Unable to connect|connection refused|no such host|i\/o timeout|context deadline/i.test(clean) + ) { + return { title: "Cannot reach the cluster", unreachable: true }; + } + if (/forbidden|Unauthorized|You must be logged in/i.test(clean)) { + return { title: "The cluster refused the credentials", unreachable: false }; + } + return { title: "helm failed", unreachable: false }; +} + +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 helm"; + case "fedora": + return "sudo dnf install helm"; + case "suse": + return "sudo zypper install helm"; + default: + return "sudo snap install helm --classic"; + } +} diff --git a/extensions/helm/lib_test.ts b/extensions/helm/lib_test.ts new file mode 100644 index 0000000..0d15c2e --- /dev/null +++ b/extensions/helm/lib_test.ts @@ -0,0 +1,170 @@ +import { assertEquals } from "@std/assert"; +import { + age, + classifyError, + healthOf, + inContext, + installCommand, + osFamily, + parseHelmDate, + parseHistory, + parseKubeconfig, + parseReleases, + rollbackCommand, + rollbackTargets, + sortReleases, + splitChart, + statusLabel, + uninstallCommand, + valuesCommand, +} from "./lib.ts"; +import { holdOpen, terminalArgs } from "./terminal.ts"; + +const LIST = JSON.stringify([ + { + name: "ingress-nginx", + namespace: "ingress", + revision: "4", + updated: "2026-10-20 09:12:33.123456789 +0000 UTC", + status: "deployed", + chart: "ingress-nginx-4.11.2", + app_version: "1.11.2", + }, + { + name: "api", + namespace: "shop", + revision: "12", + updated: "2026-10-28 11:00:00.5 +0200 CEST", + status: "failed", + chart: "api-0.3.0-rc.1", + app_version: "", + }, + { + name: "redis", + namespace: "shop", + revision: "2", + updated: "2026-10-27 08:00:00 +0000 UTC", + status: "pending-upgrade", + chart: "redis-19.6.4", + app_version: "7.2.5", + }, + { + name: "cert-manager", + namespace: "cert-manager", + revision: "1", + updated: "2026-10-01 10:00:00 +0000 UTC", + status: "deployed", + chart: "cert-manager-v1.15.3", + app_version: "v1.15.3", + }, +]); + +Deno.test("charts and dates", () => { + assertEquals(splitChart("ingress-nginx-4.11.2"), { name: "ingress-nginx", version: "4.11.2" }); + assertEquals(splitChart("api-0.3.0-rc.1"), { name: "api", version: "0.3.0-rc.1" }); + assertEquals(splitChart("cert-manager-v1.15.3"), { name: "cert-manager", version: "v1.15.3" }); + assertEquals(splitChart("weird"), { name: "weird" }); + assertEquals(parseHelmDate("2026-10-20 09:12:33.123456789 +0000 UTC")?.toISOString(), "2026-10-20T09:12:33.123Z"); + assertEquals(parseHelmDate("2026-10-28 11:00:00.5 +0200 CEST")?.toISOString(), "2026-10-28T09:00:00.500Z"); + assertEquals(parseHelmDate("2026-10-01T10:00:00.987654321Z")?.toISOString(), "2026-10-01T10:00:00.987Z"); + assertEquals(parseHelmDate("2026-10-01T12:00:00+02:00")?.toISOString(), "2026-10-01T10:00:00.000Z"); + assertEquals(parseHelmDate(""), undefined); +}); + +Deno.test("releases, problems first", () => { + const releases = parseReleases(LIST); + assertEquals(releases[0].revision, 4); + assertEquals(releases[0].appVersion, "1.11.2"); + assertEquals(releases[1].appVersion, undefined); + assertEquals(sortReleases(releases).map((r) => `${r.name}:${r.health}`), [ + "api:failed", + "redis:pending", + "ingress-nginx:ok", + "cert-manager:ok", + ]); + assertEquals(healthOf("uninstalling"), "pending"); + assertEquals(healthOf("superseded"), "other"); + assertEquals(statusLabel("pending-upgrade"), "Pending upgrade"); + assertEquals(parseReleases("null"), []); +}); + +Deno.test("history and rollback targets", () => { + const history = parseHistory(JSON.stringify([ + { + revision: 1, + updated: "2026-10-01T10:00:00Z", + status: "superseded", + chart: "api-0.2.0", + app_version: "1.0", + description: "Install complete", + }, + { + revision: 2, + updated: "2026-10-10T10:00:00Z", + status: "deployed", + chart: "api-0.2.1", + app_version: "1.1", + description: "Upgrade complete", + }, + { + revision: 3, + updated: "2026-10-28T09:00:00Z", + status: "failed", + chart: "api-0.3.0", + app_version: "1.2", + description: 'Upgrade "api" failed: timed out waiting for the condition', + }, + ])); + assertEquals(history.map((r) => r.revision), [3, 2, 1]); + assertEquals(rollbackTargets(history, 3).map((r) => r.revision), [2, 1]); +}); + +Deno.test("command lines", () => { + const r = parseReleases(LIST)[0]; + assertEquals(valuesCommand(r), ["helm", "get", "values", "ingress-nginx", "--namespace", "ingress", "-o", "yaml"]); + assertEquals(valuesCommand(r, true).at(-1), "--all"); + assertEquals(rollbackCommand(r, 3), ["helm", "rollback", "ingress-nginx", "3", "--namespace", "ingress"]); + assertEquals(inContext(uninstallCommand(r), "prod"), [ + "helm", + "--kube-context", + "prod", + "uninstall", + "ingress-nginx", + "--namespace", + "ingress", + ]); + assertEquals(inContext(["helm", "list"], undefined), ["helm", "list"]); + assertEquals(terminalArgs("wezterm", ["helm", "status", "x"]), ["start", "--", "helm", "status", "x"]); + assertEquals(holdOpen(["helm", "status", "x"])[0], "sh"); +}); + +Deno.test("kubeconfig", () => { + const c = parseKubeconfig(JSON.stringify({ + "current-context": "prod", + clusters: [{ name: "prod-cluster", cluster: { server: "https://10.0.0.1:6443" } }], + contexts: [{ name: "prod", context: { cluster: "prod-cluster", user: "admin", namespace: "shop" } }], + })); + assertEquals(c, { + current: "prod", + contexts: [{ name: "prod", cluster: "prod-cluster", namespace: "shop", server: "https://10.0.0.1:6443" }], + }); + assertEquals(parseKubeconfig("{}"), { current: undefined, contexts: [] }); +}); + +Deno.test("errors, age and install hints", () => { + assertEquals( + classifyError('helm exited with 1: Error: Kubernetes cluster unreachable: Get "https://10.0.0.1:6443/version"') + .unreachable, + true, + ); + assertEquals( + classifyError("Error: list: failed to list: secrets is forbidden").title, + "The cluster refused the credentials", + ); + const now = Date.parse("2026-10-28T12:00:00Z"); + assertEquals(age(new Date("2026-10-28T11:15:00Z"), now), "45 min"); + assertEquals(age(new Date("2026-10-20T12:00:00Z"), now), "8 d"); + assertEquals(age(undefined, now), ""); + assertEquals(installCommand(osFamily("ID=arch\n")), "sudo pacman -S helm"); + assertEquals(installCommand(osFamily("ID=ubuntu\nID_LIKE=debian\n")), "sudo snap install helm --classic"); +}); diff --git a/extensions/helm/terminal.ts b/extensions/helm/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/helm/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/hugging-face/api.ts b/extensions/hugging-face/api.ts new file mode 100644 index 0000000..6125743 --- /dev/null +++ b/extensions/hugging-face/api.ts @@ -0,0 +1,69 @@ +// The Hugging Face Hub API, with the optional token for private and gated repositories. + +import { type Kind, mapRepo, plainSearchUrl, type Query, readmeUrl, type Repo, repoApiUrl, searchUrl } from "./lib.ts"; + +export class HubError extends Error { + constructor(message: string, public status: number) { + super(message); + } +} + +const headers = (token?: string): Record => ({ + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), +}); + +async function get(url: string, token: string | undefined, signal?: AbortSignal): Promise { + const res = await fetch(url, { signal, headers: headers(token) }); + if (res.ok) return res; + await res.body?.cancel(); + if (res.status === 401 && token) { + throw new HubError("Hugging Face rejected the token: check it in Settings → Extensions → Hugging Face", 401); + } + if (res.status === 429) throw new HubError("Hugging Face is rate limiting: wait a minute, or add a token", 429); + throw new HubError(`Hugging Face answered ${res.status}`, res.status); +} + +export async function searchHub(kind: Kind, q: Query, token: string | undefined, signal: AbortSignal): Promise { + let res: Response; + try { + res = await get(searchUrl(kind, q), token, signal); + } catch (e) { + // A field the Hub doesn't know any more: search without the extra fields. + if (!(e instanceof HubError) || e.status !== 400) throw e; + res = await get(plainSearchUrl(kind, q), token, signal); + } + const list = await res.json(); + return (Array.isArray(list) ? list : []).map((r: unknown) => mapRepo(kind, r)); +} + +/** One repository by id (for an exact id or URL typed), or undefined when it doesn't exist. */ +export async function getRepo(kind: Kind, id: string, token: string | undefined, signal?: AbortSignal) { + try { + return mapRepo(kind, await (await get(repoApiUrl(kind, id), token, signal)).json()); + } catch (e) { + if (e instanceof HubError && (e.status === 404 || e.status === 401)) return undefined; + throw e; + } +} + +/** The README, or why it can't be read. */ +export async function readme( + kind: Kind, + id: string, + token: string | undefined, + signal?: AbortSignal, +): Promise<{ text?: string; problem?: string }> { + const res = await fetch(readmeUrl(kind, id), { signal, headers: token ? { Authorization: `Bearer ${token}` } : {} }); + if (res.ok) return { text: await res.text() }; + await res.body?.cancel(); + if (res.status === 404) return { problem: "This repository has no README." }; + if (res.status === 401 || res.status === 403) { + return { + problem: token + ? "Your token can't read this repository: accept its conditions on its page first." + : "This repository is gated or private: accept its conditions on its page, then add a token in Settings → Extensions → Hugging Face.", + }; + } + return { problem: `Hugging Face answered ${res.status}.` }; +} diff --git a/extensions/hugging-face/backslash.toml b/extensions/hugging-face/backslash.toml new file mode 100644 index 0000000..9a676a7 --- /dev/null +++ b/extensions/hugging-face/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "hugging-face" +title = "Hugging Face" +description = "Search Hugging Face models, datasets and Spaces with downloads, likes, licenses and model cards" +version = "1.0.0" +author = "Backslash" +icon = "bs-sparkle" +license = "MIT" + +[permissions] +# The Hub API and the repositories' README files. +net = ["huggingface.co"] + +[[commands]] +name = "search" +title = "Search Hugging Face" +description = "Models, datasets and Spaces: downloads, likes, task, license, model card; copy download and ollama commands" +icon = "bs-sparkle" +keywords = [ + "hugging face", + "huggingface", + "hf", + "model", + "dataset", + "space", + "llm", + "gguf", + "ollama", + "transformers", + "ai", + "ml", + "modèle", + "jeu de données", + "intelligence artificielle", +] + +[[preferences]] +name = "token" +title = "Access token" +description = "Optional: a read token from huggingface.co/settings/tokens, for your private and gated repositories" +type = "secret" diff --git a/extensions/hugging-face/commands/search.ts b/extensions/hugging-face/commands/search.ts new file mode 100644 index 0000000..639509a --- /dev/null +++ b/extensions/hugging-face/commands/search.ts @@ -0,0 +1,250 @@ +import { + type Action, + action, + copy, + detail, + type DetailSpec, + type Icon, + Icons, + type Item, + list, + meta, + type Metadata, + open, + type Stat, +} from "@backslash/api"; +import { getRepo, readme, searchHub } from "../api.ts"; +import { + compact, + facts, + gatedLabel, + humanize, + type Kind, + KINDS, + modelCard, + pageUrl, + paramsLabel, + parseQuery, + plainTags, + relativeDate, + type Repo, + snippets, + spaceAppUrl, +} from "../lib.ts"; + +interface Prefs { + token?: string; +} + +const TOKEN_PAGE = "https://huggingface.co/settings/tokens"; + +function icon(r: Repo): Icon { + // A Space's own emoji is its icon on the Hub too. + if (r.kind === "space" && r.emoji) return r.emoji; + if (r.kind === "dataset") return { source: Icons.database, tint: KINDS.dataset.tint }; + if (r.kind === "space") return { source: Icons.rocket, tint: KINDS.space.tint }; + return { source: r.gguf ? Icons.box : Icons.cpu, tint: KINDS.model.tint }; +} + +function stats(r: Repo): Stat[] { + return [ + ...(r.downloads !== undefined ? [{ label: "Downloads · 30 days", value: compact(r.downloads) }] : []), + ...(r.likes !== undefined ? [{ label: "Likes", value: compact(r.likes) }] : []), + ...(r.parameters ? [{ label: "Parameters", value: compact(r.parameters) }] : []), + ...(r.kind === "space" && r.stage + ? [{ + label: "Status", + value: humanize(r.stage.toLowerCase()), + color: r.stage === "RUNNING" ? "green" as const : "gray" as const, + }] + : []), + ...(r.lastModified && (!r.parameters || r.kind !== "model") + ? [{ label: "Updated", value: relativeDate(r.lastModified) }] + : []), + ]; +} + +function metadata(r: Repo): Metadata[] { + const f = facts(r); + const tags = plainTags(r); + return [ + meta.tags("Type", [{ text: KINDS[r.kind].title, color: KINDS[r.kind].tint }]), + ...(r.pipeline ? [meta.text("Task", humanize(r.pipeline), { icon: Icons.sparkle })] : []), + ...(f.tasks.length ? [meta.tags("Tasks", f.tasks.map(humanize))] : []), + ...(r.library ? [meta.text("Library", r.library, { icon: Icons.package })] : []), + ...(r.sdk ? [meta.text("SDK", r.sdk, { icon: Icons.code })] : []), + ...(r.hardware ? [meta.text("Hardware", r.hardware, { icon: Icons.cpu })] : []), + ...(r.gguf?.architecture ? [meta.text("Architecture", r.gguf.architecture)] : []), + ...(r.gguf?.contextLength ? [meta.text("Context", `${compact(r.gguf.contextLength)} tokens`)] : []), + ...(r.license ? [meta.text("License", r.license, { icon: Icons.shield })] : []), + ...(r.gated ? [meta.tags("Access", [{ text: gatedLabel(r.gated), color: "orange" }])] : []), + ...(r.private ? [meta.tags("Visibility", [{ text: "Private", color: "red" }])] : []), + ...(f.baseModels.length ? [meta.text("Base model", f.baseModels.join(", "))] : []), + ...(f.languages.length ? [meta.tags("Languages", f.languages)] : []), + ...(f.datasets.length ? [meta.tags("Datasets", f.datasets)] : []), + ...(tags.length ? [meta.tags("Tags", tags)] : []), + meta.separator(), + ...(r.author ? [meta.person("Author", r.author)] : []), + ...(r.lastModified + ? [ + meta.text("Updated", new Date(r.lastModified).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.calendar, + }), + ] + : []), + ...(r.createdAt + ? [meta.text("Created", new Date(r.createdAt).toLocaleDateString("en", { dateStyle: "medium" }))] + : []), + meta.link("Page", pageUrl(r.kind, r.id).replace("https://", ""), pageUrl(r.kind, r.id)), + ]; +} + +function actions(r: Repo, prefs: Prefs, withCard: boolean): Action[] { + const url = pageUrl(r.kind, r.id); + const app = spaceAppUrl(r); + const s = snippets(r); + return [ + ...(withCard + ? [ + action( + r.kind === "model" ? "Show Model Card" : r.kind === "dataset" ? "Show Dataset Card" : "Show README", + () => card(r, prefs), + { icon: Icons.book }, + ), + ] + : []), + open(url, "Open on Hugging Face"), + ...(app ? [open(app, "Open App", { icon: Icons.window, shortcut: "o" })] : []), + copy(r.id, "Copy ID", { shortcut: "c" }), + ...s.map((x, i) => + copy(x.text, `Copy ${x.title}`, { + icon: Icons.terminal, + section: "Use", + ...(i === 0 ? { shortcut: "d" } : x.title === "ollama run" ? { shortcut: "o" } : {}), + }) + ), + copy(url, "Copy URL", { icon: Icons.link, section: "Copy" }), + ...(prefs.token?.trim() + ? [] + : [open(TOKEN_PAGE, "Create an Access Token", { icon: Icons.key, section: "Account" })]), + ]; +} + +async function card(r: Repo, prefs: Prefs): Promise { + const token = prefs.token?.trim() || undefined; + const { text, problem } = await readme(r.kind, r.id, token); + const body = text ? modelCard(text, 120) : `_${problem}_`; + const main = snippets(r)[r.gguf ? 2 : 0]; + return detail({ + title: r.title ?? r.id, + stats: stats(r), + markdown: [body || "_The README is empty._", "", "```", main.text, "```"].join("\n"), + metadata: metadata(r), + actions: actions(r, prefs, false), + }); +} + +function item(r: Repo, prefs: Prefs, section?: string): Item { + const sub = r.kind === "space" ? (r.description ?? r.title) : r.kind === "dataset" ? r.description : [ + r.pipeline ? humanize(r.pipeline) : undefined, + r.library, + paramsLabel(r.parameters || r.gguf?.total) || undefined, + ].filter(Boolean).join(" · "); + const lines = r.kind === "space" && r.title && r.description + ? [`**${r.title}**`, "", r.description] + : r.description + ? [r.description] + : []; + const main = snippets(r)[r.gguf ? 2 : 0]; + return { + id: `${r.kind}:${r.id}`, + title: r.id, + subtitle: sub || undefined, + icon: icon(r), + section, + keywords: [r.pipeline ?? "", r.library ?? "", ...(r.title ? [r.title] : [])].filter(Boolean), + accessories: [ + ...(r.gated ? [{ icon: Icons.lock, tooltip: gatedLabel(r.gated) }] : []), + ...(r.private ? [{ tag: "Private", color: "red" as const }] : []), + ...(r.gguf ? [{ tag: "GGUF", color: "orange" as const }] : []), + ...(r.downloads !== undefined + ? [{ icon: Icons.download, text: compact(r.downloads), tooltip: "Downloads, last 30 days" }] + : []), + ...(r.likes !== undefined ? [{ icon: Icons.heart, text: compact(r.likes), tooltip: "Likes" }] : []), + ...(r.lastModified ? [{ text: relativeDate(r.lastModified), tooltip: "Last modified" }] : []), + ], + detail: { + stats: stats(r), + markdown: [...lines, ...(lines.length ? [""] : []), "```", main.text, "```"].join("\n"), + metadata: metadata(r), + }, + actions: actions(r, prefs, true), + }; +} + +let status = ""; + +export default list({ + placeholder: "Search models, datasets and Spaces… (add gguf for GGUF models, or paste a URL)", + filter: { + options: [ + { value: "model", title: "Models" }, + { value: "dataset", title: "Datasets" }, + { value: "space", title: "Spaces" }, + ], + }, + empty: { title: "Nothing found", description: "Try fewer words: the Hub matches names.", icon: Icons.search }, + status: () => status, + async search(input, { filter, preferences, signal }) { + const prefs = preferences as Prefs; + const token = prefs.token?.trim() || undefined; + const kind = (["model", "dataset", "space"].includes(filter ?? "") ? filter : "model") as Kind; + const q = parseQuery(input, kind); + const k = q.exact?.kind ?? kind; + try { + const [found, exact] = await Promise.all([ + searchHub(k, q, token, signal), + q.exact ? getRepo(q.exact.kind, q.exact.id, token, signal).catch(() => undefined) : Promise.resolve(undefined), + ]); + const rest = exact ? found.filter((r) => r.id !== exact.id) : found; + const what = KINDS[k].plural.toLowerCase(); + status = q.text + ? `${rest.length + (exact ? 1 : 0)} ${what}${q.gguf ? " in GGUF" : ""} · most downloaded first` + : `Trending ${what}${q.gguf ? " in GGUF" : ""}${token ? "" : " · public only"}`; + return [ + ...(exact ? [item(exact, prefs, "Exact Match")] : []), + ...rest.map((r) => + item(r, prefs, exact ? KINDS[k].plural : q.text ? undefined : `Trending ${KINDS[k].plural}`) + ), + ...(q.text + ? [{ + id: "web", + title: `Search “${q.text}” on Hugging Face`, + subtitle: "Full-text search, filters and collections", + icon: { source: Icons.search, tint: "gray" as const }, + section: "More", + actions: [ + open( + `https://huggingface.co/${KINDS[k].api}?search=${encodeURIComponent(q.text)}`, + "Search on Hugging Face", + ), + ], + }] + : []), + ]; + } catch (e) { + if (signal.aborted) throw e; + status = "Hugging Face is unreachable"; + return [{ + id: "error", + title: "Can't reach Hugging Face", + subtitle: e instanceof Error ? e.message : String(e), + icon: { source: Icons.wifiOff, tint: "red" }, + actions: [ + open(`https://huggingface.co/${KINDS[k].api}?search=${encodeURIComponent(q.text)}`, "Search on Hugging Face"), + open(TOKEN_PAGE, "Manage Access Tokens", { icon: Icons.key }), + ], + }]; + } + }, +}); diff --git a/extensions/hugging-face/lib.ts b/extensions/hugging-face/lib.ts new file mode 100644 index 0000000..1a83eaa --- /dev/null +++ b/extensions/hugging-face/lib.ts @@ -0,0 +1,289 @@ +// Hugging Face Hub: search URLs, response mapping, model cards and commands. Pure, tested in lib_test.ts. + +export type Kind = "model" | "dataset" | "space"; + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export const HUB = "https://huggingface.co"; + +export const KINDS: Record = { + model: { title: "Model", plural: "Models", path: "", api: "models", tint: "purple" }, + dataset: { title: "Dataset", plural: "Datasets", path: "datasets/", api: "datasets", tint: "teal" }, + space: { title: "Space", plural: "Spaces", path: "spaces/", api: "spaces", tint: "pink" }, +}; + +/** Fields asked of the list endpoints (`expand[]`): what the rows and side panel show. */ +export const EXPAND: Record = { + model: [ + "author", + "downloads", + "likes", + "lastModified", + "createdAt", + "pipeline_tag", + "library_name", + "tags", + "gated", + "private", + "gguf", + "safetensors", + ], + dataset: ["author", "downloads", "likes", "lastModified", "createdAt", "tags", "gated", "private", "description"], + space: ["author", "likes", "lastModified", "createdAt", "tags", "private", "sdk", "cardData", "runtime", "subdomain"], +}; + +export interface Query { + text: string; + /** `gguf` typed in the query: only GGUF repositories. */ + gguf: boolean; + /** A repository id or page URL was typed: look it up exactly too. */ + exact?: { kind: Kind; id: string }; +} + +/** `llama gguf`, `meta-llama/Llama-3.1-8B`, `https://huggingface.co/datasets/x/y`. */ +export function parseQuery(input: string, kind: Kind): Query { + let text = input.trim(); + const url = text.match( + /^(?:https?:\/\/)?(?:www\.)?(?:huggingface\.co|hf\.co)\/(datasets\/|spaces\/)?([^/\s?#]+\/[^/\s?#]+)/, + ); + if (url) { + const k: Kind = url[1] === "datasets/" ? "dataset" : url[1] === "spaces/" ? "space" : "model"; + return { text: url[2], gguf: false, exact: { kind: k, id: url[2] } }; + } + const gguf = kind === "model" && /(^|\s)gguf(\s|$)/i.test(text); + if (gguf) text = text.replace(/(^|\s)gguf(?=\s|$)/gi, " ").replace(/\s+/g, " ").trim(); + const exact = /^[\w.-]+\/[\w.-]+$/.test(text) ? { kind, id: text } : undefined; + return { text, gguf, ...(exact ? { exact } : {}) }; +} + +export function searchUrl(kind: Kind, q: Query, limit = 30): string { + const p = new URLSearchParams(); + if (q.text) p.set("search", q.text); + // No query: what's trending; a query: the most downloaded (Spaces have no download counts). + p.set("sort", !q.text ? "trendingScore" : kind === "space" ? "likes" : "downloads"); + p.set("direction", "-1"); + p.set("limit", String(limit)); + if (q.gguf) p.set("filter", "gguf"); + for (const e of EXPAND[kind]) p.append("expand[]", e); + return `${HUB}/api/${KINDS[kind].api}?${p}`; +} + +/** The same search without `expand[]`, if the Hub ever rejects a field. */ +export const plainSearchUrl = (kind: Kind, q: Query, limit = 30) => + searchUrl(kind, q, limit).replace(/&expand%5B%5D=[^&]*/g, ""); + +export const repoApiUrl = (kind: Kind, id: string) => `${HUB}/api/${KINDS[kind].api}/${id}`; +export const pageUrl = (kind: Kind, id: string) => `${HUB}/${KINDS[kind].path}${id}`; +export const readmeUrl = (kind: Kind, id: string) => `${HUB}/${KINDS[kind].path}${id}/raw/main/README.md`; + +export interface Repo { + kind: Kind; + id: string; + author?: string; + downloads?: number; + likes?: number; + lastModified?: string; + createdAt?: string; + pipeline?: string; + library?: string; + license?: string; + tags: string[]; + /** "auto", "manual", or false. */ + gated?: string | false; + private?: boolean; + gguf?: { architecture?: string; contextLength?: number; total?: number }; + /** Parameter count (safetensors). */ + parameters?: number; + description?: string; + // Spaces + sdk?: string; + title?: string; + emoji?: string; + stage?: string; + hardware?: string; + subdomain?: string; +} + +/** Tags like `license:mit`, `language:en`, `region:us` carry facts. */ +function tagValues(tags: string[], prefix: string): string[] { + return tags.filter((t) => t.startsWith(`${prefix}:`)).map((t) => t.slice(prefix.length + 1)); +} + +export function mapRepo(kind: Kind, r: any): Repo { + const tags: string[] = Array.isArray(r?.tags) ? r.tags.map(String) : []; + const card = r?.cardData ?? {}; + const license = card.license ?? tagValues(tags, "license")[0]; + const isGguf = kind === "model" && (r?.library_name === "gguf" || tags.includes("gguf") || !!r?.gguf); + return { + kind, + id: String(r?.id ?? r?.modelId ?? ""), + ...(r?.author ? { author: r.author } : {}), + ...(typeof r?.downloads === "number" ? { downloads: r.downloads } : {}), + ...(typeof r?.likes === "number" ? { likes: r.likes } : {}), + ...(r?.lastModified ? { lastModified: r.lastModified } : {}), + ...(r?.createdAt ? { createdAt: r.createdAt } : {}), + ...(r?.pipeline_tag ? { pipeline: r.pipeline_tag } : {}), + ...(r?.library_name ? { library: r.library_name } : {}), + ...(license ? { license: Array.isArray(license) ? license.join(", ") : String(license) } : {}), + tags, + ...(r?.gated !== undefined ? { gated: r.gated === true ? "auto" : r.gated } : {}), + ...(r?.private ? { private: true } : {}), + ...(isGguf + ? { + gguf: { + ...(r?.gguf?.architecture ? { architecture: r.gguf.architecture } : {}), + ...(r?.gguf?.context_length ? { contextLength: r.gguf.context_length } : {}), + ...(r?.gguf?.total ? { total: r.gguf.total } : {}), + }, + } + : {}), + ...(r?.safetensors?.total ? { parameters: r.safetensors.total } : {}), + ...(r?.description ? { description: cleanDescription(r.description) } : {}), + ...(r?.sdk ?? card.sdk ? { sdk: r?.sdk ?? card.sdk } : {}), + ...(card.title ? { title: String(card.title) } : {}), + ...(card.emoji ? { emoji: String(card.emoji) } : {}), + ...(card.short_description && !r?.description ? { description: String(card.short_description) } : {}), + ...(r?.runtime?.stage ? { stage: r.runtime.stage } : {}), + ...(r?.runtime?.hardware?.current ? { hardware: r.runtime.hardware.current } : {}), + ...(r?.subdomain ? { subdomain: r.subdomain } : {}), + }; +} + +/** Dataset descriptions are the README's start, with markup: one readable line. */ +export function cleanDescription(s: string): string { + const text = s.replace(/<[^>]+>/g, " ").replace(/[#*_`>]+/g, " ").replace(/\s+/g, " ").trim(); + return text.length > 200 ? `${text.slice(0, 199)}…` : text; +} + +/** Languages, datasets, base models and other facts from the tags. */ +export function facts(r: Repo): { languages: string[]; datasets: string[]; baseModels: string[]; tasks: string[] } { + return { + languages: tagValues(r.tags, "language").slice(0, 8), + datasets: tagValues(r.tags, "dataset").slice(0, 5), + baseModels: tagValues(r.tags, "base_model").map((b) => b.replace(/^(finetune|quantized|adapter|merge):/, "")) + .filter((b, i, a) => a.indexOf(b) === i).slice(0, 3), + tasks: tagValues(r.tags, "task_categories").slice(0, 5), + }; +} + +/** Tags worth showing as pills (without `key:value` ones). */ +export const plainTags = (r: Repo) => + r.tags.filter((t) => !t.includes(":") && t !== r.library && t !== r.pipeline && t !== r.sdk).slice(0, 10); + +// --------------------------------------------------------------------------- +// Model cards + +/** The README without its YAML front matter, HTML and badges, cut after `maxLines`. */ +export function modelCard(md: string, maxLines = 100): string { + let text = md.replace(/\r\n?/g, "\n").replace(/^/, ""); + text = text.replace(/^---\n[\s\S]*?\n---[ \t]*(\n|$)/, ""); + text = text + .replace(//g, "") + .replace(/]*>/gi, "") + .replace(//gi, "\n") + .replace(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)") + .replace( + /<\/?(p|div|center|span|picture|source|sup|sub|details|summary|table|thead|tbody|tr|td|th|h\d)[^>]*>/gi, + "", + ); + const lines = text.split("\n").filter((l) => !/^\s*(\[?!\[[^\]]*\]\([^)]*\)\]?(\([^)]*\))?\s*)+$/.test(l)); + const out: string[] = []; + let blank = false; + let cut = false; + for (const [i, l] of lines.entries()) { + if (!l.trim()) { + if (!blank && out.length) out.push(""); + blank = true; + continue; + } + blank = false; + out.push(l.replace(/\s+$/, "")); + if (out.length >= maxLines) { + cut = lines.slice(i + 1).some((rest) => rest.trim()); + break; + } + } + if (out.filter((l) => l.trim().startsWith("```")).length % 2) out.push("```"); + const body = out.join("\n").trim(); + return cut ? `${body}\n\n…` : body; +} + +// --------------------------------------------------------------------------- +// Commands to copy + +export interface Snippet { + title: string; + text: string; +} + +export function snippets(r: Repo): Snippet[] { + const out: Snippet[] = []; + const type = r.kind === "model" ? "" : ` --repo-type ${r.kind}`; + out.push({ title: "huggingface-cli download", text: `huggingface-cli download ${r.id}${type}` }); + out.push({ title: "hf download", text: `hf download ${r.id}${type}` }); + if (r.kind === "model" && r.gguf) { + out.push({ title: "ollama run", text: `ollama run hf.co/${r.id}` }); + out.push({ title: "llama-server", text: `llama-server -hf ${r.id}` }); + } + if (r.kind === "model" && r.library === "transformers" && r.pipeline) { + out.push({ + title: "Transformers pipeline", + text: `from transformers import pipeline\n\npipe = pipeline("${r.pipeline}", model="${r.id}")`, + }); + } + if (r.kind === "model" && r.library === "diffusers") { + out.push({ + title: "Diffusers pipeline", + text: `from diffusers import DiffusionPipeline\n\npipe = DiffusionPipeline.from_pretrained("${r.id}")`, + }); + } + if (r.kind === "model" && r.library === "sentence-transformers") { + out.push({ + title: "Sentence Transformers", + text: `from sentence_transformers import SentenceTransformer\n\nmodel = SentenceTransformer("${r.id}")`, + }); + } + if (r.kind === "dataset") { + out.push({ title: "load_dataset", text: `from datasets import load_dataset\n\nds = load_dataset("${r.id}")` }); + } + out.push({ title: "git clone", text: `git clone ${pageUrl(r.kind, r.id)}` }); + return out; +} + +/** The Space's running app, when it has one. */ +export const spaceAppUrl = (r: Repo) => r.subdomain ? `https://${r.subdomain}.hf.space` : undefined; + +// --------------------------------------------------------------------------- +// Formatting + +export function compact(n: number | undefined): string { + if (n === undefined) return ""; + if (n < 1000) return String(n); + const units = ["K", "M", "B", "T"]; + let v = n; + let i = -1; + while (v >= 1000 && i < units.length - 1) v /= 1000, i++; + return `${v >= 100 ? Math.round(v) : v.toFixed(1).replace(/\.0$/, "")}${units[i]}`; +} + +/** 8030261248 → "8B params", 494032768 → "494M params". */ +export const paramsLabel = (n: number | undefined) => n ? `${compact(n)} params` : ""; + +export function relativeDate(iso: string | undefined, now = Date.now()): string { + if (!iso) return ""; + const t = new Date(iso).getTime(); + if (isNaN(t)) return ""; + const d = Math.floor(Math.max(0, now - t) / 86400000); + if (d === 0) return "today"; + if (d === 1) return "yesterday"; + if (d < 45) return `${d} days ago`; + const months = Math.floor(d / 30); + if (d < 730) return `${months} month${months === 1 ? "" : "s"} ago`; + return `${Math.floor(d / 365)} years ago`; +} + +/** `text-generation` → "Text Generation". */ +export const humanize = (s: string) => s.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + +export const gatedLabel = (g: string | false | undefined) => + g === "manual" ? "Gated, manual approval" : g ? "Gated, automatic approval" : ""; diff --git a/extensions/hugging-face/lib_test.ts b/extensions/hugging-face/lib_test.ts new file mode 100644 index 0000000..ac082ee --- /dev/null +++ b/extensions/hugging-face/lib_test.ts @@ -0,0 +1,271 @@ +import { assert, assertEquals } from "@std/assert"; +import { + compact, + facts, + gatedLabel, + humanize, + mapRepo, + modelCard, + pageUrl, + paramsLabel, + parseQuery, + plainSearchUrl, + plainTags, + readmeUrl, + relativeDate, + repoApiUrl, + searchUrl, + snippets, + spaceAppUrl, +} from "./lib.ts"; + +// huggingface.co is not reachable from the test machine: these follow the Hub API's documented shapes +// (GET /api/models|datasets|spaces with expand[]), with real ids and values. +const MODEL = { + _id: "6696ab0f0bdf7d6f2e1e8b39", + id: "meta-llama/Llama-3.1-8B-Instruct", + author: "meta-llama", + downloads: 5214788, + likes: 4632, + lastModified: "2024-09-25T17:00:57.000Z", + createdAt: "2024-07-18T08:56:00.000Z", + pipeline_tag: "text-generation", + library_name: "transformers", + gated: "manual", + private: false, + tags: [ + "transformers", + "safetensors", + "llama", + "text-generation", + "facebook", + "meta", + "pytorch", + "llama-3", + "conversational", + "en", + "de", + "base_model:meta-llama/Llama-3.1-8B", + "base_model:finetune:meta-llama/Llama-3.1-8B", + "license:llama3.1", + "text-generation-inference", + "endpoints_compatible", + "region:us", + ], + safetensors: { parameters: { BF16: 8030261248 }, total: 8030261248 }, +}; +const GGUF = { + id: "bartowski/Qwen2.5-7B-Instruct-GGUF", + author: "bartowski", + downloads: 95631, + likes: 112, + lastModified: "2024-09-19T03:27:44.000Z", + pipeline_tag: "text-generation", + library_name: "gguf", + tags: ["gguf", "chat", "text-generation", "en", "base_model:Qwen/Qwen2.5-7B-Instruct", "license:apache-2.0"], + gguf: { total: 7615616512, architecture: "qwen2", context_length: 32768 }, + gated: false, +}; +const DATASET = { + id: "HuggingFaceFW/fineweb", + author: "HuggingFaceFW", + downloads: 241550, + likes: 2196, + lastModified: "2025-01-31T14:10:44.000Z", + gated: false, + private: false, + description: + '\n\t\n\t\t\n\t\t🍷 FineWeb\n\t\n\n
\n FineWeb\n
\n\n15 trillion tokens of the finest data the 🌐 web has to offer', + tags: [ + "task_categories:text-generation", + "language:en", + "license:odc-by", + "size_categories:10B { + assertEquals(parseQuery("llama", "model"), { text: "llama", gguf: false }); + assertEquals(parseQuery(" qwen gguf ", "model"), { text: "qwen", gguf: true }); + assertEquals(parseQuery("gguf", "model"), { text: "", gguf: true }); + assertEquals(parseQuery("qwen gguf", "dataset"), { text: "qwen gguf", gguf: false }); + assertEquals(parseQuery("meta-llama/Llama-3.1-8B", "model"), { + text: "meta-llama/Llama-3.1-8B", + gguf: false, + exact: { kind: "model", id: "meta-llama/Llama-3.1-8B" }, + }); + assertEquals(parseQuery("https://huggingface.co/datasets/HuggingFaceFW/fineweb/tree/main", "model"), { + text: "HuggingFaceFW/fineweb", + gguf: false, + exact: { kind: "dataset", id: "HuggingFaceFW/fineweb" }, + }); + assertEquals(parseQuery("hf.co/spaces/a/b", "model").exact, { kind: "space", id: "a/b" }); +}); + +Deno.test("search URLs", () => { + const url = new URL(searchUrl("model", { text: "llama", gguf: true })); + assertEquals(url.pathname, "/api/models"); + assertEquals(url.searchParams.get("search"), "llama"); + assertEquals(url.searchParams.get("sort"), "downloads"); + assertEquals(url.searchParams.get("filter"), "gguf"); + assert(url.searchParams.getAll("expand[]").includes("gguf")); + assertEquals(new URL(searchUrl("space", { text: "", gguf: false })).searchParams.get("sort"), "trendingScore"); + assertEquals(new URL(searchUrl("space", { text: "flux", gguf: false })).searchParams.get("sort"), "likes"); + assertEquals( + plainSearchUrl("dataset", { text: "x", gguf: false }), + "https://huggingface.co/api/datasets?search=x&sort=downloads&direction=-1&limit=30", + ); + assertEquals(repoApiUrl("space", "a/b"), "https://huggingface.co/api/spaces/a/b"); + assertEquals(pageUrl("model", "a/b"), "https://huggingface.co/a/b"); + assertEquals(pageUrl("dataset", "a/b"), "https://huggingface.co/datasets/a/b"); + assertEquals(readmeUrl("dataset", "a/b"), "https://huggingface.co/datasets/a/b/raw/main/README.md"); +}); + +Deno.test("mapRepo: models", () => { + const m = mapRepo("model", MODEL); + assertEquals(m.id, "meta-llama/Llama-3.1-8B-Instruct"); + assertEquals(m.downloads, 5214788); + assertEquals(m.pipeline, "text-generation"); + assertEquals(m.library, "transformers"); + assertEquals(m.license, "llama3.1"); + assertEquals(m.gated, "manual"); + assertEquals(m.parameters, 8030261248); + assertEquals(m.gguf, undefined); + assertEquals(facts(m), { + languages: [], + datasets: [], + baseModels: ["meta-llama/Llama-3.1-8B"], + tasks: [], + }); + assertEquals(plainTags(m).slice(0, 4), ["safetensors", "llama", "facebook", "meta"]); + const g = mapRepo("model", GGUF); + assertEquals(g.gguf, { architecture: "qwen2", contextLength: 32768, total: 7615616512 }); + assertEquals(g.gated, false); + // A GGUF repository found without the gguf field. + assertEquals(mapRepo("model", { id: "a/b-GGUF", tags: ["gguf"] }).gguf, {}); +}); + +Deno.test("mapRepo: datasets and spaces", () => { + const d = mapRepo("dataset", DATASET); + assertEquals(d.description, "🍷 FineWeb 15 trillion tokens of the finest data the 🌐 web has to offer"); + assertEquals(d.license, "odc-by"); + assertEquals(facts(d).languages, ["en"]); + assertEquals(facts(d).tasks, ["text-generation"]); + const s = mapRepo("space", SPACE); + assertEquals(s.title, "FLUX.1 [dev]"); + assertEquals(s.emoji, "🖥️"); + assertEquals(s.sdk, "gradio"); + assertEquals(s.stage, "RUNNING"); + assertEquals(s.hardware, "zero-a10g"); + assertEquals(s.license, "mit"); + assertEquals(s.description, "12B param rectified flow transformer guidance-distilled"); + assertEquals(spaceAppUrl(s), "https://black-forest-labs-flux-1-dev.hf.space"); + assertEquals(plainTags(s), []); +}); + +Deno.test("modelCard strips front matter, HTML and badges", () => { + const md = [ + "---", + "license: apache-2.0", + "tags:", + "- text-generation", + "---", + "", + '
', + ' ', + "
", + "", + "[![Discord](https://img.shields.io/badge/d.svg)](https://discord.gg/x) ![HF](https://img.shields.io/b.svg)", + "", + "# Qwen2.5-7B-Instruct", + "", + "", + "", + 'See
the blog.
Thanks.', + "", + "```python", + "from transformers import AutoModel", + "model = AutoModel.from_pretrained('x')", + "```", + ].join("\n"); + assertEquals( + modelCard(md), + [ + "# Qwen2.5-7B-Instruct", + "", + "See [the blog](https://qwenlm.github.io).", + "Thanks.", + "", + "```python", + "from transformers import AutoModel", + "model = AutoModel.from_pretrained('x')", + "```", + ].join("\n"), + ); + // Cut in a code block: closed, and marked. + assertEquals(modelCard("# T\n\n```\na\nb\nc\n", 3), "# T\n\n```\n```\n\n…"); + assertEquals(modelCard("No front matter"), "No front matter"); +}); + +Deno.test("snippets per kind and library", () => { + const titles = (r: Parameters[0]) => snippets(r).map((s) => s.title); + const m = mapRepo("model", MODEL); + assertEquals(titles(m), ["huggingface-cli download", "hf download", "Transformers pipeline", "git clone"]); + assertEquals( + snippets(m)[2].text, + 'from transformers import pipeline\n\npipe = pipeline("text-generation", model="meta-llama/Llama-3.1-8B-Instruct")', + ); + const g = mapRepo("model", GGUF); + assertEquals(snippets(g).map((s) => s.text).slice(0, 4), [ + "huggingface-cli download bartowski/Qwen2.5-7B-Instruct-GGUF", + "hf download bartowski/Qwen2.5-7B-Instruct-GGUF", + "ollama run hf.co/bartowski/Qwen2.5-7B-Instruct-GGUF", + "llama-server -hf bartowski/Qwen2.5-7B-Instruct-GGUF", + ]); + const d = mapRepo("dataset", DATASET); + assertEquals(snippets(d)[0].text, "huggingface-cli download HuggingFaceFW/fineweb --repo-type dataset"); + assertEquals(titles(d), ["huggingface-cli download", "hf download", "load_dataset", "git clone"]); + assertEquals( + snippets(mapRepo("space", SPACE)).at(-1)?.text, + "git clone https://huggingface.co/spaces/black-forest-labs/FLUX.1-dev", + ); +}); + +Deno.test("formatting", () => { + assertEquals(compact(5214788), "5.2M"); + assertEquals(compact(4632), "4.6K"); + assertEquals(compact(12), "12"); + assertEquals(paramsLabel(8030261248), "8B params"); + assertEquals(paramsLabel(494032768), "494M params"); + assertEquals(paramsLabel(undefined), ""); + const now = Date.parse("2026-10-28T12:00:00Z"); + assertEquals(relativeDate("2026-10-28T01:00:00Z", now), "today"); + assertEquals(relativeDate("2026-10-27T01:00:00Z", now), "yesterday"); + assertEquals(relativeDate("2026-08-01T00:00:00Z", now), "2 months ago"); + assertEquals(relativeDate("2024-09-25T17:00:57.000Z", now), "2 years ago"); + assertEquals(humanize("text-generation"), "Text Generation"); + assertEquals(gatedLabel("manual"), "Gated, manual approval"); + assertEquals(gatedLabel("auto"), "Gated, automatic approval"); + assertEquals(gatedLabel(false), ""); +}); diff --git a/extensions/icon-search/api.ts b/extensions/icon-search/api.ts new file mode 100644 index 0000000..a19dea8 --- /dev/null +++ b/extensions/icon-search/api.ts @@ -0,0 +1,134 @@ +// Icon indexes and SVG files from jsDelivr, cached in ~/.cache/backslash/icon-search. + +import { HEROICONS } from "./heroicons.ts"; +import { + displayColor, + fileUrl, + type Icon, + MATERIAL_NAMES_URL, + parseHeroicons, + parseLucide, + parseMaterial, + parsePhosphor, + parseSimple, + parseTabler, + type SetId, + setOf, + svgPath, + viewSvg, +} from "./lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +export const CACHE = `${HOME}/.cache/backslash/icon-search`; +const WEEK = 7 * 24 * 60 * 60 * 1000; + +async function fetchText(url: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(url, { signal }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Error("cdn.jsdelivr.net can't be reached: check your connection"); + } + if (res.status === 404) throw new NotFound(url); + if (!res.ok) throw new Error(`cdn.jsdelivr.net answered ${res.status}`); + return await res.text(); +} + +export class NotFound extends Error {} + +async function readJson(path: string): Promise { + try { + return JSON.parse(await Deno.readTextFile(path)) as T; + } catch { + return undefined; + } +} + +async function download(id: SetId, signal?: AbortSignal): Promise { + const set = setOf(id)!; + switch (id) { + case "heroicons": + return parseHeroicons(HEROICONS); + case "material": + return parseMaterial(await fetchText(MATERIAL_NAMES_URL, signal)); + case "phosphor": + return parsePhosphor(await fetchText(fileUrl(set, set.index!), signal)); + case "lucide": + return parseLucide(JSON.parse(await fetchText(fileUrl(set, set.index!), signal))); + case "tabler": + return parseTabler(JSON.parse(await fetchText(fileUrl(set, set.index!), signal))); + case "simple": + return parseSimple(JSON.parse(await fetchText(fileUrl(set, set.index!), signal))); + } +} + +/** The icons of a set: from the cache when it is less than a week old, else downloaded again. */ +export async function loadSet(id: SetId, signal?: AbortSignal): Promise { + const file = `${CACHE}/index/${id}.json`; + const cached = await readJson<{ at: number; icons: Icon[] }>(file); + if (cached && Date.now() - cached.at < WEEK && cached.icons.length) return cached.icons; + try { + const icons = await download(id, signal); + if (!icons.length) throw new Error(`The ${setOf(id)!.title} index is empty`); + await Deno.mkdir(`${CACHE}/index`, { recursive: true }); + await Deno.writeTextFile(file, JSON.stringify({ at: Date.now(), icons })); + return icons; + } catch (e) { + if (cached?.icons.length && !signal?.aborted) return cached.icons; // stale beats nothing + throw e; + } +} + +const safe = (path: string) => path.replace(/[^\w.-]+/g, "_"); + +/** The SVG file of an icon (or one of its variants) as published. */ +export async function svgText(icon: Icon, path = svgPath(icon), signal?: AbortSignal): Promise { + const file = `${CACHE}/svg/${icon.set}/${safe(path)}`; + try { + return await Deno.readTextFile(file); + } catch { + const text = await fetchText(fileUrl(setOf(icon.set)!, path), signal); + if (!text.includes(" { + const file = `${CACHE}/view/${icon.set}/${safe(icon.name)}.svg`; + try { + await Deno.stat(file); + return file; + } catch { + try { + const svg = await svgText(icon, svgPath(icon), signal); + await Deno.mkdir(`${CACHE}/view/${icon.set}`, { recursive: true }); + await Deno.writeTextFile(file, viewSvg(svg, displayColor(icon))); + return file; + } catch (e) { + if (signal?.aborted) throw e; + return undefined; + } + } +} + +/** Runs `fn` on every item, `limit` at a time. */ +export async function pool(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +export async function clearCache() { + await Deno.remove(CACHE, { recursive: true }).catch(() => {}); +} diff --git a/extensions/icon-search/backslash.toml b/extensions/icon-search/backslash.toml new file mode 100644 index 0000000..cd32a67 --- /dev/null +++ b/extensions/icon-search/backslash.toml @@ -0,0 +1,40 @@ +[extension] +id = "icon-search" +title = "Icon Search" +description = "Search Lucide, Material Symbols, Tabler, Heroicons, Phosphor and Simple Icons, and copy them as SVG, JSX or data URI" +version = "1.0.0" +author = "Backslash" +icon = "bs-image" +license = "MIT" + +# Indexes and SVG files come from the icon sets' npm packages on jsDelivr. +# They are cached in ~/.cache/backslash/icon-search (indexes for a week), with +# a recolored copy of each SVG that the launcher can show on any theme. +[permissions] +net = ["cdn.jsdelivr.net"] +read = ["~/.cache/backslash/icon-search"] +write = ["~/.cache/backslash/icon-search"] +env = ["HOME"] + +[[commands]] +name = "icons" +title = "Search Icons" +description = "Lucide, Material Symbols, Tabler, Heroicons, Phosphor and Simple Icons in one grid" +icon = "bs-image" +keywords = [ + "icon", + "icons", + "icône", + "icônes", + "svg", + "lucide", + "material", + "symbols", + "tabler", + "heroicons", + "phosphor", + "simple icons", + "brand", + "logo", + "pictogramme", +] diff --git a/extensions/icon-search/commands/icons.ts b/extensions/icon-search/commands/icons.ts new file mode 100644 index 0000000..4ad2ce2 --- /dev/null +++ b/extensions/icon-search/commands/icons.ts @@ -0,0 +1,172 @@ +import { + type Action, + action, + clipboard, + close, + copy, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + toast, +} from "@backslash/api"; +import { CACHE, clearCache, loadSet, NotFound, pool, svgText, viewFile } from "../api.ts"; +import { + cleanSvg, + dataUri, + fileUrl, + type Icon, + label, + materialHtml, + pageUrl, + react, + search, + type SetId, + setOf, + SETS, + svgPath, + variants, +} from "../lib.ts"; + +const LIMIT = 64; + +/** Copies an SVG fetched when the action runs (it is usually cached already). */ +const copySvg = (icon: Icon, title: string, path?: string, o: { shortcut?: string; section?: string } = {}) => + action(title, async () => { + try { + await clipboard.write(cleanSvg(await svgText(icon, path))); + } catch (e) { + if (e instanceof NotFound) throw new Error(`${icon.name} has no such style`); + throw e; + } + return close(`Copied ${icon.name}.svg`); + }, { icon: Icons.code, ...o }); + +function iconActions(icon: Icon): Action[] { + const set = setOf(icon.set)!; + const r = react(icon); + const styles = variants(icon); + return [ + copySvg(icon, "Copy SVG"), + copy(icon.name, "Copy Name", { icon: Icons.text }), + ...(r + ? [ + copy(r.importLine, "Copy React Import", { icon: Icons.braces, shortcut: "i", section: "Code" }), + copy(`<${r.component} />`, "Copy JSX", { icon: Icons.braces, shortcut: "j", section: "Code" }), + ] + : [ + copy(materialHtml(icon.name), "Copy HTML", { icon: Icons.braces, shortcut: "j", section: "Code" }), + ]), + action("Copy as Data URI", async () => { + await clipboard.write(dataUri(await svgText(icon))); + return close("Copied the data URI"); + }, { icon: Icons.link, shortcut: "u", section: "Code" }), + ...(styles.length > 1 + ? [submenu( + "Copy Style…", + styles.map(([title, path]) => copySvg(icon, `${title} SVG`, path)), + { icon: Icons.layers, shortcut: "s", section: "Code" }, + )] + : []), + copy(fileUrl(set, svgPath(icon)), "Copy CDN URL", { icon: Icons.globe, section: "Code" }), + ...(icon.hex + ? [ + copy(icon.hex, "Copy Brand Color", { icon: Icons.palette, shortcut: "h", section: "Code" }), + ] + : []), + open(pageUrl(icon), `Open on ${new URL(pageUrl(icon)).host.replace(/^www\./, "")}`, { + shortcut: "o", + section: "Links", + }), + action("Clear Icon Cache", async () => { + await clearCache(); + await toast("Icon cache cleared", { style: "success", message: "Indexes download again on the next search" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: "Delete the downloaded indexes and SVG files?", + section: "Cache", + }), + ]; +} + +function iconItem(icon: Icon, file: string | undefined, section?: string): Item { + const set = setOf(icon.set)!; + const r = react(icon); + return { + id: `${icon.set}:${icon.name}`, + title: label(icon), + subtitle: set.title, + icon: file ?? { source: Icons.image, tint: set.tint }, + section, + keywords: [icon.name, ...icon.tags.slice(0, 12)], + detail: { + ...(file ? { image: file } : {}), + metadata: [ + meta.text("Name", icon.name, { icon: Icons.tag }), + meta.tags("Set", [{ text: set.title, color: set.tint }]), + ...(icon.category ? [meta.text("Category", icon.category, { icon: Icons.folder })] : []), + ...(icon.hex ? [meta.text("Brand color", icon.hex, { icon: `color:${icon.hex}` })] : []), + ...(icon.tags.length ? [meta.tags("Tags", icon.tags.slice(0, 10))] : []), + ...(r ? [meta.text("React", r.component, { icon: Icons.braces })] : []), + meta.separator(), + meta.text("License", set.license, { icon: Icons.shield }), + meta.link("Page", new URL(pageUrl(icon)).host, pageUrl(icon)), + ], + }, + actions: iconActions(icon), + }; +} + +let last = ""; + +export default list({ + layout: "grid", + columns: 8, + placeholder: "Search icons: camera, arrow right, github…", + filter: { + options: [{ value: "all", title: "All" }, ...SETS.map((s) => ({ value: s.id, title: s.title }))], + default: "lucide", + }, + empty: { title: "No icon found", description: "Try another word, or All with Tab.", icon: Icons.image }, + status: () => last, + actions: [open(CACHE, "Open Icon Cache Folder", { icon: Icons.folder })], + async search(query, { filter, signal }) { + const ids: SetId[] = filter && filter !== "all" ? [filter as SetId] : SETS.map((s) => s.id); + const loaded = await Promise.all(ids.map((id) => + loadSet(id, signal).then((icons) => ({ id, icons }), (error) => { + if (signal.aborted) throw error; + return { id, icons: [] as Icon[], error: error instanceof Error ? error.message : String(error) }; + }) + )); + const failed = loaded.filter((l) => "error" in l); + const all = loaded.flatMap((l) => l.icons); + if (!all.length && failed.length) { + last = "Offline"; + return [{ + id: "error", + title: "Icons can't be downloaded", + subtitle: (failed[0] as { error: string }).error, + icon: { source: Icons.wifiOff, tint: "red" }, + detail: { markdown: "Indexes and SVG files come from **cdn.jsdelivr.net** and are cached for a week." }, + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + const single = ids.length === 1; + // With every set, the best few of each, so one big set doesn't hide the others. + const per = single ? LIMIT : Math.ceil(LIMIT / ids.length); + const found = query.trim() + ? loaded.flatMap((l) => search(l.icons, query, per)) + : loaded.flatMap((l) => l.icons.slice(0, per)); + const files = await pool(found, 12, (icon) => viewFile(icon, signal)); + const count = all.length.toLocaleString("en"); + last = `${query.trim() ? `${found.length}${found.length === LIMIT ? "+" : ""} of ` : ""}${count} icons${ + single ? ` · ${setOf(ids[0])!.title}` : "" + }${failed.length ? ` · ${failed.map((f) => setOf(f.id)!.title).join(", ")} unavailable` : ""}`; + return found.map((icon, i) => iconItem(icon, files[i], single ? undefined : setOf(icon.set)!.title)); + }, +}); diff --git a/extensions/icon-search/heroicons.ts b/extensions/icon-search/heroicons.ts new file mode 100644 index 0000000..60d0ee5 --- /dev/null +++ b/extensions/icon-search/heroicons.ts @@ -0,0 +1,49 @@ +// The 324 icons of Heroicons 2.2.0 (its last release); the package has no metadata file to fetch. +// Each exists in 24/outline and 24/solid, most also in 20/solid and 16/solid. + +export const HEROICONS = ` +academic-cap adjustments-horizontal adjustments-vertical archive-box archive-box-arrow-down +archive-box-x-mark arrow-down arrow-down-circle arrow-down-left arrow-down-on-square +arrow-down-on-square-stack arrow-down-right arrow-down-tray arrow-left arrow-left-circle +arrow-left-end-on-rectangle arrow-left-on-rectangle arrow-left-start-on-rectangle arrow-long-down +arrow-long-left arrow-long-right arrow-long-up arrow-path arrow-path-rounded-square arrow-right +arrow-right-circle arrow-right-end-on-rectangle arrow-right-on-rectangle arrow-right-start-on-rectangle +arrow-small-down arrow-small-left arrow-small-right arrow-small-up arrow-top-right-on-square +arrow-trending-down arrow-trending-up arrow-turn-down-left arrow-turn-down-right arrow-turn-left-down +arrow-turn-left-up arrow-turn-right-down arrow-turn-right-up arrow-turn-up-left arrow-turn-up-right arrow-up +arrow-up-circle arrow-up-left arrow-up-on-square arrow-up-on-square-stack arrow-up-right arrow-up-tray +arrow-uturn-down arrow-uturn-left arrow-uturn-right arrow-uturn-up arrows-pointing-in arrows-pointing-out +arrows-right-left arrows-up-down at-symbol backspace backward banknotes bars-2 bars-3 bars-3-bottom-left +bars-3-bottom-right bars-3-center-left bars-4 bars-arrow-down bars-arrow-up battery-0 battery-100 battery-50 +beaker bell bell-alert bell-slash bell-snooze bold bolt bolt-slash book-open bookmark bookmark-slash +bookmark-square briefcase bug-ant building-library building-office building-office-2 building-storefront cake +calculator calendar calendar-date-range calendar-days camera chart-bar chart-bar-square chart-pie +chat-bubble-bottom-center chat-bubble-bottom-center-text chat-bubble-left chat-bubble-left-ellipsis +chat-bubble-left-right chat-bubble-oval-left chat-bubble-oval-left-ellipsis check check-badge check-circle +chevron-double-down chevron-double-left chevron-double-right chevron-double-up chevron-down chevron-left +chevron-right chevron-up chevron-up-down circle-stack clipboard clipboard-document clipboard-document-check +clipboard-document-list clock cloud cloud-arrow-down cloud-arrow-up code-bracket code-bracket-square cog +cog-6-tooth cog-8-tooth command-line computer-desktop cpu-chip credit-card cube cube-transparent +currency-bangladeshi currency-dollar currency-euro currency-pound currency-rupee currency-yen +cursor-arrow-rays cursor-arrow-ripple device-phone-mobile device-tablet divide document document-arrow-down +document-arrow-up document-chart-bar document-check document-currency-bangladeshi document-currency-dollar +document-currency-euro document-currency-pound document-currency-rupee document-currency-yen +document-duplicate document-magnifying-glass document-minus document-plus document-text ellipsis-horizontal +ellipsis-horizontal-circle ellipsis-vertical envelope envelope-open equals exclamation-circle +exclamation-triangle eye eye-dropper eye-slash face-frown face-smile film finger-print fire flag folder +folder-arrow-down folder-minus folder-open folder-plus forward funnel gif gift gift-top globe-alt +globe-americas globe-asia-australia globe-europe-africa h1 h2 h3 hand-raised hand-thumb-down hand-thumb-up +hashtag heart home home-modern identification inbox inbox-arrow-down inbox-stack information-circle italic +key language lifebuoy light-bulb link link-slash list-bullet lock-closed lock-open magnifying-glass +magnifying-glass-circle magnifying-glass-minus magnifying-glass-plus map map-pin megaphone microphone minus +minus-circle minus-small moon musical-note newspaper no-symbol numbered-list paint-brush paper-airplane +paper-clip pause pause-circle pencil pencil-square percent-badge phone phone-arrow-down-left +phone-arrow-up-right phone-x-mark photo play play-circle play-pause plus plus-circle plus-small power +presentation-chart-bar presentation-chart-line printer puzzle-piece qr-code question-mark-circle queue-list +radio receipt-percent receipt-refund rectangle-group rectangle-stack rocket-launch rss scale scissors server +server-stack share shield-check shield-exclamation shopping-bag shopping-cart signal signal-slash slash +sparkles speaker-wave speaker-x-mark square-2-stack square-3-stack-3d squares-2x2 squares-plus star stop +stop-circle strikethrough sun swatch table-cells tag ticket trash trophy truck tv underline user user-circle +user-group user-minus user-plus users variable video-camera video-camera-slash view-columns viewfinder-circle +wallet wifi window wrench wrench-screwdriver x-circle x-mark +`.trim().split(/\s+/); diff --git a/extensions/icon-search/lib.ts b/extensions/icon-search/lib.ts new file mode 100644 index 0000000..f732269 --- /dev/null +++ b/extensions/icon-search/lib.ts @@ -0,0 +1,372 @@ +// Pure logic of the Icon Search extension: icon sets, their index files, search and SVG helpers. + +import type { Color } from "@backslash/api"; + +export type SetId = "lucide" | "material" | "tabler" | "heroicons" | "phosphor" | "simple"; + +export interface IconSet { + id: SetId; + title: string; + /** npm package whose files jsDelivr serves. */ + pkg: string; + version: string; + /** The index file in the package, if it has one. */ + index?: string; + license: string; + site: string; + tint: Color; +} + +const CDN = "https://cdn.jsdelivr.net/npm"; + +export const SETS: IconSet[] = [ + { + id: "lucide", + title: "Lucide", + pkg: "lucide-static", + version: "latest", + index: "tags.json", + license: "ISC", + site: "https://lucide.dev/icons/", + tint: "pink", + }, + { + id: "material", + title: "Material Symbols", + pkg: "@material-symbols/svg-400", + version: "latest", + license: "Apache-2.0", + site: "https://fonts.google.com/icons", + tint: "blue", + }, + { + id: "tabler", + title: "Tabler", + pkg: "@tabler/icons", + version: "latest", + index: "icons.json", + license: "MIT", + site: "https://tabler.io/icons", + tint: "teal", + }, + { + id: "heroicons", + title: "Heroicons", + pkg: "heroicons", + version: "2.2.0", + license: "MIT", + site: "https://heroicons.com", + tint: "purple", + }, + { + id: "phosphor", + title: "Phosphor", + pkg: "@phosphor-icons/core", + version: "latest", + index: "dist/index.mjs", + license: "MIT", + site: "https://phosphoricons.com", + tint: "green", + }, + { + id: "simple", + title: "Simple Icons", + pkg: "simple-icons", + version: "latest", + index: "data/simple-icons.json", + license: "CC0-1.0", + site: "https://simpleicons.org", + tint: "orange", + }, +]; + +export const setOf = (id: string) => SETS.find((s) => s.id === id); + +/** Material Symbols' names come from the font package's typings, published with the SVGs. */ +export const MATERIAL_NAMES_URL = `${CDN}/material-symbols@latest/index.d.ts`; + +export const fileUrl = (set: IconSet, path: string) => `${CDN}/${set.pkg}@${set.version}/${path}`; + +export interface Icon { + set: SetId; + /** File name without .svg: `camera`, `photo_camera`, `github`. */ + name: string; + /** Brand name for Simple Icons ("GitHub"). */ + title?: string; + tags: string[]; + category?: string; + /** The React component name when the set says it (Phosphor). */ + pascal?: string; + /** Brand color, Simple Icons only. */ + hex?: string; + /** Tabler: whether a filled version exists. */ + filled?: boolean; +} + +// --------------------------------------------------------------------------- +// Index files + +/** lucide-static/tags.json: `{ "camera": ["photo", "webcam", …] }`. */ +export function parseLucide(json: Record): Icon[] { + return Object.entries(json).map(([name, tags]) => ({ set: "lucide", name, tags: tags ?? [] })); +} + +/** @tabler/icons/icons.json: `{ "camera": { name, category, tags, styles: { outline, filled } } }`. */ +export function parseTabler(json: Record): Icon[] { + return Object.values(json).filter((i) => i?.name && (!i.styles || i.styles.outline)).map((i) => ({ + set: "tabler" as const, + name: i.name, + tags: (i.tags ?? []).map(String), + ...(i.category ? { category: i.category } : {}), + ...(i.styles?.filled ? { filled: true } : {}), + })); +} + +/** simple-icons/data/simple-icons.json: `[{ title, slug, hex, aliases }]`. */ +export function parseSimple(json: any[]): Icon[] { + return json.filter((i) => i?.title).map((i) => ({ + set: "simple" as const, + name: i.slug ?? titleToSlug(i.title), + title: i.title, + tags: [ + ...(i.aliases?.aka ?? []), + ...Object.values(i.aliases?.dup ?? {}).map((d: any) => d.title ?? "").filter(Boolean), + ...Object.values(i.aliases?.loc ?? {}), + ].map(String), + hex: `#${String(i.hex ?? "000000").toLowerCase()}`, + })); +} + +/** Simple Icons' own slug rule, for older data files without `slug`. */ +export function titleToSlug(title: string): string { + const replacements: Record = { "+": "plus", ".": "dot", "&": "and" }; + return title.toLowerCase() + .replace(/[+.&]/g, (c) => replacements[c]) + .normalize("NFD").replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]/g, ""); +} + +/** material-symbols/index.d.ts: `type MaterialSymbols = ["123", "360", "10k", …]`. */ +export function parseMaterial(dts: string): Icon[] { + const names = [...new Set([...dts.matchAll(/"([a-z0-9_]+)"/g)].map((m) => m[1]))]; + return names.map((name) => ({ set: "material", name, tags: name.split("_") })); +} + +/** @phosphor-icons/core/dist/index.mjs: `{ name: "acorn", pascal_name: "Acorn", categories: [e.FINANCE], tags: [...] }`. */ +export function parsePhosphor(js: string): Icon[] { + // The category enums: `e = /* @__PURE__ */ ((i) => (i.MEDIA = "media", …))(e || {})`. + const enums: Record> = {}; + for (const m of js.matchAll(/(\w+)\s*=\s*\/\*\s*@__PURE__\s*\*\/\s*\(\(\w+\)\s*=>\s*\(([\s\S]*?)\)\)\(/g)) { + enums[m[1]] = Object.fromEntries([...m[2].matchAll(/\.(\w+)\s*=\s*"([^"]*)"/g)].map((x) => [x[1], x[2]])); + } + const out: Icon[] = []; + const re = + /name:\s*"([^"]+)",\s*pascal_name:\s*"([^"]+)",\s*(?:alias:[\s\S]*?,\s*)?categories:\s*\[([^\]]*)\][\s\S]*?tags:\s*\[([^\]]*)\]/g; + for (const m of js.matchAll(re)) { + const tags = [...m[4].matchAll(/"([^"]*)"/g)].map((t) => t[1]).filter((t) => !t.startsWith("*")); + const [owner, key] = (m[3].split(",")[0] ?? "").trim().split("."); + const category = key ? enums[owner]?.[key] ?? key.toLowerCase().replace(/_/g, " ") : undefined; + out.push({ set: "phosphor", name: m[1], pascal: m[2], tags, ...(category ? { category } : {}) }); + } + return out; +} + +export function parseHeroicons(names: string[]): Icon[] { + return names.map((name) => ({ set: "heroicons", name, tags: [] })); +} + +// --------------------------------------------------------------------------- +// Files and names + +/** The SVG shown and copied by default. */ +export function svgPath(icon: Icon): string { + switch (icon.set) { + case "lucide": + return `icons/${icon.name}.svg`; + case "material": + return `outlined/${icon.name}.svg`; + case "tabler": + return `icons/outline/${icon.name}.svg`; + case "heroicons": + return `24/outline/${icon.name}.svg`; + case "phosphor": + return `assets/regular/${icon.name}.svg`; + case "simple": + return `icons/${icon.name}.svg`; + } +} + +/** The other styles of an icon: [title, path in the package]. */ +export function variants(icon: Icon): [string, string][] { + const n = icon.name; + switch (icon.set) { + case "material": + return [ + ["Outlined", `outlined/${n}.svg`], + ["Outlined, Filled", `outlined/${n}-fill.svg`], + ["Rounded", `rounded/${n}.svg`], + ["Rounded, Filled", `rounded/${n}-fill.svg`], + ["Sharp", `sharp/${n}.svg`], + ["Sharp, Filled", `sharp/${n}-fill.svg`], + ]; + case "tabler": + return [["Outline", `icons/outline/${n}.svg`], ...(icon.filled ? [["Filled", `icons/filled/${n}.svg`]] : [])] as [ + string, + string, + ][]; + case "heroicons": + return [ + ["Outline 24", `24/outline/${n}.svg`], + ["Solid 24", `24/solid/${n}.svg`], + ["Mini 20", `20/solid/${n}.svg`], + ["Micro 16", `16/solid/${n}.svg`], + ]; + case "phosphor": + return ["thin", "light", "regular", "bold", "fill", "duotone"].map((w) => [ + w[0].toUpperCase() + w.slice(1), + `assets/${w}/${n}${w === "regular" ? "" : `-${w}`}.svg`, + ]); + default: + return [["Default", svgPath(icon)]]; + } +} + +/** `arrow-down-0-1` → `ArrowDown01`, `photo_camera` → `PhotoCamera`. */ +export const pascal = (name: string) => + name.split(/[-_\s]+/).filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(""); + +/** The component name and its import, in each set's React package. */ +export function react(icon: Icon): { component: string; importLine: string } | undefined { + switch (icon.set) { + case "lucide": { + const c = pascal(icon.name); + return { component: c, importLine: `import { ${c} } from "lucide-react";` }; + } + case "tabler": { + const c = `Icon${pascal(icon.name)}`; + return { component: c, importLine: `import { ${c} } from "@tabler/icons-react";` }; + } + case "heroicons": { + const c = `${pascal(icon.name)}Icon`; + return { component: c, importLine: `import { ${c} } from "@heroicons/react/24/outline";` }; + } + case "phosphor": { + const c = icon.pascal ?? pascal(icon.name); + return { component: c, importLine: `import { ${c} } from "@phosphor-icons/react";` }; + } + case "simple": { + const c = `Si${icon.name[0].toUpperCase()}${icon.name.slice(1)}`; + return { component: c, importLine: `import { ${c} } from "@icons-pack/react-simple-icons";` }; + } + default: + return undefined; + } +} + +/** Material Symbols are used through their font. */ +export const materialHtml = (name: string) => `${name}`; + +export function pageUrl(icon: Icon): string { + const n = encodeURIComponent(icon.name); + switch (icon.set) { + case "lucide": + return `https://lucide.dev/icons/${n}`; + case "material": + return `https://fonts.google.com/icons?selected=Material+Symbols+Outlined:${n}`; + case "tabler": + return `https://tabler.io/icons/icon/${n}`; + case "heroicons": + return "https://heroicons.com/outline"; + case "phosphor": + return `https://phosphoricons.com/?q=${n}`; + case "simple": + return `https://simpleicons.org/?q=${n}`; + } +} + +/** A readable name: `photo_camera` → "photo camera". */ +export const label = (icon: Icon) => icon.title ?? icon.name.replace(/[-_]/g, " "); + +// --------------------------------------------------------------------------- +// SVG + +/** Strips comments and XML prologs, so what is copied starts with ` svg.replace(/<\?xml[^>]*>/g, "").replace(//g, "").trim(); + +/** + * The SVG drawn in the launcher: currentColor (and the default black fill) + * replaced by a color visible on light and dark themes, rendered big enough + * for the preview to be crisp. + */ +export function viewSvg(svg: string, color: string, size = 256): string { + const clean = cleanSvg(svg).replaceAll("currentColor", color); + return clean.replace(/]*)>/, (_, attrs: string) => { + let a = attrs.replace(/\s(width|height)="[^"]*"/g, ""); + if (!/\sfill="/.test(a)) a += ` fill="${color}"`; + return ``; + }); +} + +/** Relative luminance (0–1) of `#rrggbb`. */ +export function luminance(hex: string): number { + const n = parseInt(hex.slice(1), 16); + const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]; +} + +/** Brands in their color unless it would vanish on a dark or light background. */ +export function displayColor(icon: Icon, neutral = "#8e8e96"): string { + if (!icon.hex) return neutral; + const l = luminance(icon.hex); + return l < 0.03 || l > 0.85 ? neutral : icon.hex; +} + +/** `data:image/svg+xml;base64,…`, for CSS and . */ +export function dataUri(svg: string): string { + const bytes = new TextEncoder().encode(cleanSvg(svg)); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return `data:image/svg+xml;base64,${btoa(bin)}`; +} + +// --------------------------------------------------------------------------- +// Search + +const tokens = (s: string) => s.toLowerCase().split(/[-_\s]+/).filter(Boolean); + +/** Score of an icon for a query; 0 when it doesn't match. */ +export function score(icon: Icon, query: string): number { + const q = query.trim().toLowerCase().replace(/\s+/g, " "); + if (!q) return 1; + const name = icon.name.toLowerCase(); + const title = icon.title?.toLowerCase(); + const joined = q.replace(/[\s_]+/g, "-"); + const under = q.replace(/[\s-]+/g, "_"); + if (name === joined || name === under || title === q) return 1000; + if (name.startsWith(joined) || name.startsWith(under) || title?.startsWith(q)) return 800 - name.length; + const words = tokens(q); + const nameWords = tokens(icon.name); + if (words.every((w) => nameWords.some((n) => n.startsWith(w)))) return 600 - name.length; + if (name.includes(joined) || name.includes(under) || title?.includes(q)) return 400 - name.length; + const tags = icon.tags.map((t) => t.toLowerCase()); + if (tags.includes(q)) return 300 - name.length; + if (words.every((w) => tags.some((t) => t.includes(w)) || nameWords.some((n) => n.startsWith(w)))) { + return 150 - name.length; + } + if (icon.category && icon.category.toLowerCase().includes(q)) return 50; + return 0; +} + +export function search(icons: Icon[], query: string, limit: number): Icon[] { + if (!query.trim()) return icons.slice(0, limit); + const scored: [number, Icon][] = []; + for (const i of icons) { + const s = score(i, query); + if (s > 0) scored.push([s, i]); + } + scored.sort((a, b) => b[0] - a[0] || a[1].name.localeCompare(b[1].name)); + return scored.slice(0, limit).map(([, i]) => i); +} diff --git a/extensions/icon-search/lib_test.ts b/extensions/icon-search/lib_test.ts new file mode 100644 index 0000000..56d59cf --- /dev/null +++ b/extensions/icon-search/lib_test.ts @@ -0,0 +1,187 @@ +import { assert, assertEquals } from "@std/assert"; +import LUCIDE from "./testdata/lucide-tags.json" with { type: "json" }; +import TABLER from "./testdata/tabler-icons.json" with { type: "json" }; +import SIMPLE from "./testdata/simple-icons.json" with { type: "json" }; +import { HEROICONS } from "./heroicons.ts"; +import { + cleanSvg, + dataUri, + displayColor, + fileUrl, + type Icon, + luminance, + materialHtml, + pageUrl, + parseHeroicons, + parseLucide, + parseMaterial, + parsePhosphor, + parseSimple, + parseTabler, + pascal, + react, + score, + search, + setOf, + svgPath, + titleToSlug, + variants, + viewSvg, +} from "./lib.ts"; + +// Excerpts of the real index files (lucide-static 1.63, @tabler/icons 3.50, simple-icons 16.36, +// @phosphor-icons/core 2.1.1, material-symbols 0.48.2). +const dir = new URL("./testdata/", import.meta.url); +const PHOSPHOR = Deno.readTextFileSync(new URL("phosphor.mjs.txt", dir)); +const MATERIAL = Deno.readTextFileSync(new URL("material.d.ts.txt", dir)); + +Deno.test("parses each set's index", () => { + const lucide = parseLucide(LUCIDE); + assertEquals(lucide[0].name, "camera"); + assertEquals(lucide[0].set, "lucide"); + assert(lucide[0].tags.includes("photo")); + assertEquals(lucide.length, 4); + const tabler = parseTabler(TABLER); + const cam = tabler.find((i) => i.name === "camera")!; + assertEquals(cam.category, "Media"); + assertEquals(cam.filled, true); + assert(cam.tags.includes("photo")); + assertEquals(tabler.find((i) => i.name === "a-b-2")?.filled, undefined); + + const simple = parseSimple(SIMPLE); + const gh = simple.find((i) => i.name === "github")!; + assertEquals(gh.title, "GitHub"); + assertEquals(gh.hex, "#181717"); + assert(simple.find((i) => i.name === "dotnet")); + const loc = simple.find((i) => i.name === "afdian")!; + assert(loc.tags.length > 0); // localized names become tags + + const phosphor = parsePhosphor(PHOSPHOR); + assertEquals(phosphor.map((i) => i.name), ["acorn", "camera"]); + assertEquals(phosphor[1], { + set: "phosphor", + name: "camera", + pascal: "Camera", + tags: ["photography", "pictures", "lens"], + category: "media", + }); + assertEquals(phosphor[0].category, "finances"); + assert(!phosphor[0].tags.includes("*new*")); + + const material = parseMaterial(MATERIAL); + assert(material.some((i) => i.name === "photo_camera")); + assertEquals(material.find((i) => i.name === "zoom_out_map")?.tags, ["zoom", "out", "map"]); + assertEquals(material[0].name, "123"); + + const hero = parseHeroicons(HEROICONS); + assertEquals(hero.length, 324); + assert(hero.some((i) => i.name === "academic-cap")); +}); + +Deno.test("simple icons slugs", () => { + assertEquals(titleToSlug("Next.js"), "nextdotjs"); + assertEquals(titleToSlug(".NET"), "dotnet"); + assertEquals(titleToSlug("C++"), "cplusplus"); + assertEquals(titleToSlug("Ableton Live"), "abletonlive"); + assertEquals(titleToSlug("Citroën"), "citroen"); +}); + +const icon = (set: Icon["set"], name: string, extra: Partial = {}): Icon => ({ set, name, tags: [], ...extra }); + +Deno.test("files and variants", () => { + assertEquals(svgPath(icon("lucide", "camera")), "icons/camera.svg"); + assertEquals(svgPath(icon("material", "photo_camera")), "outlined/photo_camera.svg"); + assertEquals(svgPath(icon("heroicons", "camera")), "24/outline/camera.svg"); + assertEquals(svgPath(icon("phosphor", "camera")), "assets/regular/camera.svg"); + assertEquals( + fileUrl(setOf("tabler")!, svgPath(icon("tabler", "camera"))), + "https://cdn.jsdelivr.net/npm/@tabler/icons@latest/icons/outline/camera.svg", + ); + assertEquals(variants(icon("phosphor", "camera")).map(([, p]) => p), [ + "assets/thin/camera-thin.svg", + "assets/light/camera-light.svg", + "assets/regular/camera.svg", + "assets/bold/camera-bold.svg", + "assets/fill/camera-fill.svg", + "assets/duotone/camera-duotone.svg", + ]); + assertEquals(variants(icon("tabler", "a-b-2")).length, 1); + assertEquals(variants(icon("tabler", "camera", { filled: true }))[1], ["Filled", "icons/filled/camera.svg"]); + assertEquals(variants(icon("material", "home"))[1], ["Outlined, Filled", "outlined/home-fill.svg"]); +}); + +Deno.test("React names", () => { + assertEquals(pascal("arrow-down-0-1"), "ArrowDown01"); + assertEquals(pascal("photo_camera"), "PhotoCamera"); + assertEquals(react(icon("lucide", "camera-off"))?.importLine, 'import { CameraOff } from "lucide-react";'); + assertEquals(react(icon("tabler", "brand-github"))?.component, "IconBrandGithub"); + assertEquals(react(icon("heroicons", "academic-cap"))?.component, "AcademicCapIcon"); + assertEquals(react(icon("phosphor", "github-logo", { pascal: "GithubLogo" }))?.component, "GithubLogo"); + assertEquals( + react(icon("simple", "nextdotjs"))?.importLine, + 'import { SiNextdotjs } from "@icons-pack/react-simple-icons";', + ); + assertEquals(react(icon("material", "home")), undefined); + assertEquals(materialHtml("home"), 'home'); +}); + +Deno.test("pages", () => { + assertEquals(pageUrl(icon("lucide", "camera")), "https://lucide.dev/icons/camera"); + assertEquals(pageUrl(icon("tabler", "camera")), "https://tabler.io/icons/icon/camera"); + assertEquals( + pageUrl(icon("material", "photo_camera")), + "https://fonts.google.com/icons?selected=Material+Symbols+Outlined:photo_camera", + ); +}); + +Deno.test("view SVGs are recolored and sized", () => { + const lucide = + `\n\n \n`; + const v = viewSvg(lucide, "#8e8e96"); + assert(!v.includes("|$)/g, " ") + .replace(/|<\/(p|div|tr|li|h\d)>/gi, "\n") + .replace(/<[^>]*(>|$)/g, " "), + ); +} + +/** The readable text of a (possibly truncated) message body, from its first text part. */ +export function bodyText(headers: Record, body: string, depth = 0): string { + const ct = contentType(headers["content-type"]); + if (ct.type.startsWith("multipart/") && ct.params.boundary && depth < 4) { + const parts = body.split(`--${ct.params.boundary}`).slice(1); + const parsed = parts.map((p) => { + const split = /\r?\n\r?\n/.exec(p); + const head = split ? p.slice(0, split.index) : p; + return { + headers: parseHeaders(head.replace(/^\r?\n/, "")), + body: split ? p.slice(split.index + split[0].length) : "", + }; + }); + const pick = parsed.find((p) => contentType(p.headers["content-type"]).type === "text/plain") ?? + parsed.find((p) => contentType(p.headers["content-type"]).type.startsWith("multipart/")) ?? + parsed.find((p) => contentType(p.headers["content-type"]).type === "text/html"); + return pick ? bodyText(pick.headers, pick.body, depth + 1) : ""; + } + if (!ct.type.startsWith("text/")) return ""; + const text = decodeBytes(decodeTransfer(body, headers["content-transfer-encoding"]), ct.params.charset ?? "utf-8"); + return ct.type === "text/html" ? htmlToText(text) : text; +} + +/** One line of the body: without quotes, signatures, links and blank space. */ +export function preview(text: string, max = 180): string { + const lines: string[] = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line === "--" || line === "-- " || /^_{8,}$/.test(line)) break; + if (line.startsWith(">") || /^On .+ wrote:$/.test(line) || /^Le .+ a écrit\s?:$/.test(line)) continue; + if (line) lines.push(line); + } + const one = lines.join(" ").replace(/https?:\/\/\S{30,}/g, "").replace(/[\s​-‍͏]+/g, " ") + .trim(); + return one.length > max ? `${one.slice(0, max).replace(/\s+\S*$/, "")}…` : one; +} + +// --------------------------------------------------------------------------- +// Addresses and dates + +export interface Address { + name?: string; + email: string; +} + +/** `"Ada Lovelace" ` (a header) → name and address. */ +export function parseAddress(value: string | undefined): Address { + const text = decodeWords(value ?? "").trim(); + const m = /^(.*?)\s*<([^>]+)>/.exec(text); + if (m) { + const name = m[1].trim().replace(/^"(.*)"$/, "$1").replace(/\\(.)/g, "$1").trim(); + return { name: name || undefined, email: m[2].trim() }; + } + const email = text.replace(/\s*\(.*\)$/, "").trim(); + const comment = /\(([^)]+)\)/.exec(text)?.[1]; + return { name: comment, email }; +} + +/** An envelope address list `((name adl mailbox host) …)`. */ +function envelopeAddresses(t: Token | undefined): Address[] { + if (!Array.isArray(t)) return []; + return t.filter(Array.isArray).map((a) => { + const [name, , mailbox, host] = a as Token[]; + const decoded = name ? decodeWords(String(name)).trim() : ""; + return { name: decoded || undefined, email: [str(mailbox), str(host)].filter(Boolean).join("@") }; + }).filter((a) => a.email); +} + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; +const ZONES: Record = { + UT: 0, + UTC: 0, + GMT: 0, + Z: 0, + EST: -5, + EDT: -4, + CST: -6, + CDT: -5, + PST: -8, + PDT: -7, +}; + +/** RFC 5322 dates (`Tue, 27 Oct 2026 14:03:00 +0100`) and INTERNALDATE (`27-Oct-2026 14:03:00 +0100`). */ +export function parseDate(raw: string | undefined): number | undefined { + const s = (raw ?? "").trim(); + const m = /(\d{1,2})[\s-]+([a-z]{3})[a-z]*[\s-]+(\d{2,4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([+-]\d{4}|[a-z]{1,4})?/i + .exec(s); + if (m) { + const mo = MONTHS.indexOf(m[2].toLowerCase()); + if (mo >= 0) { + const y = m[3].length === 2 ? 2000 + Number(m[3]) : Number(m[3]); + const z = m[7] ?? "+0000"; + const offset = /^[+-]/.test(z) + ? (z[0] === "-" ? -1 : 1) * (Number(z.slice(1, 3)) * 60 + Number(z.slice(3, 5))) + : (ZONES[z.toUpperCase()] ?? 0) * 60; + return Date.UTC(y, mo, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0)) - offset * 60_000; + } + } + const t = Date.parse(s); + return Number.isFinite(t) ? t : undefined; +} + +// --------------------------------------------------------------------------- +// Messages + +export interface Message { + uid: number; + flags: string[]; + date?: number; + from: Address; + to: Address[]; + subject: string; + messageId?: string; + size?: number; + preview: string; + attachments: boolean; +} + +/** The fields this extension fetches, in one `UID FETCH`. */ +export const PREVIEW_BYTES = 3072; +export const FETCH_ITEMS = + `(UID FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID CONTENT-TYPE CONTENT-TRANSFER-ENCODING)] BODY.PEEK[TEXT]<0.${PREVIEW_BYTES}>)`; + +export function parseMessage(f: Record): Message | undefined { + const uid = Number(str(f.UID)); + if (!Number.isFinite(uid) || uid <= 0) return undefined; + const env = Array.isArray(f.ENVELOPE) ? f.ENVELOPE : []; + const headerKey = Object.keys(f).find((k) => k.startsWith("BODY[HEADER")); + const headers = parseHeaders(str(headerKey ? f[headerKey] : undefined) ?? ""); + const from = envelopeAddresses(env[2])[0] ?? parseAddress(headers.from); + const subject = decodeWords(str(env[1]) ?? headers.subject ?? "").replace(/\s+/g, " ").trim(); + const ct = contentType(headers["content-type"]); + return { + uid, + flags: Array.isArray(f.FLAGS) ? f.FLAGS.filter((x): x is string => typeof x === "string") : [], + date: parseDate(str(env[0]) ?? headers.date) ?? parseDate(str(f.INTERNALDATE)), + from, + to: envelopeAddresses(env[5]), + subject: subject || "(no subject)", + messageId: (str(env[9]) ?? headers["message-id"] ?? "").trim() || undefined, + size: str(f["RFC822.SIZE"]) ? Number(f["RFC822.SIZE"]) : undefined, + preview: preview(bodyText(headers, str(f["BODY[TEXT]"]) ?? "")), + attachments: ct.type === "multipart/mixed", + }; +} + +// --------------------------------------------------------------------------- +// Accounts + +export type Security = "tls" | "starttls"; + +export interface Preset { + id: string; + name: string; + host: string; + port: number; + security: Security; + /** Where to create the password the extension signs in with. */ + passwordUrl?: string; + passwordHelp: string; + webmail?: string; + /** Why it can't work, for providers that turned password sign-in off. */ + unsupported?: string; +} + +export const PRESETS: Record = { + gmail: { + id: "gmail", + name: "Gmail", + host: "imap.gmail.com", + port: 993, + security: "tls", + passwordUrl: "https://myaccount.google.com/apppasswords", + passwordHelp: "An app password (Google Account → Security → 2-Step Verification → App passwords)", + webmail: "https://mail.google.com/mail/", + }, + fastmail: { + id: "fastmail", + name: "Fastmail", + host: "imap.fastmail.com", + port: 993, + security: "tls", + passwordUrl: "https://app.fastmail.com/settings/security/apps", + passwordHelp: "An app password with IMAP access (Settings → Privacy & Security → App passwords)", + webmail: "https://app.fastmail.com/mail/Inbox/", + }, + icloud: { + id: "icloud", + name: "iCloud Mail", + host: "imap.mail.me.com", + port: 993, + security: "tls", + passwordUrl: "https://account.apple.com/account/manage", + passwordHelp: "An app-specific password (Apple Account → Sign-In and Security → App-Specific Passwords)", + webmail: "https://www.icloud.com/mail/", + }, + yahoo: { + id: "yahoo", + name: "Yahoo Mail", + host: "imap.mail.yahoo.com", + port: 993, + security: "tls", + passwordUrl: "https://login.yahoo.com/account/security", + passwordHelp: "An app password (Account Security → Generate app password)", + webmail: "https://mail.yahoo.com/", + }, + proton: { + id: "proton", + name: "Proton Mail", + host: "127.0.0.1", + port: 1143, + security: "starttls", + passwordUrl: "https://proton.me/mail/bridge", + passwordHelp: "The IMAP password Proton Mail Bridge shows for the account (not your Proton password)", + webmail: "https://mail.proton.me/u/0/inbox", + }, + outlook: { + id: "outlook", + name: "Outlook", + host: "outlook.office365.com", + port: 993, + security: "tls", + passwordHelp: "", + webmail: "https://outlook.live.com/mail/", + unsupported: + "Microsoft turned off password sign-in (basic authentication) for IMAP on Outlook.com and Microsoft 365: use the Microsoft 365 extension for this account", + }, + custom: { + id: "custom", + name: "IMAP", + host: "", + port: 993, + security: "tls", + passwordHelp: "Your password, or an app password if the provider requires one", + }, +}; + +const ALIASES: Record = { + google: "gmail", + googlemail: "gmail", + "google workspace": "gmail", + icloud: "icloud", + "icloud mail": "icloud", + apple: "icloud", + me: "icloud", + protonmail: "proton", + "proton mail": "proton", + "proton bridge": "proton", + hotmail: "outlook", + "outlook.com": "outlook", + live: "outlook", + "microsoft 365": "outlook", + office365: "outlook", + "yahoo mail": "yahoo", + aol: "custom", + imap: "custom", + other: "custom", +}; + +/** The provider preference (any case, a few synonyms), or a guess from the address. */ +export function presetFor(provider: unknown, email: string): Preset { + const p = String(provider ?? "").trim().toLowerCase(); + const id = PRESETS[p] ? p : ALIASES[p]; + if (id) return PRESETS[id]; + const domain = email.split("@")[1]?.toLowerCase() ?? ""; + if (/^(gmail|googlemail)\.com$/.test(domain)) return PRESETS.gmail; + if (/^(fastmail\.(com|fm)|messagingengine\.com)$/.test(domain)) return PRESETS.fastmail; + if (/^(icloud|me|mac)\.com$/.test(domain)) return PRESETS.icloud; + if (/^(yahoo|ymail)\./.test(domain)) return PRESETS.yahoo; + if (/^(proton\.me|protonmail\.(com|ch)|pm\.me)$/.test(domain)) return PRESETS.proton; + if (/^(outlook|hotmail|live|msn)\./.test(domain)) return PRESETS.outlook; + return PRESETS.custom; +} + +export interface Account { + /** 1 to 3: the preferences it comes from. */ + index: number; + email: string; + password: string; + preset: Preset; + host: string; + port: number; + security: Security; +} + +export interface AccountProblem { + index: number; + email: string; + preset: Preset; + message: string; +} + +export const ACCOUNT_SLOTS = [1, 2, 3]; + +/** `imap.example.com`, `imap.example.com:143`, `imaps://host` → host, port, security. */ +export function parseServer(input: string, fallback: Preset): { host: string; port: number; security: Security } { + let text = input.trim(); + let scheme = ""; + const m = /^(imaps?):\/\//i.exec(text); + if (m) { + scheme = m[1].toLowerCase(); + text = text.slice(m[0].length); + } + text = text.replace(/\/.*$/, ""); + const hp = /^\[?([^\]]+?)\]?(?::(\d+))?$/.exec(text); + const host = (hp?.[1] ?? text).toLowerCase(); + const port = hp?.[2] ? Number(hp[2]) : scheme === "imap" ? 143 : fallback.port; + const security: Security = scheme === "imap" || port === 143 || port === 1143 + ? "starttls" + : scheme === "imaps" || port === 993 + ? "tls" + : fallback.security; + return { host, port, security }; +} + +/** The accounts set in the preferences, and those that can't be used as set. */ +export function accountsFrom(prefs: Record): { accounts: Account[]; problems: AccountProblem[] } { + const accounts: Account[] = []; + const problems: AccountProblem[] = []; + for (const index of ACCOUNT_SLOTS) { + const email = String(prefs[`email${index}`] ?? "").trim(); + const password = String(prefs[`password${index}`] ?? ""); + const server = String(prefs[`server${index}`] ?? "").trim(); + const provider = String(prefs[`provider${index}`] ?? "").trim(); + if (!email && !password && !server && !provider) continue; + const preset = presetFor(provider, email); + const problem = (message: string) => problems.push({ index, email: email || `Account ${index}`, preset, message }); + if (preset.unsupported) { + problem(preset.unsupported); + continue; + } + if (!email) { + problem(`Add the address of account ${index}`); + continue; + } + const where = server ? parseServer(server, preset) : preset; + if (!where.host) { + problem(`Add the IMAP server of ${email} (imap.example.com)`); + continue; + } + if (!password) { + problem(`Add the password of ${email}`); + continue; + } + accounts.push({ index, email, password, preset, host: where.host, port: where.port, security: where.security }); + } + return { accounts, problems }; +} + +export const isLoopback = (host: string) => /^(127\.\d+\.\d+\.\d+|localhost|::1|\[::1\])$/i.test(host); + +/** Where Enter opens a message: Gmail finds it by Message-ID, others open the inbox. */ +export function webmailUrl(a: Pick, messageId?: string): string | undefined { + if (a.preset.id === "gmail") { + const base = `https://mail.google.com/mail/u/${encodeURIComponent(a.email)}/`; + const id = messageId?.replace(/^<|>$/g, ""); + return id ? `${base}#search/rfc822msgid%3A${encodeURIComponent(id)}` : `${base}#inbox`; + } + return a.preset.webmail; +} + +/** A reply in the default mail app. */ +export function replyUrl(m: Pick): string { + const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`; + const params = [`subject=${encodeURIComponent(subject)}`]; + if (m.messageId) params.push(`In-Reply-To=${encodeURIComponent(m.messageId)}`); + return `mailto:${encodeURIComponent(m.from.email).replace(/%40/g, "@")}?${params.join("&")}`; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** "09:12" today, "Yesterday", "Mon", "12 Oct", "12 Oct 2025". */ +export function shortDate(ms: number | undefined, now = Date.now()): string { + if (ms === undefined) return ""; + const d = new Date(ms); + const today = new Date(now); + today.setHours(0, 0, 0, 0); + const day = 86_400_000; + if (ms >= today.getTime()) return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + if (ms >= today.getTime() - day) return "Yesterday"; + if (ms >= today.getTime() - 6 * day) return d.toLocaleDateString("en-GB", { weekday: "short" }); + const sameYear = d.getFullYear() === today.getFullYear(); + return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", ...(sameYear ? {} : { year: "numeric" }) }); +} + +export function fullDate(ms: number): string { + return new Date(ms).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function size(bytes: number | undefined): string | undefined { + if (bytes === undefined || !Number.isFinite(bytes)) return undefined; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export const senderName = (a: Address) => a.name || a.email.split("@")[0] || a.email; + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** "5 unread", or nothing when the inbox is clean. */ +export function unreadSubtitle(total: number): string | null { + return total > 0 ? `${total} unread` : null; +} diff --git a/extensions/mail/lib_test.ts b/extensions/mail/lib_test.ts new file mode 100644 index 0000000..870a195 --- /dev/null +++ b/extensions/mail/lib_test.ts @@ -0,0 +1,535 @@ +import { assert, assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; +import { Client, ImapError, type Transport } from "./imap.ts"; +import { + type Account, + accountsFrom, + bodyText, + capabilities, + decodeWords, + FETCH_ITEMS, + Framer, + parseAddress, + parseDate, + parseFetch, + parseFolders, + parseHeaders, + parseMessage, + parseServer, + parseStatus, + presetFor, + PRESETS, + preview, + quote, + replyUrl, + searchUids, + shortDate, + specialFolder, + tokenize, + uidSet, + unreadSubtitle, + webmailUrl, +} from "./lib.ts"; +import { accountErrorItem, messageItem, problemItem, setupItems } from "./views.ts"; + +const enc = new TextEncoder(); +/** An IMAP literal: `{bytes}` CRLF and the text. */ +const lit = (s: string) => `{${enc.encode(s).length}}\r\n${s}`; + +// --------------------------------------------------------------------------- +// A transport replaying a recorded session: `C:` lines must be what the +// client sends, `S:` lines are what the server answers, delivered in small +// chunks so framing across reads is exercised. + +type Step = { c: string } | { s: string | Uint8Array }; + +class Replay implements Transport { + #steps: Step[]; + #out = ""; + closed = false; + constructor(steps: Step[], private chunk = 7) { + this.#steps = [...steps]; + } + read(p: Uint8Array): Promise { + const step = this.#steps[0]; + if (!step) return Promise.resolve(null); + if ("c" in step) throw new Error(`The client waits for an answer, but should send: ${step.c}`); + const bytes = typeof step.s === "string" ? enc.encode(step.s) : step.s; + const n = Math.min(this.chunk, bytes.length, p.length); + p.set(bytes.subarray(0, n)); + const rest = bytes.subarray(n); + if (rest.length) this.#steps[0] = { s: rest }; + else this.#steps.shift(); + return Promise.resolve(n); + } + write(p: Uint8Array): Promise { + this.#out += new TextDecoder().decode(p); + let eol: number; + while ((eol = this.#out.indexOf("\r\n")) >= 0) { + const line = this.#out.slice(0, eol); + this.#out = this.#out.slice(eol + 2); + const step = this.#steps.shift(); + if (!step || !("c" in step)) throw new Error(`Unexpected client line: ${line}`); + assertEquals(line, step.c); + } + return Promise.resolve(p.length); + } + close() { + this.closed = true; + } + get done() { + return this.#steps.length === 0; + } +} + +/** `C: …` / `S: …` lines; a server line may hold literals (CRLF inside). */ +function transcript(...lines: string[]): Step[] { + return lines.map((l) => (l.startsWith("C: ") ? { c: l.slice(3) } : { s: `${l.slice(3)}\r\n` })); +} + +// --------------------------------------------------------------------------- +// Recorded sessions + +const GMAIL_HEADERS_1 = [ + "From: =?UTF-8?B?w4lsZWN0cmljaXTDqQ==?= de France ", + "Subject: =?UTF-8?Q?Votre_facture_d=E2=80=99octobre?=", + "Date: Tue, 27 Oct 2026 18:02:09 +0000", + "Message-ID: <20261027180209.4F2A@edf.fr>", + 'Content-Type: multipart/alternative; boundary="b1_9f3"', + "", + "", +].join("\r\n"); +const GMAIL_BODY_1 = [ + "--b1_9f3", + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: quoted-printable", + "", + "Bonjour Ada,=0D", + "Votre facture d=E2=80=99octobre est disponible : 64,20 =E2=82=AC, pr=C3=A9l=", + "ev=C3=A9e le 5 novembre.", + "", + "--b1_9f3", + 'Content-Type: text/html; charset="utf-8"', + "", + "

Bonjour Ada

", +].join("\r\n"); + +const GMAIL_HEADERS_2 = [ + "From: Grace Hopper ", + "Subject: Re: Compiler meeting", + "Date: Wed, 28 Oct 2026 08:15:00 -0400", + "Message-ID: ", + "Content-Type: text/plain; charset=UTF-8", + "", + "", +].join("\r\n"); +const GMAIL_BODY_2 = [ + "Works for me, see you at 10.", + "", + "On Tue, Oct 27, 2026 at 5:00 PM Ada wrote:", + "> Shall we meet tomorrow?", + "", + "-- ", + "Grace", +].join("\r\n"); + +const GMAIL = transcript( + "S: * OK Gimap ready for requests from 203.0.113.9 x5mb12345687abc", + "C: A1 CAPABILITY", + "S: * CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH", + "S: A1 OK Thats all she wrote! x5mb12345687abc", + `C: A2 AUTHENTICATE PLAIN ${btoa("\0ada@gmail.com\0abcd efgh ijkl mnop")}`, + "S: * CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE APPENDLIMIT=35651584", + "S: A2 OK ada@gmail.com authenticated (Success)", + "C: A3 EXAMINE INBOX", + "S: * FLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen $NotPhishing $Phishing)", + "S: * OK [PERMANENTFLAGS ()] Flags permitted.", + "S: * OK [UIDVALIDITY 1] UIDs valid.", + "S: * 1203 EXISTS", + "S: * 0 RECENT", + "S: * OK [UIDNEXT 88213] Predicted next UID.", + "S: A3 OK [READ-ONLY] INBOX selected. (Success)", + "C: A4 UID SEARCH UNSEEN", + "S: * SEARCH 88190 88207", + "S: A4 OK SEARCH completed (Success)", + `C: A5 UID FETCH 88190,88207 ${FETCH_ITEMS}`, + `S: * 1199 FETCH (UID 88190 FLAGS () INTERNALDATE "27-Oct-2026 18:02:11 +0000" RFC822.SIZE 48213 ENVELOPE ("Tue, 27 Oct 2026 18:02:09 +0000" "=?UTF-8?Q?Votre_facture_d=E2=80=99octobre?=" (("=?UTF-8?B?w4lsZWN0cmljaXTDqQ==?= de France" NIL "factures" "edf.fr")) (("=?UTF-8?B?w4lsZWN0cmljaXTDqQ==?= de France" NIL "factures" "edf.fr")) (("=?UTF-8?B?w4lsZWN0cmljaXTDqQ==?= de France" NIL "factures" "edf.fr")) ((NIL NIL "ada" "gmail.com")) NIL NIL NIL "<20261027180209.4F2A@edf.fr>") BODY[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID CONTENT-TYPE CONTENT-TRANSFER-ENCODING)] ${ + lit(GMAIL_HEADERS_1) + } BODY[TEXT]<0> ${lit(GMAIL_BODY_1)})`, + `S: * 1203 FETCH (UID 88207 FLAGS (\\Flagged) INTERNALDATE "28-Oct-2026 12:15:02 +0000" RFC822.SIZE 5120 ENVELOPE ("Wed, 28 Oct 2026 08:15:00 -0400" "Re: Compiler meeting" (("Grace Hopper" NIL "grace" "navy.example")) (("Grace Hopper" NIL "grace" "navy.example")) (("Grace Hopper" NIL "grace" "navy.example")) (("Ada Lovelace" NIL "ada" "gmail.com") (NIL NIL "team" "navy.example")) NIL NIL "" "") BODY[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID CONTENT-TYPE CONTENT-TRANSFER-ENCODING)] ${ + lit(GMAIL_HEADERS_2) + } BODY[TEXT]<0> ${lit(GMAIL_BODY_2)})`, + "S: A5 OK Success", + "C: A6 LOGOUT", + "S: * BYE LOGOUT Requested", + "S: A6 OK 73 good day (Success)", +); + +Deno.test("a recorded Gmail session: sign in with AUTHENTICATE PLAIN and SASL-IR, unread messages", async () => { + const t = new Replay(GMAIL); + const c = new Client(t); + await c.greeting(); + await c.login("ada@gmail.com", "abcd efgh ijkl mnop"); + assert(c.caps.has("MOVE")); + const { total, messages } = await c.unread(30); + await c.logout(); + assert(t.done); + assertEquals(total, 2); + assertEquals(messages.map((m) => m.uid), [88207, 88190]); + const [grace, edf] = messages; + assertEquals(grace.from, { name: "Grace Hopper", email: "grace@navy.example" }); + assertEquals(grace.subject, "Re: Compiler meeting"); + assertEquals(grace.preview, "Works for me, see you at 10."); + assertEquals(grace.flags, ["\\Flagged"]); + assertEquals(grace.to.map((a) => a.email), ["ada@gmail.com", "team@navy.example"]); + assertEquals(grace.messageId, ""); + assertEquals(grace.date, Date.UTC(2026, 9, 28, 12, 15)); + assertEquals(edf.from, { name: "Électricité de France", email: "factures@edf.fr" }); + assertEquals(edf.subject, "Votre facture d’octobre"); + assertEquals(edf.preview, "Bonjour Ada, Votre facture d’octobre est disponible : 64,20 €, prélevée le 5 novembre."); + assertEquals(edf.size, 48213); +}); + +Deno.test("a recorded Dovecot session: LOGIN with escaped quotes, greeting capabilities, archive with MOVE", async () => { + const steps = transcript( + "S: * OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ AUTH=LOGIN] Dovecot ready.", + 'C: A1 LOGIN "ada@example.org" "pa\\"ss\\\\word"', + "S: A1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT THREAD=REFERENCES MULTIAPPEND UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY SPECIAL-USE] Logged in", + 'C: A2 LIST "" "*"', + '* LIST (\\HasNoChildren) "." INBOX'.replace(/^/, "S: "), + 'S: * LIST (\\HasNoChildren \\Archive) "." Archive', + 'S: * LIST (\\HasNoChildren \\Trash) "." Trash', + 'S: * LIST (\\HasNoChildren \\Sent) "." "Sent Messages"', + "S: A2 OK List completed (0.001 + 0.000 secs).", + "C: A3 SELECT INBOX", + "S: * 12 EXISTS", + "S: A3 OK [READ-WRITE] Select completed (0.001 + 0.000 secs).", + 'C: A4 UID MOVE 41:42 "Archive"', + "S: * OK [COPYUID 1690000000 41:42 7:8] Moved UIDs.", + "S: * 11 EXPUNGE", + "S: * 11 EXPUNGE", + "S: A4 OK Move completed (0.002 + 0.000 + 0.001 secs).", + ); + const t = new Replay(steps, 5); + const c = new Client(t); + await c.greeting(); + await c.login("ada@example.org", 'pa"ss\\word'); + assertEquals(await c.moveTo([42, 41], "archive"), "Archive"); + assert(t.done); +}); + +Deno.test("no MOVE: copy to the trash, flag deleted and expunge; non-ASCII password as a literal", async () => { + const steps = transcript( + "S: * OK IMAP4rev1 Service Ready", + "C: A1 CAPABILITY", + "S: * CAPABILITY IMAP4rev1 IDLE", + "S: A1 OK CAPABILITY completed", + 'C: A2 LOGIN "rene@example.fr" {10}', + "S: + Ready for literal data", + "C: mot2passé", + "S: A2 OK LOGIN completed", + "C: A3 CAPABILITY", + "S: * CAPABILITY IMAP4rev1 IDLE CHILDREN", + "S: A3 OK CAPABILITY completed", + 'C: A4 LIST "" "*"', + 'S: * LIST (\\HasChildren) "/" INBOX', + 'S: * LIST (\\HasNoChildren) "/" "Deleted Messages"', + "S: A4 OK LIST completed", + "C: A5 SELECT INBOX", + "S: A5 OK [READ-WRITE] SELECT completed", + 'C: A6 UID COPY 7 "Deleted Messages"', + "S: A6 OK COPY completed", + "C: A7 UID STORE 7 +FLAGS.SILENT (\\Deleted)", + "S: A7 OK STORE completed", + "C: A8 EXPUNGE", + "S: * 3 EXPUNGE", + "S: A8 OK EXPUNGE completed", + ); + const t = new Replay(steps); + const c = new Client(t); + await c.greeting(); + await c.login("rene@example.fr", "mot2passé"); + assertEquals(await c.moveTo([7], "trash"), "Deleted Messages"); + assert(t.done); +}); + +Deno.test("AUTHENTICATE PLAIN without SASL-IR answers the continuation; a refusal is an auth error", async () => { + const token = btoa("\0bob@fastmail.com\0wrong"); + const steps = transcript( + "S: * OK [CAPABILITY IMAP4rev1 LITERAL+ ID ENABLE AUTH=PLAIN] Fastmail IMAP4rev1 3.9.0", + "C: A1 AUTHENTICATE PLAIN", + "S: + ", + `C: ${token}`, + "S: A1 NO [AUTHENTICATIONFAILED] Authentication failed", + ); + const c = new Client(new Replay(steps)); + await c.greeting(); + const e = await assertRejects(() => c.login("bob@fastmail.com", "wrong"), ImapError, "Authentication failed"); + assertEquals((e as ImapError).kind, "auth"); +}); + +Deno.test("mark read selects the inbox and stores \\Seen silently", async () => { + const steps = transcript( + "C: A1 SELECT INBOX", + "S: A1 OK [READ-WRITE] done", + "C: A2 UID STORE 3,5:7 +FLAGS.SILENT (\\Seen)", + "S: A2 OK done", + ); + const t = new Replay(steps); + await new Client(t).markRead([7, 3, 6, 5]); + assert(t.done); +}); + +Deno.test("a BYE or a closed connection fails clearly", async () => { + const c = new Client(new Replay(transcript("S: * BYE Too many connections from your IP"))); + await assertRejects(() => c.greeting(), ImapError, "Too many connections"); + const d = new Client(new Replay(transcript("S: * OK ready", "C: A1 NOOP"))); + await d.greeting(); + await assertRejects(() => d.command("NOOP"), ImapError, "closed"); +}); + +// --------------------------------------------------------------------------- +// Parsing + +Deno.test("framer: lines and literals, across chunks", () => { + const f = new Framer(); + const data = `* OK hi\r\n* 1 FETCH (BODY[TEXT] ${lit("a\r\nb")})\r\n* 2 EXISTS\r\nA1 OK`; + const out: string[] = []; + for (const ch of enc.encode(data)) { + f.push(new Uint8Array([ch])); + let line; + while ((line = f.next()) !== undefined) out.push(line); + } + assertEquals(out, ["* OK hi", "* 1 FETCH (BODY[TEXT] {4}\r\na\r\nb)", "* 2 EXISTS"]); + assertEquals(f.pending, 5); +}); + +Deno.test("tokenize: lists, quoted strings, NIL, sections and literals", () => { + assertEquals( + tokenize('* 1 FETCH (UID 9 FLAGS (\\Seen $Junk) BODY[HEADER.FIELDS (FROM)]<0> {3}\r\nabc X "a \\"b\\"" NIL)'), + ["*", "1", "FETCH", [ + "UID", + "9", + "FLAGS", + ["\\Seen", "$Junk"], + "BODY[HEADER.FIELDS (FROM)]<0>", + "abc", + "X", + 'a "b"', + null, + ]], + ); +}); + +Deno.test("status lines, capabilities, search results", () => { + assertEquals(parseStatus("A1 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)"), { + tag: "A1", + status: "NO", + code: "AUTHENTICATIONFAILED", + text: "Invalid credentials (Failure)", + }); + assertEquals(parseStatus("* OK Gimap ready")?.code, undefined); + assert(capabilities(["* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] Ready"]).has("AUTH=PLAIN")); + assertEquals(searchUids(["* SEARCH 4 8 15", "* SEARCH"]), [4, 8, 15]); +}); + +Deno.test("fetch without an envelope falls back on the headers", () => { + const headers = + 'From: "Lovelace, Ada" \r\nSubject: =?iso-8859-1?Q?R=E9union?=\r\n =?iso-8859-1?Q?_demain?=\r\nDate: 3 Nov 2026 09:00 +0100\r\n\r\n'; + const f = parseFetch(`* 4 FETCH (UID 77 FLAGS (\\Recent) BODY[HEADER.FIELDS (FROM SUBJECT DATE)] ${lit(headers)})`)!; + const m = parseMessage(f)!; + assertEquals(m.from, { name: "Lovelace, Ada", email: "ada@example.org" }); + assertEquals(m.subject, "Réunion demain"); + assertEquals(m.date, Date.UTC(2026, 10, 3, 8, 0)); + assertEquals(m.preview, ""); + assertEquals(parseMessage({ FLAGS: [] }), undefined); +}); + +Deno.test("decodeWords: B and Q, charsets, adjacent words, raw UTF-8", () => { + assertEquals(decodeWords("=?UTF-8?B?8J+OiSBTb2xkZXM=?= -50%"), "🎉 Soldes -50%"); + assertEquals(decodeWords("=?utf-8?q?a?= =?utf-8?q?b?= c"), "ab c"); + assertEquals(decodeWords("=?windows-1252?Q?=93Hi=94?="), "“Hi”"); + assertEquals(decodeWords("Caf\xc3\xa9"), "Café"); + assertEquals(decodeWords("Caf\xe9"), "Café"); +}); + +Deno.test("bodyText: base64 HTML, nested multipart, truncated parts", () => { + const html = btoa("

Hello you

"); + assertEquals( + bodyText({ "content-type": "text/html", "content-transfer-encoding": "base64" }, html).trim().replace(/\s+/g, " "), + "Hello you", + ); + const nested = [ + "--outer", + 'Content-Type: multipart/alternative; boundary="inner"', + "", + "--inner", + "Content-Type: text/plain; charset=iso-8859-1", + "Content-Transfer-Encoding: quoted-printable", + "", + "Ci-joint le devis sign=E9.", + "--inner--", + "--outer", + "Content-Type: application/pdf", + "Content-Transfer-Encoding: base64", + "", + "JVBERi0xLjQK", + ].join("\r\n"); + assertEquals( + bodyText({ "content-type": 'multipart/mixed; boundary="outer"' }, nested).trim(), + "Ci-joint le devis signé.", + ); + // Cut in the middle of a base64 part. + assertEquals( + bodyText({ "content-type": "text/plain", "content-transfer-encoding": "base64" }, "SGVsbG8gd29y"), + "Hello wor", + ); + assertEquals(bodyText({ "content-type": "image/png" }, "xx"), ""); +}); + +Deno.test("preview skips quotes and signatures and shortens", () => { + assertEquals(preview("Hi!\n\n> quoted\nLe 3 nov. 2026, Bob a écrit :\nThanks\n-- \nBob"), "Hi! Thanks"); + const long = preview("word ".repeat(100), 30); + assert(long.length <= 31 && long.endsWith("…")); +}); + +Deno.test("headers, addresses and dates", () => { + assertEquals(parseHeaders("Subject: a\r\n b\r\nFrom: x\r\nSubject: second"), { subject: "a b", from: "x" }); + assertEquals(parseAddress("bob@example.com (Bob)"), { name: "Bob", email: "bob@example.com" }); + assertEquals(parseAddress(""), { name: undefined, email: "noreply@example.com" }); + assertEquals(parseDate("Tue, 27 Oct 2026 14:03:00 +0100"), Date.UTC(2026, 9, 27, 13, 3)); + assertEquals(parseDate("27-Oct-2026 14:03:00 -0700"), Date.UTC(2026, 9, 27, 21, 3)); + assertEquals(parseDate("Mon, 2 Nov 2026 10:00:00 GMT (UTC)"), Date.UTC(2026, 10, 2, 10)); + assertEquals(parseDate("garbage"), undefined); +}); + +Deno.test("folders: special use first, then usual names, never \\Noselect", () => { + const gmail = parseFolders([ + '* LIST (\\HasNoChildren) "/" "INBOX"', + '* LIST (\\HasChildren \\Noselect) "/" "[Gmail]"', + '* LIST (\\All \\HasNoChildren) "/" "[Gmail]/All Mail"', + '* LIST (\\HasNoChildren \\Trash) "/" "[Gmail]/Trash"', + ]); + assertEquals(specialFolder(gmail, "archive"), "[Gmail]/All Mail"); + assertEquals(specialFolder(gmail, "trash"), "[Gmail]/Trash"); + const plain = parseFolders(['* LIST () "." INBOX.Archive', '* LIST () "." {5}\r\nTrash']); + assertEquals(specialFolder(plain, "archive"), "INBOX.Archive"); + assertEquals(specialFolder(plain, "trash"), "Trash"); + assertEquals(specialFolder([], "archive"), undefined); +}); + +Deno.test("quote and uid sets", () => { + assertEquals(quote('a"b\\c'), '"a\\"b\\\\c"'); + assertEquals(quote("é"), undefined); + assertEquals(uidSet([5, 1, 2, 3, 9, 10]), "1:3,5,9:10"); +}); + +// --------------------------------------------------------------------------- +// Accounts + +Deno.test("accounts: presets, guesses, custom servers, problems", () => { + const { accounts, problems } = accountsFrom({ + provider1: "Gmail", + email1: "ada@gmail.com", + password1: "abcd efgh ijkl mnop", + email2: "bob@hotmail.com", + password2: "x", + provider3: "custom", + email3: "me@example.org", + password3: "p", + server3: "mail.example.org:143", + }); + assertEquals(accounts.map((a) => [a.index, a.host, a.port, a.security]), [ + [1, "imap.gmail.com", 993, "tls"], + [3, "mail.example.org", 143, "starttls"], + ]); + assertEquals(problems.length, 1); + assertEquals(problems[0].preset.id, "outlook"); + assertEquals(accountsFrom({}).accounts, []); + assertStringIncludes( + accountsFrom({ provider1: "custom", email1: "a@b.c", password1: "x" }).problems[0].message, + "IMAP server", + ); + assertStringIncludes(accountsFrom({ email1: "a@icloud.com" }).problems[0].message, "password"); + assertEquals(presetFor("", "x@proton.me").port, 1143); + assertEquals(presetFor("protonmail", "").security, "starttls"); + assertEquals(parseServer("imaps://IMAP.Example.com", PRESETS.custom), { + host: "imap.example.com", + port: 993, + security: "tls", + }); + assertEquals(parseServer("127.0.0.1:1143", PRESETS.custom).security, "starttls"); +}); + +Deno.test("webmail and reply links", () => { + const gmail = { preset: PRESETS.gmail, email: "ada@gmail.com" }; + assertEquals( + webmailUrl(gmail, ""), + "https://mail.google.com/mail/u/ada%40gmail.com/#search/rfc822msgid%3ACAF%2Babc%40mail.gmail.com", + ); + assertEquals(webmailUrl({ preset: PRESETS.custom, email: "x" }), undefined); + assertEquals( + replyUrl({ from: { email: "bob@example.com" }, subject: "Lunch?", messageId: "<1@x>" }), + "mailto:bob@example.com?subject=Re%3A%20Lunch%3F&In-Reply-To=%3C1%40x%3E", + ); + assertEquals(unreadSubtitle(5), "5 unread"); + assertEquals(unreadSubtitle(0), null); +}); + +Deno.test("short dates", () => { + const now = new Date(2026, 9, 28, 15, 0).getTime(); + assertEquals(shortDate(new Date(2026, 9, 28, 9, 5).getTime(), now), "09:05"); + assertEquals(shortDate(new Date(2026, 9, 27, 22, 0).getTime(), now), "Yesterday"); + assertEquals(shortDate(new Date(2026, 3, 2).getTime(), now), "2 Apr"); + assertEquals(shortDate(new Date(2025, 3, 2).getTime(), now), "2 Apr 2025"); +}); + +// --------------------------------------------------------------------------- +// Items + +const ACCOUNT: Account = { + index: 1, + email: "ada@gmail.com", + password: "x", + preset: PRESETS.gmail, + host: "imap.gmail.com", + port: 993, + security: "tls", +}; + +Deno.test("message item: sender avatar, preview, actions with a confirmed delete", () => { + const item = messageItem( + { + uid: 9, + flags: ["\\Flagged"], + date: Date.now(), + from: { name: "Grace Hopper", email: "grace@navy.example" }, + to: [{ email: "ada@gmail.com" }], + subject: "Compiler", + messageId: "", + size: 2048, + preview: "See you at 10", + attachments: true, + }, + ACCOUNT, + { unreadUids: [9, 10], section: "ada@gmail.com · 2 unread" }, + ); + assertEquals(item.id, "1:9"); + assertEquals(item.subtitle, "Grace Hopper · See you at 10"); + assert(item.accessories?.some((a) => a.avatar === "Grace Hopper")); + const titles = item.actions!.map((a) => a.title); + assertEquals(titles.slice(0, 3), ["Open in Gmail", "Mark as Read", "Archive"]); + assert(titles.includes("Mark All 2 as Read")); + const del = item.actions!.find((a) => a.title === "Delete")!; + assert(del.confirm && del.destructive); + assertStringIncludes(item.detail!.markdown!, "See you at 10"); +}); + +Deno.test("setup, problems and errors explain what to do", () => { + assertEquals(setupItems()[0].id, "setup"); + const outlook = accountsFrom({ provider1: "outlook", email1: "a@outlook.com" }).problems[0]; + assertStringIncludes(problemItem(outlook).detail!.markdown!, "Microsoft 365 extension"); + const auth = accountErrorItem(ACCOUNT, new ImapError("[AUTHENTICATIONFAILED] Invalid credentials", "auth"), "x"); + assertEquals(auth.actions![0].title, "Create App Password"); + assertStringIncludes(auth.detail!.markdown!, "POP/IMAP"); + assertEquals(accountErrorItem(ACCOUNT, new Error("timeout"), "x").title, "Can't reach ada@gmail.com"); +}); diff --git a/extensions/mail/views.ts b/extensions/mail/views.ts new file mode 100644 index 0000000..6f005f5 --- /dev/null +++ b/extensions/mail/views.ts @@ -0,0 +1,265 @@ +// Unread messages, account problems and setup as launcher items. + +import { + type Accessory, + type Action, + action, + type Color, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + toast, +} from "@backslash/api"; +import { ImapError, session } from "./imap.ts"; +import { + type Account, + type AccountProblem, + fullDate, + type Message, + PRESETS, + replyUrl, + senderName, + shortDate, + size, + webmailUrl, +} from "./lib.ts"; + +export const ACCOUNT_COLORS: Color[] = ["blue", "purple", "teal"]; +export const accountColor = (index: number) => ACCOUNT_COLORS[(index - 1) % ACCOUNT_COLORS.length]; + +/** Runs an IMAP change and reloads the list, or shows why it failed. */ +function change(a: Account, title: string, done: string, fn: Parameters[1], o: Partial = {}) { + return action(title, async () => { + try { + await session(a, fn); + } catch (e) { + await toast(`${title} failed`, { style: "failure", message: e instanceof Error ? e.message : String(e) }); + return; + } + await toast(done, { style: "success" }); + return refresh(); + }, o); +} + +export function messageActions(m: Message, a: Account, unreadUids: number[]): Action[] { + const web = webmailUrl(a, m.messageId); + const markRead = change(a, "Mark as Read", "Marked as read", (c) => c.markRead([m.uid]), { + icon: Icons.check, + shortcut: "Return", + }); + const actions: Action[] = []; + if (web) { + actions.push(open(web, `Open in ${a.preset.id === "custom" ? "Webmail" : a.preset.name}`, { shortcut: "o" })); + } + actions.push( + markRead, + change(a, "Archive", "Archived", (c) => c.moveTo([m.uid], "archive"), { + icon: Icons.archive, + shortcut: "e", + }), + open(replyUrl(m), "Reply in Mail App", { icon: Icons.comment, shortcut: "r" }), + ); + if (unreadUids.length > 1) { + actions.push( + change( + a, + `Mark All ${unreadUids.length} as Read`, + `${a.email}: all read`, + (c) => c.markRead(unreadUids), + { icon: Icons.squareCheck, section: "Inbox", shortcut: "Return" }, + ), + ); + } + actions.push( + copy(m.subject, "Copy Subject", { section: "Copy", shortcut: "c" }), + copy(m.from.email, "Copy Sender Address", { section: "Copy" }), + ...(m.messageId ? [copy(m.messageId, "Copy Message-ID", { section: "Copy" })] : []), + change(a, "Delete", "Moved to the trash", (c) => c.moveTo([m.uid], "trash"), { + icon: Icons.trash, + section: "Danger", + destructive: true, + confirm: "Delete this message?", + shortcut: "Delete", + }), + ); + return actions; +} + +export function messageItem( + m: Message, + a: Account, + o: { unreadUids: number[]; section: string; now?: number }, +): Item { + const name = senderName(m.from); + const accessories: Accessory[] = []; + if (m.flags.some((f) => f.toLowerCase() === "\\flagged")) { + accessories.push({ icon: { source: Icons.flag, tint: "orange" }, tooltip: "Flagged" }); + } + if (m.attachments) accessories.push({ icon: Icons.file, tooltip: "Has attachments" }); + if (m.date) accessories.push({ text: shortDate(m.date, o.now), tooltip: fullDate(m.date) }); + accessories.push({ avatar: name, tooltip: m.from.email }); + const metadata: Metadata[] = [ + meta.person("From", name), + meta.text("Address", m.from.email, { icon: Icons.mail }), + ]; + if (m.to.length) { + const to = m.to.slice(0, 3).map((t) => t.name || t.email).join(", "); + metadata.push(meta.text("To", m.to.length > 3 ? `${to} +${m.to.length - 3}` : to, { icon: Icons.person })); + } + if (m.date) metadata.push(meta.text("Date", fullDate(m.date), { icon: Icons.calendar })); + metadata.push( + meta.separator(), + meta.text("Account", a.email, { icon: { source: Icons.inbox, tint: accountColor(a.index) } }), + ); + const s = size(m.size); + if (s) metadata.push(meta.text("Size", s)); + const tags = [ + ...(m.attachments ? [{ text: "Attachments", color: "gray" as Color }] : []), + ...(m.flags.some((f) => f.toLowerCase() === "\\flagged") ? [{ text: "Flagged", color: "orange" as Color }] : []), + ]; + if (tags.length) metadata.push(meta.tags("Labels", tags)); + return { + id: `${a.index}:${m.uid}`, + title: m.subject, + subtitle: m.preview ? `${name} · ${m.preview}` : name, + icon: { source: Icons.mail, tint: accountColor(a.index) }, + section: o.section, + keywords: [m.from.email, name, a.email], + accessories, + detail: { + markdown: [`### ${m.subject.replace(/[#*_`[\]]/g, "\\$&")}`, "", m.preview || "*No text preview*"].join("\n"), + metadata, + }, + actions: messageActions(m, a, o.unreadUids), + }; +} + +/** A clean inbox, per account. */ +export function inboxZeroItem(a: Account, section: string): Item { + const web = webmailUrl(a); + return { + id: `${a.index}:zero`, + title: "No unread mail", + subtitle: a.email, + section, + icon: { source: Icons.done, tint: "green" }, + detail: { markdown: `**Inbox zero** on ${a.email}.`, metadata: [meta.text("Server", `${a.host}:${a.port}`)] }, + actions: [ + ...(web ? [open(web, `Open ${a.preset.id === "custom" ? "Webmail" : a.preset.name}`)] : []), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + }; +} + +const SETTINGS = "**Settings → Extensions → Mail → Configure**"; + +function passwordActions(preset: AccountProblem["preset"]): Action[] { + return preset.passwordUrl ? [open(preset.passwordUrl, "Create App Password", { icon: Icons.key })] : []; +} + +/** An account that failed to load: sign-in, network, or certificate. */ +export function accountErrorItem(a: Account, error: unknown, section: string): Item { + const message = error instanceof Error ? error.message : String(error); + const auth = error instanceof ImapError && error.kind === "auth"; + const lines = auth + ? [ + `## ${a.preset.name} refused the sign-in`, + "", + `> ${message}`, + "", + `The password must be: ${a.preset.passwordHelp.charAt(0).toLowerCase()}${a.preset.passwordHelp.slice(1)}.`, + "", + `Paste it in ${SETTINGS} (Password ${a.index}).`, + ...(a.preset.id === "gmail" ? ["", "IMAP must also be on: Gmail → Settings → Forwarding and POP/IMAP."] : []), + ] + : [ + `## Can't load ${a.email}`, + "", + `> ${message}`, + "", + `Server: \`${a.host}:${a.port}\` (${a.security === "tls" ? "TLS" : "STARTTLS"})`, + ]; + return { + id: `${a.index}:error`, + title: auth ? `Can't sign in to ${a.email}` : `Can't reach ${a.email}`, + subtitle: message, + section, + icon: { source: auth ? Icons.key : Icons.alert, tint: auth ? "orange" : "red" }, + detail: { markdown: lines.join("\n") }, + actions: [ + ...(auth ? passwordActions(a.preset) : []), + action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...(a.preset.id === "proton" + ? [open("https://proton.me/support/protonmail-bridge-install", "Proton Mail Bridge Help")] + : []), + ], + }; +} + +export function problemItem(p: AccountProblem): Item { + const unsupported = !!p.preset.unsupported; + return { + id: `${p.index}:problem`, + title: unsupported ? `${p.email}: ${p.preset.name} isn't supported` : p.message, + subtitle: unsupported ? "Microsoft turned off IMAP passwords" : `Account ${p.index}`, + section: "Setup", + icon: { source: unsupported ? Icons.cross : Icons.settings, tint: "orange" }, + detail: { + markdown: unsupported + ? [ + "## Outlook and Microsoft 365", + "", + `${p.preset.unsupported}.`, + "", + `Remove this account from ${SETTINGS}.`, + ].join("\n") + : [`## ${p.message}`, "", `In ${SETTINGS}, account ${p.index}.`].join("\n"), + }, + actions: unsupported + ? [ + open( + "https://learn.microsoft.com/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online", + "Why (Microsoft)", + ), + ] + : passwordActions(p.preset), + }; +} + +export function setupItems(): Item[] { + const presets = ["gmail", "fastmail", "icloud", "yahoo", "proton", "custom"].map((id) => PRESETS[id]); + return [{ + id: "setup", + title: "Add a mail account", + subtitle: "Gmail, Fastmail, iCloud, Yahoo, Proton Mail Bridge or any IMAP server", + icon: { source: Icons.mail, tint: "blue" }, + section: "Setup", + detail: { + markdown: [ + "## Connect your mail", + "", + `In ${SETTINGS}, fill account 1 (up to 3):`, + "", + "- **Provider**: `gmail`, `fastmail`, `icloud`, `yahoo`, `proton` or `custom`", + "- **Address**: you@example.com", + "- **Password**: an *app password* for most providers", + "- **Server**: only for `custom`, e.g. `imap.example.com` or `mail.example.com:143`", + "", + "### App passwords", + "", + ...presets.filter((p) => p.passwordUrl).map((p) => `- **${p.name}**: ${p.passwordHelp}`), + "", + "Outlook.com and Microsoft 365 don't accept IMAP passwords any more: use the Microsoft 365 extension.", + ].join("\n"), + }, + actions: presets.filter((p) => p.passwordUrl).map((p, i) => + open(p.passwordUrl!, `${p.name} App Passwords`, { icon: Icons.key, section: i ? "App Passwords" : undefined }) + ), + }]; +} + +export const sectionTitle = (a: Account, total: number) => `${a.email} · ${total ? `${total} unread` : "no unread"}`; diff --git a/extensions/microsoft-365/api.ts b/extensions/microsoft-365/api.ts new file mode 100644 index 0000000..3975d1d --- /dev/null +++ b/extensions/microsoft-365/api.ts @@ -0,0 +1,126 @@ +// Authenticated calls to Microsoft Graph, and the "connect" row shown until the app is set up. + +import { action, copy, Icons, type Item, oauth, open, refresh } from "@backslash/api"; +import { AuthError, ENTRA_DOCS, ENTRA_URL, GRAPH, graphError, provider, REDIRECT_URI } from "./lib.ts"; + +export interface Client { + get(path: string, options?: { signal?: AbortSignal; headers?: Record }): Promise; + send(method: "POST" | "PATCH" | "DELETE", path: string, body?: unknown): 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); + const call = async ( + method: string, + path: string, + body?: unknown, + options: { signal?: AbortSignal; headers?: Record } = {}, + ) => { + const url = path.startsWith("https://") ? path : `${GRAPH}${path}`; + let res: Response; + try { + res = await fetch(url, { + method, + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + ...options.headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Microsoft Graph is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (!res.ok) { + if (res.status === 401) await oauth.signOut(p).catch(() => {}); + throw graphError(res.status, text, res.headers.get("retry-after")); + } + return text ? JSON.parse(text) : undefined; + }; + return { + get: (path, options) => call("GET", path, undefined, options), + send: (method, path, body) => call(method, path, body), + signOut: () => oauth.signOut(p), + }; +} + +/** Every page of a collection, up to `max` items. */ +export async function all(api: Client, path: string, signal?: AbortSignal, max = 500): Promise { + const out: T[] = []; + let next: string | undefined = path; + while (next && out.length < max) { + const page: { value: T[]; "@odata.nextLink"?: string } = await api.get(next, { signal }); + out.push(...page.value); + next = page["@odata.nextLink"]; + } + return out; +} + +export function setupItems(error: AuthError): Item[] { + if (error.message === "Microsoft signed you out") { + return [{ + id: "setup", + title: "Microsoft signed you out", + subtitle: "Sign in again in the browser", + icon: { source: Icons.key, tint: "orange" }, + actions: [action("Sign In", () => refresh(), { icon: Icons.key })], + }]; + } + return [{ + id: "setup", + title: error.message, + subtitle: "Microsoft 365 signs in with an app you register in Entra (free)", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Microsoft 365", + "", + "Backslash has no shared Microsoft app yet, so you sign in through one of your own. It takes two minutes:", + "", + "1. Open **Microsoft Entra → App registrations** (Enter) and sign in, then **New registration**.", + "2. Name it *Backslash*. Under **Supported account types** pick", + " *Accounts in any organizational directory and personal Microsoft accounts*.", + "3. **Authentication → Add a platform → Mobile and desktop applications**, and add the custom redirect URI", + ` \`${REDIRECT_URI}\` (any port is accepted on 127.0.0.1).`, + "4. Still in **Authentication**, set **Allow public client flows** to **Yes**, then **Save**.", + "5. Copy the **Application (client) ID** from **Overview** to", + " **Settings → Extensions → Microsoft 365 → Configure**.", + "", + "The first command you open asks you to sign in and to allow mail, tasks, files and calendar access.", + "", + "Work accounts: your administrator may have to approve the app. Signing in to one directory only?", + "Put its tenant ID in **Tenant** instead of *common*.", + ].join("\n"), + }, + actions: [ + open(ENTRA_URL, "Open App Registrations", { icon: Icons.key }), + open(ENTRA_DOCS, "Read Microsoft's Guide", { icon: Icons.book }), + copy(REDIRECT_URI, "Copy Redirect URI", { section: "Copy" }), + ], + }]; +} + +/** Runs `load`, or shows how to connect when the app isn't set up or the token was refused. */ +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); + throw e; + } +} + +/** The sign-out action every list offers. */ +export function signOutAction(prefs: Record, after: () => void) { + return action("Sign Out of Microsoft", async () => { + await oauth.signOut(provider(prefs)); + after(); + }, { icon: Icons.power, section: "Account" }); +} diff --git a/extensions/microsoft-365/backslash.toml b/extensions/microsoft-365/backslash.toml new file mode 100644 index 0000000..5080791 --- /dev/null +++ b/extensions/microsoft-365/backslash.toml @@ -0,0 +1,101 @@ +[extension] +id = "microsoft-365" +title = "Microsoft 365" +description = "Unread Outlook mail, Microsoft To Do tasks, OneDrive files and today's meetings through Microsoft Graph" +version = "1.0.0" +author = "Backslash" +icon = "bs-mail" +license = "MIT" + +# Microsoft Graph, and the sign-in (token endpoint) of the Microsoft identity platform. +[permissions] +net = ["graph.microsoft.com", "login.microsoftonline.com"] + +[[commands]] +name = "mail" +title = "Outlook Unread Mail" +description = "Unread messages in your inbox, Focused first; read, archive, flag or delete" +icon = "bs-mail" +keywords = [ + "outlook", + "mail", + "email", + "inbox", + "unread", + "microsoft", + "office", + "courrier", + "courriel", + "e-mail", + "boîte de réception", + "non lus", +] + +[[commands]] +name = "todo" +title = "Microsoft To Do" +description = "Tasks due today and coming up across your lists; Enter completes" +icon = "bs-square-check" +keywords = [ + "to do", + "todo", + "tasks", + "microsoft", + "outlook tasks", + "reminders", + "tâches", + "à faire", + "aujourd'hui", + "rappels", +] + +[[commands]] +name = "add-task" +title = "Add To Do Task" +description = "“Call Bob tomorrow !high #Work”: due date, importance and list are understood" +icon = "bs-plus" +mode = "no-view" +keywords = ["to do", "todo", "add", "new task", "quick add", "microsoft", "ajouter", "nouvelle tâche"] +[[commands.arguments]] +name = "task" +placeholder = "Call Bob tomorrow !high #Work" +required = true + +[[commands]] +name = "onedrive" +title = "Search OneDrive" +description = "Find files by name or content, with size, path and last change; recent files first" +icon = "bs-cloud" +keywords = ["onedrive", "files", "documents", "search", "microsoft", "sharepoint", "fichiers", "rechercher", "documents récents"] + +[[commands]] +name = "calendar" +title = "Today's Outlook Events" +description = "Today's meetings from your Outlook calendar; Enter joins the Teams call" +icon = "bs-calendar" +keywords = [ + "outlook", + "calendar", + "events", + "meetings", + "teams", + "today", + "agenda", + "microsoft", + "calendrier", + "réunions", + "rendez-vous", + "aujourd'hui", +] + +[[preferences]] +name = "clientId" +title = "Application (client) ID" +description = "Register an app at entra.microsoft.com → App registrations → New registration (any organizational directory and personal Microsoft accounts). Authentication → Add a platform → Mobile and desktop applications, custom redirect URI http://127.0.0.1/callback, and Allow public client flows: Yes. Paste its Application (client) ID here" +required = true + +[[preferences]] +name = "tenant" +title = "Tenant" +description = "common (work, school and personal accounts), organizations, consumers, or your directory's tenant ID or domain" +default = "common" diff --git a/extensions/microsoft-365/commands/add-task.ts b/extensions/microsoft-365/commands/add-task.ts new file mode 100644 index 0000000..7088273 --- /dev/null +++ b/extensions/microsoft-365/commands/add-task.ts @@ -0,0 +1,17 @@ +import { run } from "@backslash/api"; +import { client } from "../api.ts"; +import { AuthError } from "../lib.ts"; +import { createTask, loadLists } from "../todo.ts"; + +// "Add To Do Task Call Bob tomorrow !high #Work": added at once, no form. +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); + return await createTask(api, await loadLists(api, ctx.signal), { text }); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Microsoft To Do to set it up`); + throw e; + } +}); diff --git a/extensions/microsoft-365/commands/calendar.ts b/extensions/microsoft-365/commands/calendar.ts new file mode 100644 index 0000000..08f2cb6 --- /dev/null +++ b/extensions/microsoft-365/commands/calendar.ts @@ -0,0 +1,128 @@ +import { type Context, copy, Icons, type Item, list, open, updateCommandMetadata } from "@backslash/api"; +import { all, type Client, guarded, signOutAction } from "../api.ts"; +import { + calendarSubtitle, + type CalEvent, + dayRange, + EVENT_FIELDS, + eventMarkdown, + eventMetadata, + eventRelative, + eventSection, + eventTime, + graphTime, + joinLink, + plural, + RESPONSES, +} from "../lib.ts"; + +let summary = ""; + +const OUTLOOK_CALENDAR = "https://outlook.office.com/calendar/view/day"; + +async function events(api: Client, offset: number, days: number, signal: AbortSignal): Promise { + const { start, end } = dayRange(new Date(), offset, days); + const q = new URLSearchParams({ + startDateTime: start, + endDateTime: end, + $orderby: "start/dateTime", + $top: "100", + $select: EVENT_FIELDS, + }); + // Without a Prefer: outlook.timezone header, Graph gives the times in UTC. + return await all(api, `/me/calendarView?${q}`, signal, 300); +} + +function eventItem(e: CalEvent, now: Date, section: string): Item { + const join = joinLink(e); + const declined = e.responseStatus?.response === "declined"; + const live = eventSection(e, now) === "Now"; + const response = RESPONSES[e.responseStatus?.response ?? ""]; + const place = e.location?.displayName?.trim(); + return { + id: e.id, + title: e.subject || "(no title)", + subtitle: eventTime(e), + icon: { + source: join ? Icons.video : Icons.calendar, + tint: e.isCancelled || declined ? "gray" : live ? "green" : e.showAs === "tentative" ? "yellow" : "blue", + }, + section, + keywords: [place ?? "", e.organizer?.emailAddress?.name ?? "", join?.service ?? ""].filter(Boolean), + accessories: [ + ...(e.isCancelled ? [{ tag: "Canceled", color: "red" as const }] : []), + ...(response && response.text !== "Accepted" && response.text !== "Organizer" + ? [{ tag: response.text, color: response.color }] + : []), + ...(place && !/^https?:\/\//.test(place) && !/teams meeting/i.test(place) + ? [{ icon: Icons.pin, text: place.length > 24 ? `${place.slice(0, 23)}…` : place }] + : []), + ...(join ? [{ icon: Icons.video, tooltip: join.service }] : []), + { text: eventRelative(e, now), color: live ? "green" as const : undefined }, + ], + detail: { markdown: eventMarkdown(e), metadata: eventMetadata(e) }, + actions: [ + ...(join ? [open(join.url, `Join ${join.service}`, { icon: Icons.video })] : []), + open(e.webLink || OUTLOOK_CALENDAR, "Open in Outlook"), + ...(join ? [copy(join.url, "Copy Meeting Link", { shortcut: "c", section: "Copy" })] : []), + copy( + `${e.subject || "(no title)"} · ${eventTime(e)}${join ? `\n${join.url}` : ""}`, + "Copy Details", + { shortcut: "period", section: "Copy" }, + ), + ], + }; +} + +const DAYS: Record = { + today: { offset: 0, days: 1, title: "today" }, + tomorrow: { offset: 1, days: 1, title: "tomorrow" }, + week: { offset: 0, days: 7, title: "this week" }, +}; + +export default (ctx: Context) => + list({ + placeholder: "Filter events by title, place or organizer…", + filter: { + options: [ + { value: "today", title: "Today" }, + { value: "tomorrow", title: "Tomorrow" }, + { value: "week", title: "Next 7 Days" }, + ], + }, + empty: { title: "Nothing on the calendar", description: "Enjoy the free time.", icon: Icons.calendar }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + open(OUTLOOK_CALENDAR, "Open Outlook Calendar", { icon: Icons.calendar }), + signOutAction(ctx.preferences, () => {}), + ], + items: ({ signal, filter }) => + guarded(ctx.preferences, async (api) => { + const view = DAYS[filter ?? "today"] ?? DAYS.today; + const now = new Date(); + const found = await events(api, view.offset, view.days, signal); + const meetings = found.filter((e) => !e.isCancelled); + if (view === DAYS.today) { + updateCommandMetadata({ subtitle: calendarSubtitle(found, now) }).catch(() => {}); + } + const next = meetings.find((e) => eventSection(e, now) === "Upcoming" && !e.isAllDay); + summary = [ + `${plural(meetings.length, "event")} ${view.title}`, + next && view.offset === 0 ? `next ${eventRelative(next, now)}` : "", + ].filter(Boolean).join(" · "); + if (view.days === 1) { + const order = ["Now", "Upcoming", "All Day", "Earlier"]; + return found + .map((e) => ({ e, s: view.offset === 0 ? eventSection(e, now) : e.isAllDay ? "All Day" : "Events" })) + .sort((a, b) => + order.indexOf(a.s) - order.indexOf(b.s) || graphTime(a.e.start).getTime() - graphTime(b.e.start).getTime() + ) + .map(({ e, s }) => eventItem(e, now, s)); + } + return found.map((e) => { + const day = e.isAllDay ? new Date(e.start.dateTime.slice(0, 10) + "T00:00:00") : graphTime(e.start); + const title = day.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }); + return eventItem(e, now, title); + }); + }), + }); diff --git a/extensions/microsoft-365/commands/mail.ts b/extensions/microsoft-365/commands/mail.ts new file mode 100644 index 0000000..552c9bd --- /dev/null +++ b/extensions/microsoft-365/commands/mail.ts @@ -0,0 +1,167 @@ +import { + action, + type Context, + copy, + detail, + Icons, + type Item, + list, + open, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { type Client, guarded, signOutAction } from "../api.ts"; +import { + mailMarkdown, + mailMetadata, + mailSection, + mailUrl, + mdEscape, + type Message, + plural, + senderOf, + shortAgo, + sortMail, + unreadPath, +} from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; + +let unread = 0; +let shown = 0; + +async function change(api: Client, m: Message, body: unknown, message: string) { + await api.send("PATCH", `/me/messages/${encodeURIComponent(m.id)}`, body); + notifyChanged(); + await toast(message, { style: "success" }); +} + +async function move(api: Client, m: Message, folder: "archive" | "deleteditems", message: string) { + await api.send("POST", `/me/messages/${encodeURIComponent(m.id)}/move`, { destinationId: folder }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +/** The whole message as text (Graph converts HTML bodies when asked). */ +async function readMessage(api: Client, m: Message) { + const full = await api.get<{ body?: { content?: string } }>( + `/me/messages/${encodeURIComponent(m.id)}?$select=body`, + { headers: { Prefer: 'outlook.body-content-type="text"' } }, + ); + const body = (full.body?.content ?? "").replace(/\r/g, "").replace(/\n{3,}/g, "\n\n").trim(); + return detail({ + title: m.subject || "(no subject)", + markdown: `# ${mdEscape(m.subject || "(no subject)")}\n\n${mdEscape(body) || "*Empty message*"}`, + metadata: mailMetadata(m), + actions: [ + open(mailUrl(m), "Open in Outlook"), + action("Mark as Read", () => change(api, m, { isRead: true }, "Marked as read"), { icon: Icons.check }), + copy(body, "Copy Text", { section: "Copy" }), + ], + }); +} + +function mailItem(api: Client, m: Message, now: Date): Item { + const from = senderOf(m); + const received = new Date(m.receivedDateTime); + const flagged = m.flag?.flagStatus === "flagged"; + return { + id: m.id, + title: m.subject?.trim() || "(no subject)", + subtitle: from.name, + icon: { source: Icons.mail, tint: m.importance === "high" ? "red" : "blue" }, + section: mailSection(m), + keywords: [from.address, (m.bodyPreview ?? "").slice(0, 120)], + accessories: [ + ...(m.hasAttachments ? [{ icon: Icons.file, tooltip: "Attachments" }] : []), + ...(flagged ? [{ icon: { source: Icons.flag, tint: "orange" as const }, tooltip: "Flagged" }] : []), + ...(m.importance === "high" + ? [{ icon: { source: Icons.alert, tint: "red" as const }, tooltip: "Important" }] + : []), + { text: shortAgo(received, now), tooltip: received.toLocaleString() }, + ], + detail: { + markdown: mailMarkdown(m), + metadata: mailMetadata(m), + }, + actions: [ + open(mailUrl(m), "Open in Outlook"), + action("Mark as Read", () => change(api, m, { isRead: true }, "Marked as read"), { + icon: Icons.check, + shortcut: "r", + }), + action("Read Message", () => readMessage(api, m), { icon: Icons.eye }), + action("Archive", () => move(api, m, "archive", "Archived"), { + icon: Icons.archive, + shortcut: "e", + section: "Message", + }), + action( + flagged ? "Unflag" : "Flag", + () => + change( + api, + m, + { flag: { flagStatus: flagged ? "notFlagged" : "flagged" } }, + flagged ? "Unflagged" : "Flagged", + ), + { icon: Icons.flag, shortcut: "f", section: "Message" }, + ), + submenu("Move to Inbox Tab", [ + action("Focused", () => change(api, m, { inferenceClassification: "focused" }, "Moved to Focused"), { + icon: Icons.inbox, + }), + action("Other", () => change(api, m, { inferenceClassification: "other" }, "Moved to Other"), { + icon: Icons.inbox, + }), + ], { icon: Icons.inbox, section: "Message" }), + copy(m.subject ?? "", "Copy Subject", { shortcut: "period", section: "Copy" }), + ...(from.address ? [copy(from.address, "Copy Sender Address", { section: "Copy" })] : []), + copy(mailUrl(m), "Copy Link", { shortcut: "c", section: "Copy" }), + action("Delete", () => move(api, m, "deleteditems", "Moved to Deleted Items"), { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${m.subject?.trim() || "(no subject)"}”?`, + shortcut: "Delete", + section: "Danger", + }), + ], + }; +} + +export default (ctx: Context) => + list({ + placeholder: "Filter unread mail by subject, sender or text…", + filter: { + options: [ + { value: "all", title: "All Unread" }, + { value: "focused", title: "Focused" }, + { value: "other", title: "Other" }, + ], + }, + empty: { title: "Inbox zero", description: "No unread message in your inbox.", icon: Icons.inbox }, + status: (items) => + items[0]?.id === "setup" + ? "Not connected" + : `${plural(unread, "unread message")}${shown < unread ? ` · ${shown} shown` : ""} · Outlook`, + actions: [ + open("https://outlook.office.com/mail/", "Open Outlook", { icon: Icons.mail }), + signOutAction(ctx.preferences, notifyChanged), + ], + items: ({ signal, filter }) => + live(() => + guarded(ctx.preferences, async (api) => { + const [folder, messages] = await Promise.all([ + api.get<{ unreadItemCount: number }>("/me/mailFolders/inbox?$select=unreadItemCount", { signal }), + api.get<{ value: Message[] }>(unreadPath(), { signal }), + ]); + unread = folder.unreadItemCount; + updateCommandMetadata({ subtitle: unread ? `${unread} unread` : null }).catch(() => {}); + const now = new Date(); + const picked = sortMail(messages.value).filter((m) => + filter === "focused" ? mailSection(m) === "Focused" : filter === "other" ? mailSection(m) === "Other" : true + ); + shown = picked.length; + return picked.map((m) => mailItem(api, m, now)); + }), signal), + }); diff --git a/extensions/microsoft-365/commands/onedrive.ts b/extensions/microsoft-365/commands/onedrive.ts new file mode 100644 index 0000000..606c18d --- /dev/null +++ b/extensions/microsoft-365/commands/onedrive.ts @@ -0,0 +1,117 @@ +import { action, type Context, copy, Icons, type Item, list, open } from "@backslash/api"; +import { type Client, guarded, signOutAction } from "../api.ts"; +import { + DRIVE_FIELDS, + type DriveItem, + driveMetadata, + drivePath, + fileKind, + flatten, + formatSize, + plural, + searchPath, + shortAgo, +} from "../lib.ts"; + +let count = 0; +let label = ""; + +function fileItem(api: Client, raw: DriveItem, section?: string): Item { + const item = flatten(raw); + const kind = fileKind(item); + const now = new Date(); + const modified = item.lastModifiedDateTime ? new Date(item.lastModifiedDateTime) : undefined; + const path = drivePath(item); + return { + id: item.id, + title: item.name, + subtitle: path, + icon: { source: kind.icon, tint: kind.tint }, + section, + keywords: [kind.label], + accessories: [ + ...(item.shared ? [{ icon: Icons.person, tooltip: "Shared" }] : []), + { text: item.folder ? plural(item.folder.childCount ?? 0, "item") : formatSize(item.size) }, + ...(modified ? [{ icon: Icons.clock, text: shortAgo(modified, now), tooltip: modified.toLocaleString() }] : []), + ], + detail: { + stats: [ + item.folder + ? { label: "Items", value: item.folder.childCount ?? 0 } + : { label: "Size", value: formatSize(item.size) }, + ...(modified ? [{ label: "Modified", value: shortAgo(modified, now) }] : []), + ], + metadata: driveMetadata(item, now), + }, + actions: [ + ...(item.folder + ? [ + action("Browse Folder", () => folderView(api, item), { icon: Icons.folder }), + open(item.webUrl, "Open in OneDrive"), + ] + : [open(item.webUrl, "Open in Browser")]), + copy(item.webUrl, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(item.name, "Copy Name", { shortcut: "period", section: "Copy" }), + copy(`${path === "OneDrive" ? "" : `${path}/`}${item.name}`, "Copy Path", { section: "Copy" }), + ], + }; +} + +function folderView(api: Client, folder: DriveItem) { + const where = folder.parentReference?.driveId + ? `/drives/${encodeURIComponent(folder.parentReference.driveId)}/items/${encodeURIComponent(folder.id)}` + : `/me/drive/items/${encodeURIComponent(folder.id)}`; + return list({ + title: folder.name, + placeholder: `Filter ${folder.name}…`, + empty: { title: "Empty folder", icon: Icons.folder }, + status: (items) => `${plural(items.length, "item")} · ${folder.name}`, + actions: [open(folder.webUrl, "Open in OneDrive")], + items: async ({ signal }) => { + const res = await api.get<{ value: DriveItem[] }>( + `${where}/children?$top=200&$orderby=name&$select=${DRIVE_FIELDS}`, + { signal }, + ); + const sorted = [...res.value].sort((a, b) => Number(!!b.folder) - Number(!!a.folder)); + return sorted.map((i) => fileItem(api, i, i.folder ? "Folders" : "Files")); + }, + }); +} + +/** OneDrive's recent files; accounts where the endpoint is gone get the last changed files at the root. */ +async function recent(api: Client, signal: AbortSignal): Promise { + try { + return (await api.get<{ value: DriveItem[] }>("/me/drive/recent?$top=25", { signal })).value; + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + return (await api.get<{ value: DriveItem[] }>( + `/me/drive/root/children?$top=25&$orderby=lastModifiedDateTime desc&$select=${DRIVE_FIELDS}`, + { signal }, + )).value; + } +} + +export default (ctx: Context) => + list({ + placeholder: "Search OneDrive files by name or content…", + empty: { + title: "No file found", + description: "Try another word: OneDrive searches names and content.", + icon: Icons.search, + }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : `${plural(count, "file")} · ${label}`, + actions: [ + open("https://www.microsoft365.com/onedrive", "Open OneDrive", { icon: Icons.cloud }), + signOutAction(ctx.preferences, () => {}), + ], + search: (query, { signal }) => + guarded(ctx.preferences, async (api) => { + const q = query.trim(); + const files = q + ? (await api.get<{ value: DriveItem[] }>(searchPath(q), { signal })).value + : await recent(api, signal); + count = files.length; + label = q ? `“${q}”` : "recent"; + return files.map((f) => fileItem(api, f, q ? undefined : "Recent")); + }), + }); diff --git a/extensions/microsoft-365/commands/todo.ts b/extensions/microsoft-365/commands/todo.ts new file mode 100644 index 0000000..eeea302 --- /dev/null +++ b/extensions/microsoft-365/commands/todo.ts @@ -0,0 +1,78 @@ +import { action, type Context, Icons, type Item, list, open, updateCommandMetadata } from "@backslash/api"; +import { client, guarded, signOutAction } from "../api.ts"; +import { AuthError, dueBucket, dueOf, plural, sortTasks, type TodoList, todoUrl } from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; +import { isToday, loadLists, loadTasks, taskForm, taskItem } from "../todo.ts"; + +const VIEWS = [ + { value: "today", title: "Today" }, + { value: "week", title: "Next 7 Days" }, + { value: "all", title: "All" }, +]; + +let summary = ""; +let lists: TodoList[] = []; + +export default async (ctx: Context) => { + // The lists become chips after the date views; without a sign-in there are none yet. + try { + lists = await loadLists(await client(ctx.preferences), ctx.signal); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + } + const tenant = ctx.preferences.tenant; + return list({ + placeholder: "Filter tasks by title, list or category…", + filter: { + options: [ + ...VIEWS, + ...lists.filter((l) => l.wellknownListName !== "flaggedEmails").map((l) => ({ + value: `list:${l.id}`, + title: l.displayName, + })), + ], + }, + empty: { title: "Nothing to do", description: "Press Ctrl+N to add a task.", icon: Icons.squareCheck }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async () => taskForm(await client(ctx.preferences), lists, { title: ctx.query }), { + icon: Icons.plus, + shortcut: "n", + }), + open(todoUrl(tenant), "Open Microsoft To Do", { icon: Icons.squareCheck }), + signOutAction(ctx.preferences, notifyChanged), + ], + items: ({ signal, filter }) => + live(() => + guarded(ctx.preferences, async (api): Promise => { + lists = await loadLists(api, signal); + const tasks = sortTasks(await loadTasks(api, lists, signal)); + const now = new Date(); + const today = tasks.filter((t) => dueBucket(dueOf(t), now) === "Today").length; + const overdue = tasks.filter((t) => dueBucket(dueOf(t), now) === "Overdue").length; + updateCommandMetadata({ + subtitle: [today ? `${plural(today, "task")} today` : "", overdue ? `${overdue} overdue` : ""] + .filter(Boolean).join(" · ") || null, + }).catch(() => {}); + + const view = filter ?? "today"; + const listId = view.startsWith("list:") ? view.slice(5) : undefined; + const known = listId ? lists.find((l) => l.id === listId) : undefined; + const shown = tasks.filter((t) => { + if (listId) return !known || t.listId === listId; + if (view === "all") return true; + if (view === "week") { + const b = dueBucket(dueOf(t), now); + return b !== "Later" && b !== "No Due Date"; + } + return isToday(t, now); + }); + summary = [ + plural(shown.length, "task"), + overdue ? `${overdue} overdue` : "", + known ? known.displayName : plural(lists.length, "list"), + ].filter(Boolean).join(" · "); + return shown.map((t) => taskItem(api, t, lists, { tenant, now })); + }), signal), + }); +}; diff --git a/extensions/microsoft-365/lib.ts b/extensions/microsoft-365/lib.ts new file mode 100644 index 0000000..50d1fd4 --- /dev/null +++ b/extensions/microsoft-365/lib.ts @@ -0,0 +1,766 @@ +// Microsoft Graph: the sign-in, response types and pure logic (mail, To Do, OneDrive, calendar). + +import { type Color, Icons, meta, type Metadata, type OAuthProvider } from "@backslash/api"; + +export const GRAPH = "https://graph.microsoft.com/v1.0"; +export const ENTRA_URL = "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"; +export const ENTRA_DOCS = "https://learn.microsoft.com/entra/identity-platform/quickstart-register-app"; +export const REDIRECT_URI = "http://127.0.0.1/callback"; +export const SCOPES = [ + "offline_access", + "User.Read", + "Mail.ReadWrite", + "Tasks.ReadWrite", + "Files.Read", + "Calendars.Read", +]; + +/** The app isn't registered yet, or Microsoft refused the token: lists show how to connect. */ +export class AuthError extends Error {} + +/** The user's own Entra app registration (there is no shared Backslash app). */ +export function provider(prefs: Record): OAuthProvider { + const clientId = String(prefs.clientId ?? "").trim(); + if (!clientId) throw new AuthError("Connect your Microsoft account"); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(clientId)) { + throw new AuthError("The Application (client) ID is a GUID like 1b2c3d4e-…: check the preference"); + } + const tenant = String(prefs.tenant ?? "").trim() || "common"; + if (!/^[\w.-]+$/.test(tenant)) throw new AuthError(`“${tenant}” is not a tenant: use common, or your tenant ID`); + const base = `https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0`; + return { + id: "microsoft", + title: "Microsoft 365", + authorizeUrl: `${base}/authorize`, + tokenUrl: `${base}/token`, + clientId, + scopes: SCOPES, + params: { prompt: "select_account" }, + }; +} + +/** A Graph error body: `{ error: { code, message } }`. */ +export function graphError(status: number, text: string, retryAfter?: string | null): Error { + let code = ""; + let message = ""; + try { + const body = JSON.parse(text); + code = body?.error?.code ?? ""; + message = body?.error?.message ?? body?.error_description ?? ""; + } catch { + message = text.slice(0, 160); + } + if (code === "MailboxNotEnabledForRESTAPI" || (code === "ResourceNotFound" && /mailbox/i.test(message))) { + return new Error("This account has no Exchange Online mailbox"); + } + if (status === 401) return new AuthError("Microsoft signed you out"); + if (status === 403) { + return new Error( + `Microsoft refused it${message ? `: ${message}` : ""}. Sign out and in again to grant the permissions`, + ); + } + if (status === 404) return new Error("Not found: it may have been moved or deleted"); + if (status === 429) { + const wait = Number(retryAfter); + return new Error(`Microsoft Graph is throttling: try again in ${wait > 0 ? `${wait} s` : "a minute"}`); + } + return new Error(`Microsoft Graph answered ${status}${message ? `: ${message}` : code ? `: ${code}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Shared formatting + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** "now", "5 min", "3 h", "yesterday", "Mon", "12 Oct", "12 Oct 2024". */ +export function shortAgo(date: Date, now = new Date()): string { + const s = (now.getTime() - date.getTime()) / 1000; + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)} min`; + if (dayDiff(date, now) === 0) return `${Math.floor(s / 3600)} h`; + if (dayDiff(date, now) === -1) return "yesterday"; + if (dayDiff(date, now) > -7) return date.toLocaleDateString("en-GB", { weekday: "short" }); + return date.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }), + }); +} + +export function fullDate(date: Date): string { + return date.toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export const clock = (date: Date) => date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + +/** Whole days from `now`'s day to `date`'s day (negative in the past). */ +export function dayDiff(date: Date, now = new Date()): number { + return Math.round((startOfDay(date).getTime() - startOfDay(now).getTime()) / 86_400_000); +} + +/** Plain text of an escaped preview, safe inside markdown. */ +export function mdEscape(text: string): string { + return text.replace(/([\\`*_[\]#<>|])/g, "\\$1"); +} + +// --------------------------------------------------------------------------- +// Outlook mail + +export interface Address { + emailAddress?: { name?: string; address?: string }; +} + +export interface Message { + id: string; + subject?: string | null; + bodyPreview?: string; + from?: Address; + toRecipients?: Address[]; + ccRecipients?: Address[]; + receivedDateTime: string; + webLink?: string; + inferenceClassification?: "focused" | "other"; + flag?: { flagStatus?: "notFlagged" | "flagged" | "complete" }; + importance?: "low" | "normal" | "high"; + hasAttachments?: boolean; + isRead?: boolean; +} + +export const MAIL_FIELDS = + "id,subject,bodyPreview,from,toRecipients,ccRecipients,receivedDateTime,webLink,inferenceClassification,flag,importance,hasAttachments,isRead"; + +/** + * Unread inbox messages, newest first. Graph wants the `$orderby` property in + * `$filter` too, and first, or it refuses the query as too complex. + */ +export function unreadPath(top = 50): string { + const q = new URLSearchParams({ + $filter: "receivedDateTime ge 1900-01-01T00:00:00Z and isRead eq false", + $orderby: "receivedDateTime desc", + $top: String(top), + $select: MAIL_FIELDS, + }); + return `/me/mailFolders/inbox/messages?${q}`; +} + +/** Focused before Other, each newest first. */ +export function sortMail(messages: Message[]): Message[] { + const rank = (m: Message) => (m.inferenceClassification === "other" ? 1 : 0); + return [...messages].sort((a, b) => + rank(a) - rank(b) || Date.parse(b.receivedDateTime) - Date.parse(a.receivedDateTime) + ); +} + +export const mailSection = (m: Message) => (m.inferenceClassification === "other" ? "Other" : "Focused"); + +export function senderOf(m: Message): { name: string; address: string } { + const e = m.from?.emailAddress ?? {}; + return { name: (e.name || e.address || "Unknown sender").trim(), address: (e.address ?? "").trim() }; +} + +const people = (list?: Address[]) => + (list ?? []).map((a) => a.emailAddress?.name || a.emailAddress?.address || "").filter(Boolean); + +/** The message's page in Outlook on the web (Graph's own link when it has one). */ +export function mailUrl(m: Message): string { + return m.webLink || `https://outlook.office.com/mail/inbox/id/${encodeURIComponent(m.id)}`; +} + +export function mailMarkdown(m: Message): string { + const subject = m.subject?.trim() || "(no subject)"; + const preview = (m.bodyPreview ?? "").replace(/\r/g, "").trim(); + return [`### ${mdEscape(subject)}`, "", preview ? mdEscape(preview) : "*No preview*"].join("\n"); +} + +export function mailMetadata(m: Message): Metadata[] { + const from = senderOf(m); + const to = people(m.toRecipients); + const cc = people(m.ccRecipients); + const lines: Metadata[] = [ + meta.person( + "From", + from.address && from.address !== from.name ? `${from.name} <${from.address}>` : from.name, + from.name, + ), + ...(to.length ? [meta.text("To", to.slice(0, 4).join(", ") + (to.length > 4 ? ` +${to.length - 4}` : ""))] : []), + ...(cc.length ? [meta.text("Cc", cc.slice(0, 4).join(", ") + (cc.length > 4 ? ` +${cc.length - 4}` : ""))] : []), + meta.text("Received", fullDate(new Date(m.receivedDateTime)), { icon: Icons.clock }), + meta.separator(), + ]; + const tags: { text: string; color?: Color }[] = [ + { text: m.inferenceClassification === "other" ? "Other" : "Focused", color: "blue" }, + ]; + if (m.importance === "high") tags.push({ text: "Important", color: "red" }); + if (m.flag?.flagStatus === "flagged") tags.push({ text: "Flagged", color: "orange" }); + if (m.hasAttachments) tags.push({ text: "Attachments", color: "gray" }); + lines.push(meta.tags("Labels", tags)); + return lines; +} + +// --------------------------------------------------------------------------- +// Microsoft To Do + +export interface TodoList { + id: string; + displayName: string; + wellknownListName?: "none" | "defaultList" | "flaggedEmails" | "unknownFutureValue"; + isShared?: boolean; +} + +export interface TodoTask { + id: string; + title: string; + status: "notStarted" | "inProgress" | "completed" | "waitingOnOthers" | "deferred"; + importance: "low" | "normal" | "high"; + dueDateTime?: { dateTime: string; timeZone: string } | null; + reminderDateTime?: { dateTime: string; timeZone: string } | null; + isReminderOn?: boolean; + body?: { content?: string; contentType?: string }; + createdDateTime?: string; + lastModifiedDateTime?: string; + categories?: string[]; + checklistItems?: { id: string; displayName: string; isChecked: boolean }[]; + linkedResources?: { webUrl?: string; applicationName?: string; displayName?: string }[]; + /** Set by us: the list the task came from. */ + listId?: string; +} + +/** The default "Tasks" list first, then "Flagged email", then the others by name. */ +export function sortLists(lists: TodoList[]): TodoList[] { + const rank = (l: TodoList) => + l.wellknownListName === "defaultList" ? 0 : l.wellknownListName === "flaggedEmails" ? 2 : 1; + return [...lists].sort((a, b) => rank(a) - rank(b) || a.displayName.localeCompare(b.displayName)); +} + +/** + * To Do keeps a due *date*: midnight in some time zone. The calendar day is + * what the user picked, so read it from the string, never through a time zone. + */ +export function dueOf(task: Pick): Date | undefined { + const m = task.dueDateTime?.dateTime?.match(/^(\d{4})-(\d{2})-(\d{2})/); + return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : undefined; +} + +/** A due date as To Do writes it. */ +export function graphDue(date: Date): { dateTime: string; timeZone: string } { + const p = (n: number) => String(n).padStart(2, "0"); + return { dateTime: `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}T00:00:00`, timeZone: "UTC" }; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"] as const; +export type Bucket = typeof BUCKETS[number]; + +export function dueBucket(due: Date | undefined, now = new Date()): Bucket { + if (!due) return "No Due Date"; + const d = dayDiff(due, now); + if (d < 0) return "Overdue"; + if (d === 0) return "Today"; + if (d === 1) return "Tomorrow"; + if (d < 7) return "This Week"; + return "Later"; +} + +/** "today", "tomorrow", "Fri", "3 days ago", "12 Nov". */ +export function shortDue(due: Date, now = new Date()): string { + const d = dayDiff(due, now); + if (d === 0) return "today"; + if (d === 1) return "tomorrow"; + if (d === -1) return "yesterday"; + if (d < 0 && d > -7) return `${-d} days ago`; + if (d > 1 && d < 7) return due.toLocaleDateString("en-GB", { weekday: "short" }); + return due.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + ...(due.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }), + }); +} + +/** Overdue first, then by due date (undated last), important first within a day. */ +export function sortTasks(tasks: TodoTask[]): TodoTask[] { + const imp = { high: 0, normal: 1, low: 2 }; + return [...tasks].sort((a, b) => { + const da = dueOf(a)?.getTime() ?? Infinity; + const db = dueOf(b)?.getTime() ?? Infinity; + if (da !== db) return da - db; + return imp[a.importance] - imp[b.importance] || + Date.parse(b.createdDateTime ?? "") - Date.parse(a.createdDateTime ?? "") || 0; + }); +} + +export const IMPORTANCE = [ + { value: "high", title: "High", color: "red" as Color }, + { value: "normal", title: "Normal", color: undefined }, + { value: "low", title: "Low", color: "gray" as Color }, +] as const; + +/** Notes are HTML for tasks made in Outlook, text for those made in To Do. */ +export function taskNotes(task: TodoTask): string { + const raw = task.body?.content ?? ""; + if (task.body?.contentType?.toLowerCase() !== "html") return raw.trim(); + return raw + .replace(/<(br|\/p|\/div|\/li)\s*\/?>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function taskMarkdown(task: TodoTask): string { + const lines = [`### ${mdEscape(task.title || "Untitled")}`]; + const notes = taskNotes(task); + if (notes) lines.push("", notes); + const steps = task.checklistItems ?? []; + if (steps.length) { + lines.push( + "", + "**Steps**", + "", + ...steps.map((s) => `- ${s.isChecked ? "~~" : ""}${mdEscape(s.displayName)}${s.isChecked ? "~~" : ""}`), + ); + } + return lines.join("\n"); +} + +export function taskMetadata(task: TodoTask, list: TodoList | undefined, now = new Date()): Metadata[] { + const due = dueOf(task); + const bucket = dueBucket(due, now); + const imp = IMPORTANCE.find((i) => i.value === task.importance) ?? IMPORTANCE[1]; + const steps = task.checklistItems ?? []; + const done = steps.filter((s) => s.isChecked).length; + const lines: Metadata[] = [ + meta.text("List", list?.displayName ?? "Tasks", { icon: Icons.inbox }), + meta.text( + "Due", + due + ? `${due.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })} (${ + shortDue(due, now) + })` + : "No due date", + { + icon: Icons.calendar, + color: bucket === "Overdue" ? "red" : undefined, + }, + ), + meta.text("Importance", imp.title, { icon: Icons.flag, color: imp.color }), + ]; + if (task.isReminderOn && task.reminderDateTime) { + lines.push( + meta.text("Reminder", fullDate(new Date(`${task.reminderDateTime.dateTime.replace(/\.\d+$/, "")}Z`)), { + icon: Icons.bell, + }), + ); + } + if (steps.length) lines.push(meta.progress("Steps", done / steps.length, `${done} of ${steps.length}`)); + if (task.categories?.length) lines.push(meta.tags("Categories", task.categories)); + const link = task.linkedResources?.find((r) => r.webUrl); + if (link?.webUrl) lines.push(meta.link("Linked", link.displayName || link.applicationName || "Open", link.webUrl)); + if (task.createdDateTime) { + lines.push(meta.separator(), meta.text("Created", fullDate(new Date(task.createdDateTime)), { icon: Icons.clock })); + } + return lines; +} + +/** Microsoft To Do on the web: tasks have no web link in Graph. */ +export function todoUrl(tenant: unknown): string { + return String(tenant ?? "").trim() === "consumers" + ? "https://to-do.live.com/tasks/" + : "https://to-do.office.com/tasks/"; +} + +const WEEKDAYS: Record = { + sunday: 0, + dimanche: 0, + monday: 1, + lundi: 1, + tuesday: 2, + mardi: 2, + wednesday: 3, + mercredi: 3, + thursday: 4, + jeudi: 4, + friday: 5, + vendredi: 5, + saturday: 6, + samedi: 6, +}; + +export interface QuickAdd { + title: string; + due?: Date; + importance?: "high" | "normal" | "low"; + list?: string; +} + +/** + * "Call Bob tomorrow !high #Work" → title, due date, importance, list name. + * Dates: today, tomorrow, a weekday (the next one), "next week", "in 3 days", + * 2026-11-02, 2/11 (day/month), and their French words. + */ +export function parseQuickAdd(text: string, now = new Date()): QuickAdd { + let rest = ` ${text.trim()} `; + const out: QuickAdd = { title: "" }; + const take = (re: RegExp, fn: (m: RegExpMatchArray) => boolean | void) => { + const m = rest.match(re); + if (m && fn(m) !== false) rest = rest.replace(m[0], " "); + }; + const day = (offset: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset); + + take(/\s#("[^"]+"|\S+)/, (m) => { + out.list = m[1].replace(/^"|"$/g, ""); + }); + take(/\s(!high|!important|!!|!low|!normal|!)(?=\s)/i, (m) => { + const v = m[1].toLowerCase(); + out.importance = v === "!low" ? "low" : v === "!normal" ? "normal" : "high"; + }); + take(/\s(today|aujourd'hui)(?=\s)/i, () => { + out.due = day(0); + }); + if (!out.due) take(/\s(tomorrow|tmr|demain)(?=\s)/i, () => void (out.due = day(1))); + if (!out.due) take(/\s(?:after tomorrow|après-demain)(?=\s)/i, () => void (out.due = day(2))); + if (!out.due) { + take(/\s(?:next week|la semaine prochaine|semaine prochaine)(?=\s)/i, () => { + const toMonday = (8 - now.getDay()) % 7 || 7; + out.due = day(toMonday); + }); + } + if (!out.due) { + take(/\s(?:in|dans) (\d{1,3}) (days?|jours?|weeks?|semaines?)(?=\s)/i, (m) => { + const n = Number(m[1]) * (/^(w|s)/i.test(m[2]) ? 7 : 1); + out.due = day(n); + }); + } + if (!out.due) { + take(/\s(\d{4})-(\d{2})-(\d{2})(?=\s)/, (m) => { + out.due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + }); + } + if (!out.due) { + take(/\s(\d{1,2})\/(\d{1,2})(?=\s)/, (m) => { + const d = Number(m[1]), mo = Number(m[2]); + if (d < 1 || d > 31 || mo < 1 || mo > 12) return false; + let date = new Date(now.getFullYear(), mo - 1, d); + if (dayDiff(date, now) < 0) date = new Date(now.getFullYear() + 1, mo - 1, d); + out.due = date; + }); + } + if (!out.due) { + take(new RegExp(`\\s(?:on |next )?(${Object.keys(WEEKDAYS).join("|")})(?=\\s)`, "i"), (m) => { + const diff = (WEEKDAYS[m[1].toLowerCase()] - now.getDay() + 7) % 7 || 7; + out.due = day(diff); + }); + } + out.title = rest.replace(/\s+/g, " ").trim(); + return out; +} + +/** The list a quick add names, by exact then prefix match, ignoring case and accents. */ +export function findList(lists: TodoList[], name?: string): TodoList | undefined { + if (!name) return undefined; + const norm = (s: string) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().trim(); + const n = norm(name); + return lists.find((l) => norm(l.displayName) === n) ?? lists.find((l) => norm(l.displayName).startsWith(n)); +} + +// --------------------------------------------------------------------------- +// OneDrive + +export interface DriveItem { + id: string; + name: string; + size?: number; + webUrl: string; + lastModifiedDateTime?: string; + createdDateTime?: string; + parentReference?: { driveId?: string; id?: string; path?: string; name?: string }; + file?: { mimeType?: string }; + folder?: { childCount?: number }; + package?: { type?: string }; + lastModifiedBy?: { user?: { displayName?: string; email?: string } }; + createdBy?: { user?: { displayName?: string } }; + shared?: { scope?: string; owner?: { user?: { displayName?: string } } }; + /** Items shared with the user (recent files) carry the real item here. */ + remoteItem?: Omit; +} + +export const DRIVE_FIELDS = + "id,name,size,webUrl,lastModifiedDateTime,createdDateTime,parentReference,file,folder,package,lastModifiedBy,createdBy,shared,remoteItem"; + +/** OData strings double their single quotes; the whole call is then URL-encoded. */ +export function searchPath(query: string, top = 30): string { + const q = query.trim().replace(/'/g, "''"); + return `/me/drive/root/search(q='${encodeURIComponent(q)}')?$top=${top}&$select=${DRIVE_FIELDS}`; +} + +/** A shared item's own fields, with the id/link of where it lives. */ +export function flatten(item: DriveItem): DriveItem { + const remote = item.remoteItem; + if (!remote) return item; + return { + ...remote, + ...item, + remoteItem: undefined, + parentReference: remote.parentReference ?? item.parentReference, + size: item.size ?? remote.size, + file: item.file ?? remote.file, + folder: item.folder ?? remote.folder, + shared: item.shared ?? remote.shared, + }; +} + +/** "/drive/root:/Documents/Reports" → "Documents/Reports"; the root → "OneDrive". */ +export function drivePath(item: DriveItem): string { + const path = item.parentReference?.path; + if (!path) { + return item.parentReference?.name && item.parentReference.name !== "root" ? item.parentReference.name : "OneDrive"; + } + const after = decodeURIComponent(path.replace(/^\/drives?\/[^/]*\/?root:?/, "").replace(/^\/drive\/root:?/, "")); + return after.replace(/^\/+/, "") || "OneDrive"; +} + +export function formatSize(bytes?: number): string { + if (bytes === undefined || bytes === null) return "—"; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let n = bytes / 1024; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`; +} + +export interface Kind { + label: string; + icon: string; + tint?: Color; +} + +/** A glyph and a word for a file, from its extension then its MIME type. */ +export function fileKind(item: Pick): Kind { + if (item.folder || item.package?.type === "oneNote") { + return item.package?.type === "oneNote" + ? { label: "OneNote notebook", icon: Icons.book, tint: "purple" } + : { label: "Folder", icon: Icons.folder, tint: "blue" }; + } + const ext = item.name.includes(".") ? item.name.split(".").pop()!.toLowerCase() : ""; + const mime = item.file?.mimeType ?? ""; + const by: [RegExp, Kind][] = [ + [/^(docx?|odt|rtf|dotx?)$/, { label: "Word document", icon: Icons.text, tint: "blue" }], + [/^(xlsx?|xlsm|ods|csv|tsv)$/, { label: "Spreadsheet", icon: Icons.chartUp, tint: "green" }], + [/^(pptx?|odp|key)$/, { label: "Presentation", icon: Icons.layers, tint: "orange" }], + [/^pdf$/, { label: "PDF", icon: Icons.file, tint: "red" }], + [/^(one|onetoc2)$/, { label: "OneNote", icon: Icons.book, tint: "purple" }], + [/^(png|jpe?g|gif|webp|heic|bmp|svg|tiff?)$/, { label: "Image", icon: Icons.image, tint: "purple" }], + [/^(mp4|mov|mkv|avi|webm|m4v)$/, { label: "Video", icon: Icons.film, tint: "pink" }], + [/^(mp3|wav|flac|m4a|ogg|aac)$/, { label: "Audio", icon: Icons.music, tint: "pink" }], + [/^(zip|7z|rar|tar|gz|tgz|xz|bz2)$/, { label: "Archive", icon: Icons.archive, tint: "yellow" }], + [/^(json|ya?ml|toml|xml)$/, { label: "Data", icon: Icons.braces, tint: "teal" }], + [/^(ts|js|py|rs|go|java|c|cpp|h|sh|rb|php|cs|swift|kt|html|css)$/, { + label: "Code", + icon: Icons.code, + tint: "teal", + }], + [/^(txt|md|log)$/, { label: "Text", icon: Icons.note }], + ]; + for (const [re, kind] of by) if (re.test(ext)) return kind; + if (mime.startsWith("image/")) return { label: "Image", icon: Icons.image, tint: "purple" }; + if (mime.startsWith("video/")) return { label: "Video", icon: Icons.film, tint: "pink" }; + if (mime.startsWith("audio/")) return { label: "Audio", icon: Icons.music, tint: "pink" }; + if (mime.startsWith("text/")) return { label: "Text", icon: Icons.note }; + return { label: ext ? `${ext.toUpperCase()} file` : "File", icon: Icons.file, tint: "gray" }; +} + +export function driveMetadata(item: DriveItem, now = new Date()): Metadata[] { + const kind = fileKind(item); + const lines: Metadata[] = [ + meta.text("Kind", kind.label, { icon: { source: kind.icon, tint: kind.tint } }), + item.folder + ? meta.text("Contains", plural(item.folder.childCount ?? 0, "item")) + : meta.text("Size", formatSize(item.size)), + meta.text("Location", drivePath(item), { icon: Icons.folder }), + ]; + if (item.lastModifiedDateTime) { + const at = new Date(item.lastModifiedDateTime); + lines.push(meta.text("Modified", `${fullDate(at)} (${shortAgo(at, now)})`, { icon: Icons.clock })); + } + const by = item.lastModifiedBy?.user?.displayName; + if (by) lines.push(meta.person("Modified by", by)); + const owner = item.shared?.owner?.user?.displayName; + if (item.shared) { + lines.push( + meta.tags("Sharing", [{ + text: item.shared.scope === "anonymous" ? "Anyone with the link" : "Shared", + color: "blue", + }]), + ); + if (owner) lines.push(meta.person("Owner", owner)); + } + return lines; +} + +// --------------------------------------------------------------------------- +// Calendar + +export interface CalEvent { + id: string; + subject?: string; + start: { dateTime: string; timeZone: string }; + end: { dateTime: string; timeZone: string }; + isAllDay?: boolean; + isCancelled?: boolean; + isOnlineMeeting?: boolean; + onlineMeeting?: { joinUrl?: string } | null; + onlineMeetingUrl?: string | null; + onlineMeetingProvider?: string; + location?: { displayName?: string }; + organizer?: { emailAddress?: { name?: string; address?: string } }; + attendees?: { emailAddress?: { name?: string; address?: string }; status?: { response?: string }; type?: string }[]; + responseStatus?: { response?: string }; + showAs?: "free" | "tentative" | "busy" | "oof" | "workingElsewhere" | "unknown"; + bodyPreview?: string; + webLink?: string; + categories?: string[]; +} + +export const EVENT_FIELDS = + "id,subject,start,end,isAllDay,isCancelled,isOnlineMeeting,onlineMeeting,onlineMeetingUrl,onlineMeetingProvider,location,organizer,attendees,responseStatus,showAs,bodyPreview,webLink,categories"; + +/** The local day `offset` days from now, as the UTC instants Graph wants. */ +export function dayRange(now = new Date(), offset = 0, days = 1): { start: string; end: string } { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset + days); + return { start: start.toISOString(), end: end.toISOString() }; +} + +/** Graph answers in UTC by default, without a zone suffix. */ +export function graphTime(t: { dateTime: string; timeZone?: string }): Date { + const s = t.dateTime.replace(/(\.\d{3})\d*$/, "$1"); + return new Date(/Z|[+-]\d\d:\d\d$/.test(s) ? s : `${s}Z`); +} + +/** All-day events span local calendar days: read the date only. */ +export function allDayDate(t: { dateTime: string }): Date { + const m = t.dateTime.match(/^(\d{4})-(\d{2})-(\d{2})/)!; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); +} + +const MEETING_LINKS: [RegExp, string][] = [ + [/https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s<>"')\]]+/i, "Microsoft Teams"], + [/https:\/\/teams\.live\.com\/meet\/[^\s<>"')\]]+/i, "Microsoft Teams"], + [/https:\/\/(?:[\w-]+\.)*zoom\.us\/(?:j|my|w|s)\/[^\s<>"')\]]+/i, "Zoom"], + [/https:\/\/meet\.google\.com\/[a-z]{3}-[a-z]{4}-[a-z]{3}[^\s<>"')\]]*/i, "Google Meet"], + [/https:\/\/[\w-]+\.webex\.com\/[^\s<>"')\]]+/i, "Webex"], +]; + +/** The meeting to join: Teams' own link, else one found in the location or the text. */ +export function joinLink(e: CalEvent): { url: string; service: string } | undefined { + const teams = e.onlineMeeting?.joinUrl || e.onlineMeetingUrl; + if (teams) { + const service = /zoom/i.test(e.onlineMeetingProvider ?? "") ? "Zoom" : "Microsoft Teams"; + return { url: teams, service }; + } + for (const text of [e.location?.displayName ?? "", e.bodyPreview ?? ""]) { + for (const [re, service] of MEETING_LINKS) { + const m = text.match(re); + if (m) return { url: m[0], service }; + } + } + return undefined; +} + +export function eventSection(e: CalEvent, now = new Date()): "All Day" | "Now" | "Upcoming" | "Earlier" { + if (e.isAllDay) return "All Day"; + const start = graphTime(e.start).getTime(); + const end = graphTime(e.end).getTime(); + if (start <= now.getTime() && now.getTime() < end) return "Now"; + return end <= now.getTime() ? "Earlier" : "Upcoming"; +} + +export function formatDuration(ms: number): string { + const min = Math.round(ms / 60_000); + if (min < 60) return `${min} min`; + const h = Math.floor(min / 60); + return min % 60 ? `${h} h ${min % 60}` : `${h} h`; +} + +/** "in 25 min", "in 2 h", "ends in 10 min", "ended". */ +export function eventRelative(e: CalEvent, now = new Date()): string { + if (e.isAllDay) return "all day"; + const start = graphTime(e.start).getTime(); + const end = graphTime(e.end).getTime(); + const t = now.getTime(); + if (t < start) return `in ${formatDuration(start - t)}`; + if (t < end) return `ends in ${formatDuration(end - t)}`; + return "ended"; +} + +export function eventTime(e: CalEvent): string { + if (e.isAllDay) return "All day"; + return `${clock(graphTime(e.start))} – ${clock(graphTime(e.end))}`; +} + +/** "Next: Standup at 10:00", "Weekly sync now", or a count. */ +export function calendarSubtitle(events: CalEvent[], now = new Date()): string | null { + const live = events.filter((e) => !e.isCancelled && !e.isAllDay); + const current = live.find((e) => eventSection(e, now) === "Now"); + if (current) return `${current.subject || "Event"} now`; + const next = live.find((e) => eventSection(e, now) === "Upcoming"); + if (next) return `Next: ${next.subject || "event"} at ${clock(graphTime(next.start))}`; + return events.length ? plural(events.length, "event") : null; +} + +export const RESPONSES: Record = { + accepted: { text: "Accepted", color: "green" }, + tentativelyAccepted: { text: "Tentative", color: "yellow" }, + declined: { text: "Declined", color: "red" }, + notResponded: { text: "Not answered", color: "gray" }, + organizer: { text: "Organizer", color: "blue" }, +}; + +export function eventMarkdown(e: CalEvent): string { + const lines = [`### ${mdEscape(e.subject || "(no title)")}`]; + if (e.isCancelled) lines.push("", "**Canceled**"); + const text = (e.bodyPreview ?? "") + .replace(/_{5,}[\s\S]*$/, "") // Teams' join block + .replace(/\r/g, "") + .trim(); + if (text) lines.push("", mdEscape(text)); + return lines.join("\n"); +} + +export function eventMetadata(e: CalEvent): Metadata[] { + const lines: Metadata[] = [meta.text("When", eventTime(e), { icon: Icons.clock })]; + if (!e.isAllDay) { + lines.push(meta.text("Duration", formatDuration(graphTime(e.end).getTime() - graphTime(e.start).getTime()))); + } + const place = e.location?.displayName?.trim(); + if (place && !/^https?:\/\//.test(place)) lines.push(meta.text("Location", place, { icon: Icons.pin })); + const join = joinLink(e); + if (join) lines.push(meta.link("Meeting", join.service, join.url)); + const organizer = e.organizer?.emailAddress?.name || e.organizer?.emailAddress?.address; + if (organizer) lines.push(meta.person("Organizer", organizer)); + const people = (e.attendees ?? []).filter((a) => a.type !== "resource"); + if (people.length) { + const yes = people.filter((a) => a.status?.response === "accepted").length; + lines.push(meta.text("Attendees", `${people.length} invited · ${yes} accepted`, { icon: Icons.person })); + } + const response = RESPONSES[e.responseStatus?.response ?? ""]; + if (response) lines.push(meta.tags("Your answer", [response])); + if (e.categories?.length) lines.push(meta.tags("Categories", e.categories)); + return lines; +} diff --git a/extensions/microsoft-365/lib_test.ts b/extensions/microsoft-365/lib_test.ts new file mode 100644 index 0000000..9248fd9 --- /dev/null +++ b/extensions/microsoft-365/lib_test.ts @@ -0,0 +1,273 @@ +import { assertEquals, assertInstanceOf, assertMatch, assertThrows } from "@std/assert"; +import { + AuthError, + calendarSubtitle, + type CalEvent, + dayDiff, + dayRange, + drivePath, + dueBucket, + dueOf, + eventRelative, + eventSection, + fileKind, + findList, + flatten, + formatSize, + graphDue, + graphError, + graphTime, + joinLink, + mailSection, + type Message, + parseQuickAdd, + provider, + searchPath, + senderOf, + shortDue, + sortLists, + sortMail, + sortTasks, + taskNotes, + type TodoTask, + unreadPath, +} from "./lib.ts"; + +const ID = "1b2c3d4e-0000-4abc-8def-1234567890ab"; + +Deno.test("provider: the user's Entra app, common tenant, Graph scopes", () => { + assertThrows(() => provider({}), AuthError, "Connect"); + assertThrows(() => provider({ clientId: "abc" }), AuthError, "GUID"); + assertThrows(() => provider({ clientId: ID, tenant: "a/b" }), AuthError, "tenant"); + const p = provider({ clientId: ` ${ID} ` }); + assertEquals(p.clientId, ID); + assertEquals(p.authorizeUrl, "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"); + assertEquals(p.tokenUrl, "https://login.microsoftonline.com/common/oauth2/v2.0/token"); + assertEquals(p.clientSecret, undefined); + assertEquals(p.scopes?.includes("offline_access"), true); + assertEquals(p.scopes?.includes("Mail.ReadWrite"), true); + assertEquals( + provider({ clientId: ID, tenant: "contoso.onmicrosoft.com" }).tokenUrl.split("/")[3], + "contoso.onmicrosoft.com", + ); +}); + +Deno.test("graphError: sign-out, throttling, Graph's message", () => { + assertInstanceOf(graphError(401, ""), AuthError); + assertMatch(graphError(429, "", "12").message, /12 s/); + assertEquals( + graphError(400, JSON.stringify({ error: { code: "BadRequest", message: "Invalid filter clause" } })).message, + "Microsoft Graph answered 400: Invalid filter clause", + ); + assertMatch( + graphError(404, JSON.stringify({ error: { code: "MailboxNotEnabledForRESTAPI", message: "x" } })).message, + /mailbox/, + ); +}); + +const mail = (id: string, at: string, cls: "focused" | "other", extra: Partial = {}): Message => ({ + id, + subject: id, + receivedDateTime: at, + inferenceClassification: cls, + from: { emailAddress: { name: "Ada Lovelace", address: "ada@example.com" } }, + ...extra, +}); + +Deno.test("mail: unread query puts the orderby property first in the filter", () => { + const q = new URL(`https://x${unreadPath(10)}`).searchParams; + assertEquals(q.get("$filter"), "receivedDateTime ge 1900-01-01T00:00:00Z and isRead eq false"); + assertEquals(q.get("$orderby"), "receivedDateTime desc"); + assertEquals(q.get("$top"), "10"); +}); + +Deno.test("mail: Focused first, each newest first", () => { + const sorted = sortMail([ + mail("o-new", "2026-10-28T10:00:00Z", "other"), + mail("f-old", "2026-10-27T10:00:00Z", "focused"), + mail("f-new", "2026-10-28T09:00:00Z", "focused"), + ]); + assertEquals(sorted.map((m) => m.id), ["f-new", "f-old", "o-new"]); + assertEquals(mailSection(sorted[2]), "Other"); + assertEquals(senderOf(sorted[0]), { name: "Ada Lovelace", address: "ada@example.com" }); + assertEquals(senderOf({ ...sorted[0], from: undefined }).name, "Unknown sender"); +}); + +const NOW = new Date(2026, 9, 28, 9, 30); // Wednesday 28 October 2026 + +const task = (id: string, due?: string, extra: Partial = {}): TodoTask => ({ + id, + title: id, + status: "notStarted", + importance: "normal", + dueDateTime: due ? { dateTime: `${due}T00:00:00.0000000`, timeZone: "UTC" } : null, + ...extra, +}); + +Deno.test("to do: due dates are calendar days, whatever the zone", () => { + assertEquals(dueOf(task("a", "2026-10-28")), new Date(2026, 9, 28)); + assertEquals(dueOf(task("a")), undefined); + assertEquals(graphDue(new Date(2026, 0, 5)), { dateTime: "2026-01-05T00:00:00", timeZone: "UTC" }); + assertEquals(dueBucket(new Date(2026, 9, 27), NOW), "Overdue"); + assertEquals(dueBucket(new Date(2026, 9, 28), NOW), "Today"); + assertEquals(dueBucket(new Date(2026, 9, 29), NOW), "Tomorrow"); + assertEquals(dueBucket(new Date(2026, 10, 2), NOW), "This Week"); + assertEquals(dueBucket(new Date(2026, 10, 20), NOW), "Later"); + assertEquals(dueBucket(undefined, NOW), "No Due Date"); + assertEquals(shortDue(new Date(2026, 9, 30), NOW), "Fri"); + assertEquals(shortDue(new Date(2026, 9, 25), NOW), "3 days ago"); + assertEquals(shortDue(new Date(2027, 0, 3), NOW), "3 Jan 2027"); +}); + +Deno.test("to do: sorted by due date, important first, undated last", () => { + const sorted = sortTasks([ + task("undated"), + task("later", "2026-11-03"), + task("today", "2026-10-28"), + task("today-high", "2026-10-28", { importance: "high" }), + ]); + assertEquals(sorted.map((t) => t.id), ["today-high", "today", "later", "undated"]); +}); + +Deno.test("to do: lists, default first and flagged email last", () => { + const lists = sortLists([ + { id: "f", displayName: "Flagged email", wellknownListName: "flaggedEmails" }, + { id: "w", displayName: "Work" }, + { id: "t", displayName: "Tasks", wellknownListName: "defaultList" }, + { id: "c", displayName: "Courses" }, + ]); + assertEquals(lists.map((l) => l.id), ["t", "c", "w", "f"]); + assertEquals(findList(lists, "work")?.id, "w"); + assertEquals(findList(lists, "cour")?.id, "c"); + assertEquals(findList(lists, "nope"), undefined); +}); + +Deno.test("to do: HTML notes from Outlook become text", () => { + assertEquals( + taskNotes(task("a", undefined, { + body: { contentType: "html", content: "

Bring the & slides

Room 4

" }, + })), + "Bring the & slides\nRoom 4", + ); + assertEquals(taskNotes(task("a", undefined, { body: { contentType: "text", content: " plain " } })), "plain"); +}); + +Deno.test("quick add: dates, importance and list", () => { + const q = parseQuickAdd("Call Bob tomorrow !high #Work", NOW); + assertEquals(q, { title: "Call Bob", due: new Date(2026, 9, 29), importance: "high", list: "Work" }); + assertEquals(parseQuickAdd("Rapport demain", NOW).due, new Date(2026, 9, 29)); + assertEquals(parseQuickAdd("Pay rent friday", NOW), { title: "Pay rent", due: new Date(2026, 9, 30) }); + assertEquals(parseQuickAdd("Standup wednesday", NOW).due, new Date(2026, 10, 4)); // the next one, not today + assertEquals(parseQuickAdd("Review in 3 days", NOW).due, new Date(2026, 9, 31)); + assertEquals(parseQuickAdd("Taxes 2026-11-15 !", NOW), { + title: "Taxes", + due: new Date(2026, 10, 15), + importance: "high", + }); + assertEquals(parseQuickAdd("Birthday 2/1", NOW).due, new Date(2027, 0, 2)); + assertEquals(parseQuickAdd('Plan next week #"Side projects"', NOW), { + title: "Plan", + due: new Date(2026, 10, 2), + list: "Side projects", + }); + assertEquals(parseQuickAdd("Read about sunday markets", NOW).title, "Read about markets"); + assertEquals(parseQuickAdd("Buy milk", NOW), { title: "Buy milk" }); +}); + +Deno.test("onedrive: search path escapes quotes, paths and sizes read well", () => { + assertEquals(searchPath("Bob's plan").split("?")[0], "/me/drive/root/search(q='Bob''s%20plan')"); + assertEquals( + drivePath({ id: "1", name: "a", webUrl: "", parentReference: { path: "/drive/root:/Documents/Q3%20Reports" } }), + "Documents/Q3 Reports", + ); + assertEquals(drivePath({ id: "1", name: "a", webUrl: "", parentReference: { path: "/drive/root:" } }), "OneDrive"); + assertEquals( + drivePath({ id: "1", name: "a", webUrl: "", parentReference: { path: "/drives/b!x/root:/Shared" } }), + "Shared", + ); + assertEquals(drivePath({ id: "1", name: "a", webUrl: "" }), "OneDrive"); + assertEquals(formatSize(undefined), "—"); + assertEquals(formatSize(512), "512 B"); + assertEquals(formatSize(1536), "1.5 KB"); + assertEquals(formatSize(250 * 1024 * 1024), "250 MB"); +}); + +Deno.test("onedrive: kinds by extension, MIME type, folder", () => { + assertEquals(fileKind({ name: "Budget.xlsx", file: {} }).label, "Spreadsheet"); + assertEquals(fileKind({ name: "Deck.PPTX", file: {} }).label, "Presentation"); + assertEquals(fileKind({ name: "scan", file: { mimeType: "image/jpeg" } }).label, "Image"); + assertEquals(fileKind({ name: "Docs", folder: {} }).label, "Folder"); + assertEquals(fileKind({ name: "Notes", package: { type: "oneNote" } }).label, "OneNote notebook"); + assertEquals(fileKind({ name: "data.bin", file: {} }).label, "BIN file"); +}); + +Deno.test("onedrive: shared recent items use the remote item's details", () => { + const item = flatten({ + id: "local", + name: "Plan.docx", + webUrl: "https://contoso-my.sharepoint.com/plan", + remoteItem: { + id: "remote", + name: "Plan.docx", + webUrl: "https://contoso-my.sharepoint.com/plan", + size: 2048, + file: { mimeType: "application/msword" }, + parentReference: { path: "/drives/b!1/root:/Team" }, + shared: { owner: { user: { displayName: "Grace Hopper" } } }, + }, + }); + assertEquals([item.id, item.size, drivePath(item), item.shared?.owner?.user?.displayName], [ + "local", + 2048, + "Team", + "Grace Hopper", + ]); +}); + +const event = (subject: string, start: string, end: string, extra: Partial = {}): CalEvent => ({ + id: subject, + subject, + start: { dateTime: start, timeZone: "UTC" }, + end: { dateTime: end, timeZone: "UTC" }, + ...extra, +}); + +Deno.test("calendar: UTC times without a suffix, the local day's range", () => { + assertEquals(graphTime({ dateTime: "2026-10-28T09:00:00.0000000" }).toISOString(), "2026-10-28T09:00:00.000Z"); + const { start, end } = dayRange(NOW); + assertEquals(new Date(start).getTime(), new Date(2026, 9, 28).getTime()); + assertEquals(dayDiff(new Date(start), new Date(end)), -1); +}); + +Deno.test("calendar: sections, relative times and the subtitle", () => { + const now = new Date("2026-10-28T10:15:00Z"); + const standup = event("Standup", "2026-10-28T10:00:00.0000000", "2026-10-28T10:30:00.0000000"); + const review = event("Design review", "2026-10-28T13:00:00.0000000", "2026-10-28T14:00:00.0000000"); + const early = event("Breakfast", "2026-10-28T07:00:00.0000000", "2026-10-28T08:00:00.0000000"); + assertEquals(eventSection(standup, now), "Now"); + assertEquals(eventSection(review, now), "Upcoming"); + assertEquals(eventSection(early, now), "Earlier"); + assertEquals(eventSection({ ...review, isAllDay: true }, now), "All Day"); + assertEquals(eventRelative(standup, now), "ends in 15 min"); + assertEquals(eventRelative(review, now), "in 2 h 45"); + assertEquals(calendarSubtitle([early, standup, review], now), "Standup now"); + assertMatch(calendarSubtitle([early, review], now)!, /^Next: Design review at /); + assertEquals(calendarSubtitle([], now), null); +}); + +Deno.test("calendar: Teams' join link first, else one in the location or the text", () => { + const base = event("Sync", "2026-10-28T10:00:00", "2026-10-28T10:30:00"); + assertEquals( + joinLink({ ...base, onlineMeeting: { joinUrl: "https://teams.microsoft.com/l/meetup-join/19%3ameeting_x" } }), + { url: "https://teams.microsoft.com/l/meetup-join/19%3ameeting_x", service: "Microsoft Teams" }, + ); + assertEquals( + joinLink({ ...base, location: { displayName: "https://us02web.zoom.us/j/123456789?pwd=abc" } })?.service, + "Zoom", + ); + assertEquals( + joinLink({ ...base, bodyPreview: "Join: https://meet.google.com/abc-defg-hij" })?.url, + "https://meet.google.com/abc-defg-hij", + ); + assertEquals(joinLink({ ...base, location: { displayName: "Room 4" } }), undefined); +}); diff --git a/extensions/microsoft-365/live.ts b/extensions/microsoft-365/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/microsoft-365/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/microsoft-365/todo.ts b/extensions/microsoft-365/todo.ts new file mode 100644 index 0000000..47fc08b --- /dev/null +++ b/extensions/microsoft-365/todo.ts @@ -0,0 +1,274 @@ +// Microsoft To Do: loading, 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 { all, type Client } from "./api.ts"; +import { + type Bucket, + dueBucket, + dueOf, + findList, + graphDue, + IMPORTANCE, + parseQuickAdd, + shortDue, + sortLists, + taskMarkdown, + taskMetadata, + taskNotes, + type TodoList, + type TodoTask, + todoUrl, +} from "./lib.ts"; +import { notifyChanged } from "./live.ts"; + +/** 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; +} + +export async function loadLists(api: Client, signal?: AbortSignal): Promise { + return sortLists(await all(api, "/me/todo/lists?$top=100", signal)); +} + +/** Every open task of every list, each tagged with its list. */ +export async function loadTasks(api: Client, lists: TodoList[], signal?: AbortSignal): Promise { + const q = new URLSearchParams({ $filter: "status ne 'completed'", $top: "100", $expand: "checklistItems" }); + const per = await pool( + lists, + 4, + async (l) => + (await all(api, `/me/todo/lists/${encodeURIComponent(l.id)}/tasks?${q}`, signal, 300)) + .map((t) => ({ ...t, listId: l.id })), + ); + return per.flat(); +} + +const taskPath = (t: TodoTask) => + `/me/todo/lists/${encodeURIComponent(t.listId ?? "")}/tasks/${encodeURIComponent(t.id)}`; + +async function edit(api: Client, task: TodoTask, body: unknown, message: string) { + await api.send("PATCH", taskPath(task), body); + notifyChanged(); + await toast(message, { style: "success" }); +} + +const day = (offset: number) => { + const d = new Date(); + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset); +}; + +export const DUE_CHOICES = [ + { value: "", title: "No due date" }, + { value: "0", title: "Today" }, + { value: "1", title: "Tomorrow" }, + { value: "next-week", title: "Next week" }, +] as const; + +function dueChoice(value: string): Date | undefined { + if (value === "") return undefined; + if (value === "next-week") { + const now = new Date(); + return day((8 - now.getDay()) % 7 || 7); + } + return day(Number(value)); +} + +export function taskActions(api: Client, task: TodoTask, lists: TodoList[], tenant: unknown): Action[] { + const list = lists.find((l) => l.id === task.listId); + return [ + action("Complete Task", () => edit(api, task, { status: "completed" }, `Completed “${task.title}”`), { + icon: Icons.squareCheck, + }), + open(todoUrl(tenant), "Open Microsoft To Do"), + submenu( + "Set Due Date", + DUE_CHOICES.map((c) => + action(c.title, () => { + const due = dueChoice(c.value); + return edit( + api, + task, + { dueDateTime: due ? graphDue(due) : null }, + due ? `Due ${shortDue(due)}` : "No due date", + ); + }, { icon: Icons.calendar }) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ), + submenu( + "Set Importance", + IMPORTANCE.map((i) => + action(i.title, () => edit(api, task, { importance: i.value }, `Importance ${i.title.toLowerCase()}`), { + icon: i.value === "high" ? Icons.starFilled : Icons.star, + }) + ), + { icon: Icons.star, shortcut: "i", section: "Task" }, + ), + ...(lists.length > 1 + ? [submenu( + "Move to List", + lists.filter((l) => l.id !== task.listId && l.wellknownListName !== "flaggedEmails").map((l) => + action(l.displayName, async () => { + // Graph has no move: recreate the task in the other list, then delete the original. + await api.send("POST", `/me/todo/lists/${encodeURIComponent(l.id)}/tasks`, { + title: task.title, + importance: task.importance, + status: task.status, + ...(task.dueDateTime ? { dueDateTime: task.dueDateTime } : {}), + ...(task.body?.content ? { body: task.body } : {}), + ...(task.categories?.length ? { categories: task.categories } : {}), + }); + await api.send("DELETE", taskPath(task)); + notifyChanged(); + await toast(`Moved to ${l.displayName}`, { style: "success" }); + }, { icon: Icons.inbox }) + ), + { icon: Icons.inbox, section: "Task" }, + )] + : []), + action("New Task…", () => taskForm(api, lists, { list: list?.id }), { + icon: Icons.plus, + shortcut: "n", + section: "Task", + }), + copy(task.title, "Copy Title", { shortcut: "period", section: "Copy" }), + ...(taskNotes(task) ? [copy(taskNotes(task), "Copy Notes", { section: "Copy" })] : []), + action("Delete Task", async () => { + await api.send("DELETE", taskPath(task)); + 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: TodoTask, + lists: TodoList[], + options: { tenant: unknown; section?: string; now?: Date }, +): Item { + const now = options.now ?? new Date(); + const list = lists.find((l) => l.id === task.listId); + const due = dueOf(task); + const bucket: Bucket = dueBucket(due, now); + const steps = task.checklistItems ?? []; + const done = steps.filter((s) => s.isChecked).length; + const notes = taskNotes(task).split("\n")[0].trim(); + return { + id: task.id, + title: task.title || "Untitled", + subtitle: notes.slice(0, 80) || undefined, + icon: { + source: task.status === "inProgress" ? Icons.inProgress : Icons.square, + tint: task.importance === "high" ? "red" : undefined, + }, + section: options.section ?? bucket, + keywords: [list?.displayName ?? "", ...(task.categories ?? [])].filter(Boolean), + accessories: [ + ...(task.importance === "high" + ? [{ icon: { source: Icons.starFilled, tint: "red" as const }, tooltip: "Important" }] + : []), + ...(steps.length + ? [{ + icon: Icons.squareCheck, + text: `${done}/${steps.length}`, + color: done === steps.length ? "green" as const : undefined, + tooltip: "Steps", + }] + : []), + ...(task.isReminderOn ? [{ icon: Icons.bell, tooltip: "Reminder" }] : []), + ...(due + ? [{ + icon: Icons.calendar, + text: shortDue(due, now), + color: bucket === "Overdue" ? "red" as const : bucket === "Today" ? "blue" as const : undefined, + tooltip: "Due", + }] + : []), + ...(list && lists.length > 1 ? [{ icon: Icons.inbox, text: list.displayName }] : []), + ], + detail: { markdown: taskMarkdown(task), metadata: taskMetadata(task, list, now) }, + actions: taskActions(api, task, lists, options.tenant), + }; +} + +/** Adds a task from quick-add text ("Call Bob tomorrow !high #Work"). */ +export async function createTask( + api: Client, + lists: TodoList[], + values: { text: string; list?: string; due?: Date; importance?: string; notes?: string }, +): Promise { + const q = parseQuickAdd(values.text); + if (!q.title) throw new Error("Type the task's title"); + const named = findList(lists, q.list); + if (q.list && !named) throw new Error(`No To Do list “${q.list}”`); + const target = named ?? lists.find((l) => l.id === values.list) ?? + lists.find((l) => l.wellknownListName === "defaultList") ?? + lists[0]; + if (!target) throw new Error("No To Do list found"); + const due = q.due ?? values.due; + await api.send("POST", `/me/todo/lists/${encodeURIComponent(target.id)}/tasks`, { + title: q.title, + importance: q.importance ?? values.importance ?? "normal", + ...(due ? { dueDateTime: graphDue(due) } : {}), + ...(values.notes?.trim() ? { body: { content: values.notes.trim(), contentType: "text" } } : {}), + }); + notifyChanged(); + return `Added “${q.title}” to ${target.displayName}${due ? ` · due ${shortDue(due)}` : ""}`; +} + +export function taskForm(api: Client, lists: TodoList[], options: { list?: string; title?: string } = {}) { + const writable = lists.filter((l) => l.wellknownListName !== "flaggedEmails"); + return form({ + title: "New To Do task", + submitTitle: "Add Task", + fields: [ + field.text("title", "Task", { + required: true, + value: options.title ?? "", + placeholder: "Call Bob tomorrow !high: dates and importance are understood", + }), + field.dropdown("list", "List", writable.map((l) => ({ value: l.id, title: l.displayName })), { + value: options.list && writable.some((l) => l.id === options.list) ? options.list : writable[0]?.id, + }), + field.dropdown("due", "Due", DUE_CHOICES.map((c) => ({ value: c.value || "none", title: c.title })), { + value: "none", + }), + field.dropdown("importance", "Importance", IMPORTANCE.map((i) => ({ value: i.value, title: i.title })), { + value: "normal", + }), + field.textArea("notes", "Notes"), + ], + async submit(values) { + const due = values.due === "none" ? undefined : dueChoice(String(values.due)); + const summary = await createTask(api, lists, { + text: String(values.title), + list: String(values.list), + due, + importance: String(values.importance), + notes: String(values.notes ?? ""), + }); + await toast(summary, { style: "success" }); + return pop(); + }, + }); +} + +export const isToday = (t: TodoTask, now = new Date()) => { + const b = dueBucket(dueOf(t), now); + return b === "Today" || b === "Overdue"; +}; diff --git a/extensions/nix-search/api.ts b/extensions/nix-search/api.ts new file mode 100644 index 0000000..707cd30 --- /dev/null +++ b/extensions/nix-search/api.ts @@ -0,0 +1,53 @@ +// search.nixos.org's Elasticsearch backend, with the public credentials its own page uses. + +import { storage } from "@backslash/api"; +import { type Backend, FALLBACK, indexName, parseBackend, scriptUrl } from "./lib.ts"; + +const SITE = "https://search.nixos.org"; +const DAY = 24 * 60 * 60 * 1000; + +/** The index version and channels change a few times a year: read them from the site, once a day. */ +export async function backend(signal?: AbortSignal, force = false): Promise { + const cached = (await storage.get("backend").catch(() => undefined)) as unknown as + | { at: number; value: Backend } + | undefined; + if (!force && cached && Date.now() - cached.at < DAY) return cached.value; + try { + const page = await (await fetch(`${SITE}/packages`, { signal })).text(); + const script = scriptUrl(page); + const found = script ? parseBackend(await (await fetch(script, { signal })).text()) : undefined; + if (found) { + await storage.set("backend", { at: Date.now(), value: found } as never).catch(() => {}); + return found; + } + } catch (e) { + if (signal?.aborted) throw e; + } + return cached?.value ?? FALLBACK; +} + +async function post(b: Backend, channel: string, body: unknown, signal?: AbortSignal): Promise { + try { + return await fetch(`${SITE}/backend/${indexName(b, channel)}/_search`, { + method: "POST", + signal, + headers: { Authorization: `Basic ${btoa(`${b.username}:${b.password}`)}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Error("search.nixos.org can't be reached: check your connection"); + } +} + +/** Runs a query on a channel's index; reads the index name again if it moved. */ +export async function query(channel: string, body: unknown, signal?: AbortSignal): Promise { + let b = await backend(signal); + let res = await post(b, channel, body, signal); + if (res.status === 404 || res.status === 401) { + b = await backend(signal, true); + res = await post(b, channel, body, signal); + } + if (!res.ok) throw new Error(`search.nixos.org answered ${res.status}`); + return await res.json(); +} diff --git a/extensions/nix-search/backslash.toml b/extensions/nix-search/backslash.toml new file mode 100644 index 0000000..b666ae6 --- /dev/null +++ b/extensions/nix-search/backslash.toml @@ -0,0 +1,34 @@ +[extension] +id = "nix-search" +title = "NixOS Search" +description = "Search nixpkgs packages and NixOS, Home Manager and nix-darwin options from search.nixos.org" +version = "1.0.0" +author = "Backslash" +icon = "bs-package" +license = "MIT" + +# search.nixos.org's Elasticsearch backend, with the public read-only +# credentials of the site itself; its index version and channels are read from +# the site's script once a day. +[permissions] +net = ["search.nixos.org"] + +[[commands]] +name = "packages" +title = "Search Nix Packages" +description = "nixpkgs packages with version, license, platforms and install commands" +icon = "bs-package" +keywords = ["nix", "nixos", "nixpkgs", "package", "paquet", "logiciel", "nix-shell", "nix run", "flake", "install"] + +[[commands]] +name = "options" +title = "Search NixOS Options" +description = "NixOS, Home Manager and nix-darwin options with type, default and example" +icon = "bs-settings" +keywords = ["nix", "nixos", "option", "options", "configuration.nix", "home-manager", "nix-darwin", "réglage", "configuration"] + +[[preferences]] +name = "channel" +title = "Channel" +description = "unstable (default) or a release like 26.05; packages can switch with Tab" +default = "unstable" diff --git a/extensions/nix-search/commands/options.ts b/extensions/nix-search/commands/options.ts new file mode 100644 index 0000000..3c6ead1 --- /dev/null +++ b/extensions/nix-search/commands/options.ts @@ -0,0 +1,132 @@ +import { type Context, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { backend, query } from "../api.ts"; +import { + type Channel, + declarationUrl, + hits, + mapOption, + type NixOption, + optionParent, + optionQuery, + optionSnippet, + type OptionSource, + optionUrl, + pickChannel, + shortType, + source, + SOURCES, + total, +} from "../lib.ts"; + +const CONFIG_FILE: Record = { + "nixos": "configuration.nix", + "home-manager": "home.nix", + "darwin": "darwin-configuration.nix", +}; + +function optionItem(o: NixOption, channel: Channel): Item { + const src = source(o.source); + const declared = declarationUrl(o, channel); + const snippet = optionSnippet(o); + const block = (label: string, value?: string) => + value === undefined ? [] : ["", `**${label}**`, "", "```nix", value, "```"]; + return { + id: `${o.source}:${o.name}`, + title: o.name, + subtitle: o.summary.slice(0, 140), + icon: { source: Icons.settings, tint: src.tint }, + accessories: o.type ? [{ text: shortType(o.type, 28), tooltip: o.type }] : [], + detail: { + markdown: [ + o.description || "*No description.*", + ...block("Default", o.default), + ...block("Example", o.example), + "", + `**In ${CONFIG_FILE[o.source]}**`, + "", + "```nix", + snippet, + "```", + ].join("\n"), + metadata: [ + meta.text("Name", o.name, { icon: Icons.settings }), + ...(o.type ? [meta.text("Type", shortType(o.type, 80), { icon: Icons.braces })] : []), + ...(o.default !== undefined && o.default.length < 60 ? [meta.text("Default", o.default)] : []), + meta.tags("Source", [{ text: src.title, color: src.tint }]), + meta.separator(), + ...(declared && o.declared ? [meta.link("Declared in", o.declared, declared)] : []), + meta.link("Channel", `nixos-${channel.id}`, optionUrl(o, channel)), + ], + }, + actions: [ + copy(o.name, "Copy Option Name"), + copy(snippet, "Copy Option Snippet", { icon: Icons.code }), + open(optionUrl(o, channel), "Open on search.nixos.org", { shortcut: "o" }), + ...(declared ? [open(declared, "Open Declaration", { icon: Icons.code, shortcut: "d" })] : []), + ...(optionParent(o.name) + ? [copy(optionParent(o.name), `Copy “${optionParent(o.name)}”`, { icon: Icons.copy, section: "Copy" })] + : []), + ...(o.default !== undefined + ? [copy(o.default, "Copy Default Value", { icon: Icons.copy, section: "Copy" })] + : []), + ...(o.example !== undefined ? [copy(o.example, "Copy Example", { icon: Icons.copy, section: "Copy" })] : []), + ], + }; +} + +let last = ""; + +export default async (ctx: Context) => { + const b = await backend(ctx.signal); + const channel = pickChannel(b.channels, ctx.preferences.channel); + return list({ + placeholder: "Search options… (services.openssh, firewall ports)", + filter: { options: SOURCES.map((s) => ({ value: s.value, title: s.title })), default: "nixos" }, + empty: { + title: "No option found", + description: "Try a part of the name, or Tab to another source.", + icon: Icons.settings, + }, + status: () => last, + async search(text, { filter, signal }) { + const src = source(filter); + if (!text.trim()) { + last = `${src.title} options · nixos-${channel.id}`; + return [{ + id: "hint", + title: `Search ${src.title} options`, + subtitle: "By name or description; Tab switches between NixOS, Home Manager and nix-darwin", + icon: { source: Icons.search, tint: src.tint }, + actions: [ + open(`https://search.nixos.org/options?channel=${channel.id}&source=${src.site}`, "Open search.nixos.org"), + ], + }]; + } + try { + const json = await query(channel.id, optionQuery(text, src.value), signal); + const found = hits(json).map(mapOption); + const n = total(json); + last = `${found.length < n ? `${found.length} of ${n.toLocaleString("en")}` : n} ${src.title} option${ + n === 1 ? "" : "s" + } · nixos-${channel.id}`; + return found.map((o) => optionItem(o, channel)); + } catch (e) { + if (signal.aborted) throw e; + last = "search.nixos.org is unreachable"; + return [{ + id: "error", + title: e instanceof Error ? e.message : String(e), + icon: { source: Icons.wifiOff, tint: "red" }, + actions: [ + open( + `https://search.nixos.org/options?channel=${channel.id}&source=${src.site}&query=${ + encodeURIComponent(text) + }`, + "Search in the Browser", + ), + ], + }]; + } + }, + }); +}; diff --git a/extensions/nix-search/commands/packages.ts b/extensions/nix-search/commands/packages.ts new file mode 100644 index 0000000..8e70fb5 --- /dev/null +++ b/extensions/nix-search/commands/packages.ts @@ -0,0 +1,145 @@ +import { type Context, copy, Icons, type Item, list, meta, open, paste, submenu } from "@backslash/api"; +import { backend, query } from "../api.ts"; +import { + type Channel, + hits, + installCommands, + mapPackage, + type Package, + packageQuery, + packageUrl, + pickChannel, + platformSummary, + sourceUrl, + total, +} from "../lib.ts"; + +function packageItem(p: Package, channel: Channel): Item { + const unfree = p.licenses.some((l) => !l.free); + const { main, others } = platformSummary(p.platforms); + const install = installCommands(p); + const source = p.position ? sourceUrl(p.position, channel.branch) : undefined; + return { + id: p.attr, + title: p.attr, + subtitle: p.description, + icon: { source: Icons.package, tint: unfree ? "orange" : "blue" }, + keywords: [p.pname, ...p.programs], + accessories: [ + ...(unfree + ? [{ tag: "Unfree", color: "orange" as const, tooltip: p.licenses.map((l) => l.name).join(", ") }] + : []), + ...(p.version ? [{ tag: p.version, color: "blue" as const }] : []), + ], + detail: { + markdown: [ + `**${p.pname}** ${p.version}`, + "", + p.description ?? "", + ...(p.longDescription ? ["", p.longDescription] : []), + "", + "```", + `nix-shell -p ${p.attr}`, + "```", + ].join("\n"), + metadata: [ + meta.text("Attribute", p.attr, { icon: Icons.braces }), + ...(p.version ? [meta.text("Version", p.version, { icon: Icons.tag })] : []), + meta.tags( + p.licenses.length > 1 ? "Licenses" : "License", + p.licenses.length + ? p.licenses.map((l) => ({ text: l.spdx ?? l.name, color: l.free ? "green" as const : "orange" as const })) + : [{ text: "Not set", color: "gray" }], + ), + ...(main.length || others ? [meta.tags("Platforms", [...main, ...(others ? [`+${others} more`] : [])])] : []), + ...(p.programs.length ? [meta.tags("Programs", p.programs.slice(0, 8))] : []), + ...(p.outputs.length > 1 ? [meta.tags("Outputs", p.outputs)] : []), + ...(p.maintainers.length + ? [meta.text( + "Maintainers", + p.maintainers.slice(0, 4).map((m) => m.name).join(", ") + + (p.maintainers.length > 4 ? ` +${p.maintainers.length - 4}` : ""), + { icon: Icons.person }, + )] + : [meta.text("Maintainers", "None", { color: "orange" })]), + meta.separator(), + ...(p.homepage ? [meta.link("Homepage", p.homepage.replace(/^https?:\/\/(www\.)?/, ""), p.homepage)] : []), + ...(source && p.position ? [meta.link("Source", p.position.replace(/^pkgs\//, ""), source)] : []), + meta.link("Channel", `nixos-${channel.id}`, packageUrl(p.attr, channel)), + ], + }, + actions: [ + copy(p.attr, "Copy Attribute Name"), + open(packageUrl(p.attr, channel), "Open on search.nixos.org"), + copy(install[0][1], "Copy nix-shell Command", { icon: Icons.terminal, shortcut: "c" }), + ...(install.find(([t]) => t === "nix run") + ? [copy(`nix run nixpkgs#${p.attr}`, "Copy nix run Command", { icon: Icons.play, shortcut: "r" })] + : []), + submenu( + "Copy Install Snippet…", + install.map(([title, text]) => copy(text, title, { icon: Icons.code })), + { icon: Icons.code, shortcut: "i" }, + ), + paste(p.attr, "Paste Attribute Name", { section: "Copy" }), + ...(p.version ? [copy(p.version, "Copy Version", { icon: Icons.tag, section: "Copy" })] : []), + ...(p.homepage + ? [open(p.homepage, "Open Homepage", { icon: Icons.home, section: "Links", shortcut: "h" })] + : []), + ...(source + ? [open(source, "Open Source in Nixpkgs", { icon: Icons.code, section: "Links", shortcut: "o" })] + : []), + ], + }; +} + +let last = ""; + +export default async (ctx: Context) => { + const b = await backend(ctx.signal); + const preferred = pickChannel(b.channels, ctx.preferences.channel); + return list({ + placeholder: "Search nixpkgs packages…", + filter: { + options: b.channels.map((c) => ({ value: c.id, title: c.id === "unstable" ? "Unstable" : c.id })), + default: preferred.id, + }, + empty: { title: "No package found", description: "Try a program name or other words.", icon: Icons.package }, + status: () => last, + async search(text, { filter, signal }) { + const channel = b.channels.find((c) => c.id === filter) ?? preferred; + if (!text.trim()) { + last = `nixos-${channel.id} · ${channel.status}`; + return [{ + id: "hint", + title: `Search ${channel.id === "unstable" ? "nixos-unstable" : `NixOS ${channel.id}`} packages`, + subtitle: "By attribute, program or description; Tab switches the channel", + icon: { source: Icons.search, tint: "blue" }, + actions: [open(`https://search.nixos.org/packages?channel=${channel.id}`, "Open search.nixos.org")], + }]; + } + try { + const json = await query(channel.id, packageQuery(text), signal); + const found = hits(json).map(mapPackage); + const n = total(json); + last = `${found.length < n ? `${found.length} of ${n.toLocaleString("en")}` : n} package${ + n === 1 ? "" : "s" + } · nixos-${channel.id}`; + return found.map((p) => packageItem(p, channel)); + } catch (e) { + if (signal.aborted) throw e; + last = "search.nixos.org is unreachable"; + return [{ + id: "error", + title: e instanceof Error ? e.message : String(e), + icon: { source: Icons.wifiOff, tint: "red" }, + actions: [ + open( + `https://search.nixos.org/packages?channel=${channel.id}&query=${encodeURIComponent(text)}`, + "Search in the Browser", + ), + ], + }]; + } + }, + }); +}; diff --git a/extensions/nix-search/html.ts b/extensions/nix-search/html.ts new file mode 100644 index 0000000..28b3e19 --- /dev/null +++ b/extensions/nix-search/html.ts @@ -0,0 +1,296 @@ +// A small HTML → Markdown converter for the launcher's markdown subset (headings, +// paragraphs, **bold**, *italic*, `code`, fenced code, [links](url), lists, +// > quotes, ---). Enough for Stack Exchange posts and NixOS option docs. + +export interface Node { + tag: string; + attrs: Record; + children: (Node | string)[]; +} + +const VOID = new Set(["br", "hr", "img", "input", "meta", "link", "wbr", "source", "col", "area"]); +/** Closing one of these closes an open

or

  • left unclosed (HTML's optional end tags). */ +const AUTO_CLOSE: Record = { p: ["p"], li: ["li"], dt: ["dt", "dd"], dd: ["dt", "dd"], tr: ["tr"] }; + +const ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + hellip: "…", + mdash: "—", + ndash: "–", + laquo: "«", + raquo: "»", + ldquo: "“", + rdquo: "”", + lsquo: "‘", + rsquo: "’", + copy: "©", + reg: "®", + trade: "™", + times: "×", + rarr: "→", + larr: "←", + bull: "•", + middot: "·", + deg: "°", +}; + +/** `&`, `'`, `'` → characters. */ +export function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all; + } + return ENTITIES[e.toLowerCase()] ?? all; + }); +} + +function parseAttrs(text: string): Record { + const attrs: Record = {}; + for (const m of text.matchAll(/([a-zA-Z_:][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) { + attrs[m[1].toLowerCase()] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? ""); + } + return attrs; +} + +/** A forgiving parser: unknown or unbalanced tags never throw. */ +export function parse(html: string): Node { + const root: Node = { tag: "#root", attrs: {}, children: [] }; + const stack: Node[] = [root]; + const top = () => stack[stack.length - 1]; + const re = /|<(\/?)([a-zA-Z][\w-]*)((?:\s+[^>]*?)?)\s*(\/?)>/g; + let last = 0; + for (const m of html.matchAll(re)) { + if (m.index! > last) top().children.push(html.slice(last, m.index)); + last = m.index! + m[0].length; + if (m[0].startsWith("/g, "") + .replace(/\s*\n\s*/g, " ") + .replace( + /]*>([\s\S]*?)<\/h\1>/gi, + (_, l: string, t: string) => `\n\n${"#".repeat(Math.min(3, Number(l) + 1))} ${t}\n\n`, + ) + .replace(/]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href: string, label: string) => { + const text = htmlToText(label).replace(/[[\]]/g, "") || href; + const url = decodeEntities(href).replace(/ /g, "%20"); + return /^https?:/i.test(url) ? `[${text}](${url})` : text; + }) + .replace(/<(b|strong)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, t: string) => t.trim() ? `**${t.trim()}**` : "") + .replace(/<(i|em)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, t: string) => t.trim() ? `*${t.trim()}*` : "") + .replace(/]*>([\s\S]*?)<\/code>/gi, "`$1`") + .replace(/]*>/gi, "\n- ") + .replace(/<\/li>/gi, "") + .replace(/<\/?(ul|ol)[^>]*>/gi, "\n\n") + .replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_, q: string) => `\n\n> ${htmlToText(q)}\n\n`) + .replace(//gi, "\n") + .replace(/<\/?(p|div|section|article|figure|figcaption|header|footer|table|tr)[^>]*>/gi, "\n\n") + .replace(//gi, "\n\n---\n\n") + .replace(/<[^>]+>/g, ""); + const text = decodeEntities(s) + .split("\n") + .map((l) => l.replace(/[ \t ]+/g, " ").trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return text.replace(/\uE000(\d+)\uE000/g, (_, i: string) => "```\n" + blocks[Number(i)] + "\n```"); +} + +/** "https://www.example.com/a" → "example.com". */ +export function domain(url: string | undefined): string | undefined { + if (!url) return undefined; + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +/** "5 min", "3 h", "2 d", then a date. */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, Math.round((now - ms) / 1000)); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)} min`; + if (s < 86400) return `${Math.floor(s / 3600)} h`; + if (s < 30 * 86400) return `${Math.floor(s / 86400)} d`; + return new Date(ms).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +export function fullDate(ms: number): string { + return new Date(ms).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// --------------------------------------------------------------------------- +// XML + +export interface XmlNode { + name: string; + attrs: Record; + children: XmlNode[]; + /** Text and CDATA directly inside, decoded. */ + text: string; + /** Text and children in document order. */ + content: (string | XmlNode)[]; +} + +const node = (name: string, attrs: Record = {}): XmlNode => ({ + name, + attrs, + children: [], + text: "", + content: [], +}); + +function addText(n: XmlNode, text: string) { + if (!text) return; + n.text += text; + n.content.push(text); +} + +function parseAttrs(s: string): Record { + const attrs: Record = {}; + for (const m of s.matchAll(/([^\s=/]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)) { + attrs[m[1]] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? ""); + } + return attrs; +} + +/** + * A forgiving XML parser: enough for feeds found in the wild (CDATA, + * entities, comments, doctype, unclosed or mismatched tags). + */ +export function parseXml(xml: string): XmlNode { + const root = node("#document"); + const stack: XmlNode[] = [root]; + const top = () => stack[stack.length - 1]; + let i = 0; + const n = xml.length; + while (i < n) { + const lt = xml.indexOf("<", i); + if (lt < 0) { + addText(top(), decodeEntities(xml.slice(i))); + break; + } + if (lt > i) addText(top(), decodeEntities(xml.slice(i, lt))); + if (xml.startsWith("", lt + 4); + i = end < 0 ? n : end + 3; + } else if (xml.startsWith("", lt + 9); + addText(top(), xml.slice(lt + 9, end < 0 ? n : end)); + i = end < 0 ? n : end + 3; + } else if (xml.startsWith("", lt + 2); + i = end < 0 ? n : end + 2; + } else if (xml.startsWith("", lt); + const end = bracket >= 0 && bracket < close ? xml.indexOf("]>", bracket) + 1 : close; + i = end <= 0 ? n : end + 1; + } else if (xml[lt + 1] === "/") { + const end = xml.indexOf(">", lt); + const name = xml.slice(lt + 2, end < 0 ? n : end).trim(); + const at = stack.map((s) => s.name).lastIndexOf(name); + if (at > 0) stack.length = at; + i = end < 0 ? n : end + 1; + } else { + // Find the end of the tag, skipping quoted attribute values. + let j = lt + 1; + let quote = ""; + while (j < n) { + const c = xml[j]; + if (quote) { + if (c === quote) quote = ""; + } else if (c === '"' || c === "'") quote = c; + else if (c === ">") break; + j++; + } + const inner = xml.slice(lt + 1, j); + const selfClosing = inner.endsWith("/"); + const m = /^([^\s/>]+)([\s\S]*)$/.exec(selfClosing ? inner.slice(0, -1) : inner); + i = j + 1; + if (!m) { + addText(top(), "<"); + i = lt + 1; + continue; + } + const el = node(m[1], parseAttrs(m[2])); + top().children.push(el); + top().content.push(el); + if (!selfClosing) stack.push(el); + } + } + return root; +} + +/** The first child named `name` (qualified like `dc:creator`, or local like `link`). */ +export function child(n: XmlNode | undefined, ...names: string[]): XmlNode | undefined { + if (!n) return undefined; + for (const name of names) { + const found = name.includes(":") + ? n.children.find((c) => c.name.toLowerCase() === name.toLowerCase()) + : n.children.find((c) => !c.name.includes(":") && c.name.toLowerCase() === name.toLowerCase()); + if (found) return found; + } + return undefined; +} + +export function children(n: XmlNode | undefined, name: string): XmlNode[] { + return n?.children.filter((c) => c.name.toLowerCase() === name.toLowerCase()) ?? []; +} + +/** Serializes the content of a node back to markup (Atom `type="xhtml"`). */ +export function innerXml(n: XmlNode): string { + const esc = (s: string) => s.replace(/&/g, "&").replace(/ { + if (typeof c === "string") return esc(c); + const attrs = Object.entries(c.attrs).map(([k, v]) => ` ${k}="${v.replace(/"/g, """)}"`).join(""); + return `<${c.name}${attrs}>${innerXml(c)}`; + }).join(""); +} + +export function allText(n: XmlNode): string { + return n.content.map((c) => typeof c === "string" ? c : allText(c)).join(""); +} + +export const textOf = (n: XmlNode | undefined): string => n ? allText(n).trim() : ""; + +// --------------------------------------------------------------------------- +// Dates + +const ZONES: Record = { + UT: "+0000", + UTC: "+0000", + GMT: "+0000", + Z: "+0000", + EST: "-0500", + EDT: "-0400", + CST: "-0600", + CDT: "-0500", + MST: "-0700", + MDT: "-0600", + PST: "-0800", + PDT: "-0700", + AKST: "-0900", + AKDT: "-0800", + HST: "-1000", + WET: "+0000", + WEST: "+0100", + BST: "+0100", + CET: "+0100", + CEST: "+0200", + MET: "+0100", + MEST: "+0200", + EET: "+0200", + EEST: "+0300", + MSK: "+0300", + IST: "+0530", + SGT: "+0800", + HKT: "+0800", + JST: "+0900", + KST: "+0900", + AEST: "+1000", + AEDT: "+1100", + NZST: "+1200", + NZDT: "+1300", +}; + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** RFC 822 (RSS), ISO 8601 (Atom) and the usual variations; milliseconds. */ +export function parseDate(raw: string | undefined): number | undefined { + const s = (raw ?? "").trim(); + if (!s) return undefined; + // ISO 8601, possibly with a space instead of "T". + const iso = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/i.exec( + s, + ); + if (iso) { + const [, y, mo, d, h = "00", mi = "00", se = "00", z] = iso; + const zone = !z || z.toUpperCase() === "Z" ? "Z" : z.length === 5 ? `${z.slice(0, 3)}:${z.slice(3)}` : z; + const t = Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${se}${zone}`); + return Number.isFinite(t) ? t : undefined; + } + // RFC 822: "Tue, 27 Oct 2026 14:03:00 +0100", weekday optional, zone names. + const rfc = + /^(?:[a-z]+,?\s*)?(\d{1,2})\s+([a-z]+)\.?\s+(\d{2,4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?\s*([a-z]+|[+-]\d{4}|[+-]\d{2}:\d{2})?/i + .exec(s); + if (rfc) { + const [, d, monName, yRaw, h = "0", mi = "0", se = "0", zRaw] = rfc; + const mo = MONTHS.indexOf(monName.slice(0, 3).toLowerCase()); + if (mo >= 0) { + const y = yRaw.length === 2 ? 2000 + Number(yRaw) : Number(yRaw); + const z = !zRaw ? "+0000" : /^[+-]/.test(zRaw) ? zRaw.replace(":", "") : ZONES[zRaw.toUpperCase()] ?? "+0000"; + const sign = z[0] === "-" ? -1 : 1; + const offset = sign * (Number(z.slice(1, 3)) * 60 + Number(z.slice(3, 5))); + const t = Date.UTC(y, mo, Number(d), Number(h), Number(mi), Number(se)) - offset * 60_000; + return Number.isFinite(t) ? t : undefined; + } + } + const t = Date.parse(s); + return Number.isFinite(t) ? t : undefined; +} diff --git a/extensions/project-scripts/backslash.toml b/extensions/project-scripts/backslash.toml new file mode 100644 index 0000000..41e5f75 --- /dev/null +++ b/extensions/project-scripts/backslash.toml @@ -0,0 +1,101 @@ +[extension] +id = "project-scripts" +title = "Project Scripts" +description = "Scripts, Make targets, just recipes and tasks of your recent projects, run in a terminal" +version = "1.0.0" +author = "Backslash" +icon = "bs-terminal" +license = "MIT" + +# Projects come from editors' recent lists (VS Code, Cursor, Zed, JetBrains) and the usual +# code folders; only those folders can be read for package.json, Makefile, justfile… +# Editor databases are copied to /tmp/backslash-project-scripts to be read while the +# editor runs. just and task describe their recipes; tasks run in your terminal, through +# your shell ($SHELL) so nvm, pyenv and the like are set up. +[permissions] +env = ["HOME", "SHELL"] +read = [ + "/etc/os-release", + "~/.config/Code", + "~/.config/Code - OSS", + "~/.config/VSCodium", + "~/.config/Cursor", + "~/.config/Windsurf", + "~/.var/app/com.visualstudio.code/config/Code", + "~/.var/app/com.vscodium.codium/config/VSCodium", + "~/.config/JetBrains", + "~/.local/share/zed", + "~/Projects", + "~/projects", + "~/Code", + "~/code", + "~/dev", + "~/Developer", + "~/src", + "~/git", + "~/repos", + "~/workspace", + "~/work", + "~/Work", + "/tmp/backslash-project-scripts", +] +write = ["/tmp/backslash-project-scripts"] +run = [ + "just", + "task", + "go-task", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "scripts" +title = "Project Scripts" +description = "package.json scripts, Make targets, just recipes, Taskfile, Cargo, deno, Composer and Python tasks, by project" +icon = "bs-terminal" +keywords = [ + "scripts", + "npm run", + "pnpm", + "yarn", + "bun", + "make", + "makefile", + "just", + "justfile", + "taskfile", + "cargo", + "deno task", + "composer", + "poetry", + "uv", + "tasks", + "run", + "build", + "tâches", + "lancer", + "exécuter", + "projet", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" + +[[preferences]] +name = "max" +title = "Projects to scan" +description = "The most recent projects whose tasks are listed" +type = "number" +default = 40 diff --git a/extensions/project-scripts/commands/scripts.ts b/extensions/project-scripts/commands/scripts.ts new file mode 100644 index 0000000..1ce11c0 --- /dev/null +++ b/extensions/project-scripts/commands/scripts.ts @@ -0,0 +1,153 @@ +import { action, close, type Color, type Context, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { type Runner, shellArgv, type Task, terminalScript, tilde } from "../lib.ts"; +import { FOLDERS, HOME, projects, tasksOf } from "../sources.ts"; +import { runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; + max?: number; +} + +const RUNNERS: Record = { + npm: { icon: Icons.package, tint: "red", title: "npm" }, + pnpm: { icon: Icons.package, tint: "orange", title: "pnpm" }, + yarn: { icon: Icons.package, tint: "blue", title: "Yarn" }, + bun: { icon: Icons.package, tint: "pink", title: "Bun" }, + deno: { icon: Icons.braces, tint: "teal", title: "Deno" }, + make: { icon: Icons.settings, tint: "gray", title: "Make" }, + just: { icon: Icons.bolt, tint: "yellow", title: "just" }, + task: { icon: Icons.layers, tint: "teal", title: "Task" }, + cargo: { icon: Icons.box, tint: "orange", title: "Cargo" }, + composer: { icon: Icons.package, tint: "purple", title: "Composer" }, + python: { icon: Icons.code, tint: "blue", title: "Python" }, +}; + +const INSTALL: Record> = { + just: { + arch: "sudo pacman -S just", + fedora: "sudo dnf install just", + debian: "sudo apt install just", + suse: "sudo zypper install just", + other: "cargo install just", + }, + task: { + arch: "sudo pacman -S go-task", + fedora: "sudo dnf install go-task", + debian: "sudo snap install task --classic", + suse: "sudo zypper install go-task", + other: "go install github.com/go-task/task/v3/cmd/task@latest", + }, +}; + +let family: Promise | undefined; +function osFamily(): Promise { + family ??= Deno.readTextFile("/etc/os-release").catch(() => "").then((text) => { + const field = (k: string) => text.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 "other"; + }); + return family; +} + +let shown = { tasks: 0, projects: 0 }; + +/** The folder's name, with its parent when another project has the same name. */ +function sectionName(dir: string, all: string[]): string { + const name = dir.split("/").pop()!; + const twins = all.filter((d) => d !== dir && d.split("/").pop() === name); + return twins.length ? dir.split("/").slice(-2).join("/") : name; +} + +function taskItem(t: Task, dir: string, project: string, prefs: Prefs, hint?: string): Item { + const r = RUNNERS[t.runner]; + const runnerName = t.tool && t.runner === "python" ? `Python · ${t.tool}` : r.title; + const cd = `cd ${shellLine([tilde(dir, HOME)]).replace(/^'~/, "~'")} && ${t.command}`; + const needsArgs = /<\w[\w-]*>/.test(t.command); + return { + id: `${dir}\u0000${t.runner}\u0000${t.name}`, + title: t.name, + subtitle: t.description ?? t.script, + icon: { source: r.icon, tint: r.tint }, + section: project, + keywords: [project, r.title, t.tool ?? "", t.script ?? "", t.file], + accessories: [{ tag: runnerName, color: r.tint }], + detail: { + markdown: [ + "```sh", + t.command, + "```", + ...(t.script && t.script !== t.command ? ["", "Runs:", "", "```sh", t.script, "```"] : []), + ...(t.description && t.script ? ["", t.description] : []), + ...(needsArgs ? ["", "_The terminal asks for the `` before running._"] : []), + ...(hint ? ["", hint] : []), + ].join("\n"), + metadata: [ + meta.text("Project", project, { icon: Icons.folder }), + meta.text("Folder", tilde(dir, HOME), { icon: Icons.folder }), + meta.text("Runner", runnerName, { icon: r.icon, color: r.tint }), + meta.text("Defined in", t.file, { icon: Icons.file }), + ], + }, + actions: [ + action("Run in Terminal", () => { + const argv = shellArgv(Deno.env.get("SHELL"), terminalScript(dir, t.command)); + runInTerminal(argv, prefs.terminal || undefined); + return close(`Running ${t.name}`); + }, { icon: Icons.play, shortcut: "t" }), + copy(t.command, "Copy Command", { icon: Icons.terminal }), + copy(cd, "Copy Command with cd", { shortcut: "c", section: "Copy" }), + copy(dir, "Copy Folder Path", { icon: Icons.folder, section: "Copy" }), + open(`${dir}/${t.file}`, `Open ${t.file}`, { icon: Icons.file, shortcut: "o", section: "Project" }), + open(dir, "Open Folder", { icon: Icons.folder, section: "Project" }), + ], + }; +} + +export default (_ctx: Context) => + list({ + placeholder: "Search scripts, Make targets, recipes and tasks by name or project…", + empty: { + title: "No project scripts found", + description: `Projects opened in VS Code, Cursor, Zed or a JetBrains IDE, and folders in ~/${ + FOLDERS.slice(0, 4).join(", ~/") + }… with a package.json, Makefile, justfile, Taskfile, Cargo.toml, deno.json, composer.json or pyproject.toml.`, + icon: Icons.terminal, + }, + status: () => `${shown.tasks} task${shown.tasks === 1 ? "" : "s"} in ${shown.projects} projects`, + async *items({ preferences }) { + const prefs = preferences as Prefs; + const max = Math.max(1, Math.min(200, Number(prefs.max) || 40)); + const found = projects().slice(0, max); + const fam = await osFamily(); + const items: Item[] = []; + let count = 0; + // Project by project, most recent first, so the first ones show at once. + for (let i = 0; i < found.length; i += 8) { + const batch = found.slice(i, i + 8); + const results = await Promise.all(batch.map((p) => tasksOf(p.path))); + results.forEach((res, j) => { + const dir = batch[j].path; + if (!res.tasks.length) return; + count++; + const name = sectionName(dir, found.map((p) => p.path)); + const hint = res.missing.length + ? res.missing.map((m) => + `Install \`${m}\` for descriptions and parameters: \`${INSTALL[m]?.[fam] ?? INSTALL[m]?.other}\`` + ).join("\n\n") + : undefined; + items.push(...res.tasks.map((t) => + taskItem(t, dir, name, prefs, t.runner === "just" || t.runner === "task" ? hint : undefined) + )); + }); + shown = { tasks: items.length, projects: count }; + yield [...items]; + } + if (!found.length) { + yield []; + } + }, + }); diff --git a/extensions/project-scripts/lib.ts b/extensions/project-scripts/lib.ts new file mode 100644 index 0000000..1d2cc89 --- /dev/null +++ b/extensions/project-scripts/lib.ts @@ -0,0 +1,507 @@ +// Finding runnable tasks in a project: parsers for each task runner's file. Pure, tested in lib_test.ts. + +// --- Recent projects (same parsing as the Code Projects extension) ------------------- + +export interface Recent { + path: string; + /** Unix ms, 0 when unknown. */ + opened: number; + editor: string; +} + +/** `file:///home/me/my%20app` → `/home/me/my app`; other schemes (remote) are skipped. */ +export function fileUri(uri: string): string | undefined { + if (!uri.startsWith("file://")) return undefined; + return decodeURIComponent(uri.slice(7)).replace(/\/$/, ""); +} + +/** VS Code's `history.recentlyOpenedPathsList` value. Newest first. */ +export function vscodeRecents(json: string, editor: string, now = Date.now()): Recent[] { + const entries = (JSON.parse(json)?.entries ?? []) as { folderUri?: string }[]; + return entries.flatMap((e, i) => { + const path = fileUri(e.folderUri ?? ""); + // No timestamps: keep VS Code's order, just after "now". + return path ? [{ path, opened: now - i * 60_000, editor }] : []; + }); +} + +const JETBRAINS: [RegExp, string][] = [ + [/^IntelliJIdea|^IdeaIC/, "IntelliJ IDEA"], + [/^PyCharm/, "PyCharm"], + [/^WebStorm/, "WebStorm"], + [/^GoLand/, "GoLand"], + [/^CLion/, "CLion"], + [/^RustRover/, "RustRover"], + [/^PhpStorm/, "PhpStorm"], + [/^Rider/, "Rider"], + [/^RubyMine/, "RubyMine"], +]; + +/** `PyCharm2025.2` → `PyCharm`. */ +export const jetbrainsProduct = (dir: string) => JETBRAINS.find(([re]) => re.test(dir))?.[1]; + +/** `options/recentProjects.xml` of a JetBrains IDE. */ +export function jetbrainsRecents(xml: string, home: string, editor: string): Recent[] { + const out: Recent[] = []; + for (const m of xml.matchAll(/([\s\S]*?)<\/entry>/g)) { + const ts = m[2].match(/name="projectOpenTimestamp" value="(\d+)"/); + out.push({ path: m[1].replace("$USER_HOME$", home), opened: ts ? Number(ts[1]) : 0, editor }); + } + return out; +} + +/** Absolute paths stored by Zed (text or binary columns, depending on its version). */ +export function zedPaths(value: unknown): string[] { + const text = value instanceof Uint8Array ? new TextDecoder().decode(value) : String(value ?? ""); + return [...text.matchAll(/\/[^\p{Cc}"[\]]+/gu)].map((m) => m[0].replace(/\/$/, "")); +} + +/** The newest entry per path, newest first. */ +export function merge(recents: Recent[]): Recent[] { + const byPath = new Map(); + for (const r of recents) { + const seen = byPath.get(r.path); + if (!seen || r.opened > seen.opened) byPath.set(r.path, r); + } + return [...byPath.values()].sort((a, b) => b.opened - a.opened); +} + +// --- Tasks ---------------------------------------------------------------------------- + +export type Runner = + | "npm" + | "pnpm" + | "yarn" + | "bun" + | "make" + | "just" + | "task" + | "cargo" + | "deno" + | "composer" + | "python"; + +export interface Task { + runner: Runner; + name: string; + /** The command line to type in the project folder. */ + command: string; + /** What the task runs, when the file says it (a package.json script's text). */ + script?: string; + description?: string; + /** The file defining it, relative to the project. */ + file: string; + /** For Python: the tool behind it (uv, poetry, poe…). */ + tool?: string; +} + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +// --- package.json --------------------------------------------------------------------- + +export type JsManager = "npm" | "pnpm" | "yarn" | "bun"; + +/** From `packageManager` ("pnpm@9.1.0"), else the lock file present. */ +export function jsManager(pkg: { packageManager?: string }, files: string[]): JsManager { + const declared = /^(npm|pnpm|yarn|bun)@/.exec(pkg.packageManager ?? "")?.[1] as JsManager | undefined; + if (declared) return declared; + if (files.includes("pnpm-lock.yaml")) return "pnpm"; + if (files.includes("bun.lock") || files.includes("bun.lockb")) return "bun"; + if (files.includes("yarn.lock")) return "yarn"; + return "npm"; +} + +export function packageScripts(text: string, files: string[]): Task[] { + const pkg = JSON.parse(text) as { scripts?: Record; packageManager?: string }; + const pm = jsManager(pkg, files); + return Object.entries(pkg.scripts ?? {}) + .filter(([, script]) => typeof script === "string") + .map(([name, script]) => ({ + runner: pm, + name, + command: `${pm} run ${quote(name)}`, + script, + file: "package.json", + })); +} + +// --- Makefile ------------------------------------------------------------------------- + +/** Targets of a Makefile, with `## help` comments (same line or the line above). */ +export function makeTargets(text: string, file = "Makefile"): Task[] { + const lines = text.replace(/\\\n/g, " ").split("\n"); + const phony = new Set( + lines.filter((l) => /^\.PHONY\s*:/.test(l)).flatMap((l) => l.replace(/^\.PHONY\s*:/, "").trim().split(/\s+/)), + ); + const out: Task[] = []; + const seen = new Set(); + lines.forEach((line, i) => { + const m = /^([A-Za-z0-9_][A-Za-z0-9_./ -]*?)\s*:(?![=:])([^=]*?)(?:##\s*(.*))?$/.exec(line); + if (!m || /^\s/.test(line)) return; + const above = /^##?\s*(.+)$/.exec(lines[i - 1] ?? "")?.[1]; + for (const name of m[1].trim().split(/\s+/)) { + if (seen.has(name) || name.includes("%") || name.includes("$")) continue; + // Files the Makefile builds (main.o, build/app) aren't tasks, unless declared phony. + if (!phony.has(name) && /[/.]/.test(name)) continue; + seen.add(name); + out.push({ + runner: "make", + name, + command: file === "Makefile" || file === "makefile" || file === "GNUmakefile" + ? `make ${name}` + : `make -f ${quote(file)} ${name}`, + description: (m[3] ?? above)?.trim() || undefined, + file, + }); + } + }); + return out; +} + +// --- justfile ------------------------------------------------------------------------- + +interface JustRecipe { + name: string; + doc?: string | null; + private?: boolean; + parameters?: { name: string; default?: unknown; kind?: string }[]; + attributes?: unknown[]; +} + +const justCommand = (name: string, params: string[]) => + `just ${name}${params.length ? ` ${params.map((p) => `<${p}>`).join(" ")}` : ""}`; + +/** `just --dump --dump-format json`. */ +export function justDump(json: string, file = "justfile"): Task[] { + const dump = JSON.parse(json) as { recipes?: Record }; + return Object.values(dump.recipes ?? {}) + .filter((r) => !r.private && !r.name.startsWith("_") && !(r.attributes ?? []).includes("private")) + .map((r) => { + // Required parameters must be typed: shown as to fill in. + const required = (r.parameters ?? []).filter((p) => p.default === null || p.default === undefined) + .map((p) => p.name); + return { + runner: "just" as const, + name: r.name, + command: justCommand(r.name, required), + description: r.doc || undefined, + file, + }; + }); +} + +/** `just --summary`: names separated by spaces. */ +export function justSummary(out: string, file = "justfile"): Task[] { + return out.split(/\s+/).filter(Boolean).map((name) => ({ + runner: "just" as const, + name, + command: `just ${name}`, + file, + })); +} + +/** Without `just` installed: recipes read from the file (`# doc` above them). */ +export function justfileRecipes(text: string, file = "justfile"): Task[] { + const lines = text.split("\n"); + const out: Task[] = []; + lines.forEach((line, i) => { + if (/^\s|^#|^(set|alias|export|import|mod)\b|:=/.test(line)) return; + const m = /^@?([A-Za-z][\w-]*)((?:\s+[+*$]?[\w-]+(?:=(?:'[^']*'|"[^"]*"|\S+))?)*)\s*:(?!=)/.exec(line); + if (!m) return; + const params = m[2].trim().split(/\s+/).filter(Boolean).filter((p) => !p.includes("=")).map((p) => + p.replace(/^[+*$]/, "") + ); + const doc = /^#\s*(.+)$/.exec(lines[i - 1] ?? "")?.[1]; + out.push({ runner: "just", name: m[1], command: justCommand(m[1], params), description: doc, file }); + }); + return out; +} + +// --- Taskfile ------------------------------------------------------------------------- + +/** `task --list-all --json`. */ +export function taskList(json: string, file = "Taskfile.yml"): Task[] { + const data = JSON.parse(json) as { tasks?: { name: string; desc?: string; summary?: string; aliases?: string[] }[] }; + return (data.tasks ?? []).map((t) => ({ + runner: "task" as const, + name: t.name, + command: `task ${t.name}`, + description: t.desc || t.summary?.split("\n")[0] || undefined, + file, + })); +} + +/** Without `task` installed: the keys under `tasks:`, with their `desc:`. */ +export function taskfileTasks(text: string, file = "Taskfile.yml"): Task[] { + const out: Task[] = []; + let inTasks = false; + let indent = -1; + let current: Task | undefined; + for (const line of text.split("\n")) { + if (/^tasks:\s*$/.test(line)) { + inTasks = true; + continue; + } + if (!inTasks || !line.trim() || /^\s*#/.test(line)) continue; + if (/^\S/.test(line)) break; + const lead = line.length - line.trimStart().length; + const key = /^\s*(['"]?)([\w:.-]+)\1:\s*(.*)$/.exec(line); + if (indent < 0 && key) indent = lead; + if (key && lead === indent) { + current = { runner: "task", name: key[2], command: `task ${key[2]}`, file }; + out.push(current); + } else if (key && current && key[2] === "desc" && lead > indent) { + current.description = key[3].replace(/^['"]|['"]$/g, "") || undefined; + } + } + return out.filter((t) => !t.name.startsWith("_")); +} + +// --- Cargo ---------------------------------------------------------------------------- + +/** The usual cargo commands; `run` only for a package with a binary. */ +export function cargoTasks(toml: string, hasMain: boolean): Task[] { + const isPackage = /^\[package\]/m.test(toml); + const bins = /^\[\[bin\]\]/m.test(toml); + const workspace = /^\[workspace\]/m.test(toml); + const base: [string, string, string][] = [ + ["build", "cargo build", "Compile the package"], + ...(isPackage && (hasMain || bins) + ? [["run", "cargo run", "Build and run the binary"] as [string, string, string]] + : []), + ["test", workspace ? "cargo test --workspace" : "cargo test", "Run the tests"], + ["check", "cargo check", "Check for errors without building"], + ["clippy", "cargo clippy", "Lint with Clippy"], + ["release", "cargo build --release", "Optimized build"], + ]; + return base.map(([name, command, description]) => ({ + runner: "cargo", + name, + command, + description, + file: "Cargo.toml", + })); +} + +// --- deno.json ------------------------------------------------------------------------ + +/** JSON with comments and trailing commas (deno.jsonc, VS Code style). */ +export function parseJsonc(text: string): unknown { + let out = ""; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + const start = i; + for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++; + out += text.slice(start, i + 1); + } else if (c === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + out += "\n"; + } else if (c === "/" && text[i + 1] === "*") { + i = text.indexOf("*/", i + 2); + if (i < 0) break; + i++; + } else out += c; + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); +} + +export function denoTasks(text: string, file = "deno.json"): Task[] { + const json = parseJsonc(text) as { tasks?: Record }; + return Object.entries(json.tasks ?? {}).map(([name, t]) => ({ + runner: "deno" as const, + name, + command: `deno task ${quote(name)}`, + script: typeof t === "string" ? t : t.command, + description: typeof t === "string" ? undefined : t.description, + file, + })); +} + +// --- composer.json -------------------------------------------------------------------- + +export function composerScripts(text: string): Task[] { + const json = JSON.parse(text) as { + scripts?: Record; + "scripts-descriptions"?: Record; + }; + return Object.entries(json.scripts ?? {}) + // pre-install-cmd, post-autoload-dump…: Composer runs those itself. + .filter(([name]) => !/^(pre|post)-[a-z-]+(-cmd|-dump)?$/.test(name)) + .map(([name, script]) => ({ + runner: "composer" as const, + name, + command: `composer run-script ${quote(name)}`, + script: Array.isArray(script) ? script.join(" && ") : script, + description: json["scripts-descriptions"]?.[name], + file: "composer.json", + })); +} + +// --- pyproject.toml ------------------------------------------------------------------- + +/** Just enough TOML: `[table]` → `key = value` with values as raw text. */ +export function tomlTables(text: string): Map { + const tables = new Map(); + let current = ""; + tables.set(current, []); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith("#")) continue; + const header = /^\[\s*([^\][]+?)\s*\]$/.exec(line); + if (header) { + current = header[1].replace(/["']/g, ""); + if (!tables.has(current)) tables.set(current, []); + continue; + } + const kv = /^("[^"]+"|'[^']+'|[\w.-]+)\s*=\s*(.*)$/.exec(line); + if (!kv) continue; + let value = kv[2]; + // Multi-line strings and arrays: read until they close. + const open = (v: string) => + (v.startsWith('"""') && (v.match(/"""/g) ?? []).length < 2) || + (v.startsWith("'''") && (v.match(/'''/g) ?? []).length < 2) || + (v.startsWith("[") && depth(v) > 0); + while (open(value) && i + 1 < lines.length) value += `\n${lines[++i]}`; + tables.get(current)!.push([kv[1].replace(/^["']|["']$/g, ""), value.trim()]); + } + return tables; +} + +function depth(v: string): number { + let d = 0; + for (const c of v.replace(/"[^"]*"|'[^']*'/g, "")) d += c === "[" ? 1 : c === "]" ? -1 : 0; + return d; +} + +/** A TOML string, or the `cmd`/`shell`/`help` of an inline table. */ +function tomlValue(raw: string): { text?: string; help?: string } { + const str = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(raw); + if (str) return { text: str[1]?.replace(/\\"/g, '"') ?? str[2] }; + if (raw.startsWith("{")) { + const field = (k: string) => new RegExp(`\\b${k}\\s*=\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|'([^']*)')`).exec(raw); + const cmd = field("cmd") ?? field("shell") ?? field("script") ?? field("call") ?? field("ref"); + const help = field("help"); + return { text: cmd ? cmd[1] ?? cmd[2] : undefined, help: help ? help[1] ?? help[2] : undefined }; + } + if (raw.startsWith("[")) { + const items = [...raw.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]); + return { text: items.join(" && ") }; + } + return {}; +} + +/** Script entry points and task runners of a Python project. */ +export function pyprojectTasks(text: string, files: string[]): Task[] { + const tables = tomlTables(text); + const env = files.includes("uv.lock") + ? "uv run " + : files.includes("poetry.lock") + ? "poetry run " + : files.includes("pdm.lock") + ? "pdm run " + : ""; + const tool = env.split(" ")[0] || undefined; + const out: Task[] = []; + const add = (name: string, command: string, raw: string, t: string | undefined, help?: string) => { + if (out.some((o) => o.name === name)) return; + const v = tomlValue(raw); + out.push({ + runner: "python", + name, + command, + script: v.text, + description: help ?? v.help, + file: "pyproject.toml", + tool: t, + }); + }; + for (const [name, raw] of tables.get("project.scripts") ?? []) add(name, `${env}${name}`, raw, tool); + for (const [name, raw] of tables.get("tool.poetry.scripts") ?? []) add(name, `poetry run ${name}`, raw, "poetry"); + for (const [name, raw] of tables.get("tool.pdm.scripts") ?? []) { + if (name !== "_") add(name, `pdm run ${name}`, raw, "pdm"); + } + for (const [name, raw] of tables.get("tool.rye.scripts") ?? []) add(name, `rye run ${name}`, raw, "rye"); + for (const [name, raw] of tables.get("tool.poe.tasks") ?? []) add(name, `${env}poe ${name}`, raw, "poe"); + for (const [table, rows] of tables) { + const poe = /^tool\.poe\.tasks\.([\w-]+)$/.exec(table); + if (poe) { + const row = (k: string) => rows.find(([key]) => key === k)?.[1]; + const cmd = row("cmd") ?? row("shell") ?? row("script") ?? row("sequence") ?? ""; + const help = row("help"); + add(poe[1], `${env}poe ${poe[1]}`, cmd, "poe", help ? tomlValue(help).text : undefined); + } + const hatch = /^tool\.hatch\.envs\.([\w-]+)\.scripts$/.exec(table); + if (hatch) { + for (const [name, raw] of rows) { + add( + hatch[1] === "default" ? name : `${hatch[1]}:${name}`, + `hatch run ${hatch[1] === "default" ? name : `${hatch[1]}:${name}`}`, + raw, + "hatch", + ); + } + } + } + for (const [name, raw] of tables.get("tool.taskipy.tasks") ?? []) { + add(name, `${env}task ${name}`, raw, "taskipy"); + } + return out; +} + +// --- Running -------------------------------------------------------------------------- + +/** + * A shell script for the terminal: run in the project, then say how it went and + * wait, so the window doesn't vanish with the output. + */ +export function terminalScript(dir: string, command: string): string { + // `just deploy `: ask for each argument first. + const asks: string[] = []; + let n = 0; + const line = command.replace(/<([\w-]+)>/g, (_, name: string) => { + n++; + asks.push(`printf '\\033[1m%s:\\033[0m ' ${quote(name)}; read -r arg${n}`); + return `"$arg${n}"`; + }); + return [ + `cd ${quote(dir)} || exit`, + ...asks, + `printf '\\033[2m$ %s\\033[0m\\n' ${quote(command)}`, + line, + "code=$?", + `if [ "$code" = 0 ]; then printf '\\n\\033[32mDone.\\033[0m '; else printf '\\n\\033[31mExited with %s.\\033[0m ' "$code"; fi`, + "printf 'Press Enter to close…'; read -r _", + ].join("\n"); +} + +/** The shell running the script: the user's bash or zsh (their PATH, nvm…), else sh. */ +export function shellArgv(shell: string | undefined, script: string): string[] { + const name = (shell ?? "").split("/").pop() ?? ""; + return ["bash", "zsh"].includes(name) ? [shell!, "-ic", script] : ["sh", "-c", script]; +} + +/** `/home/me/code/app` → `~/code/app`. */ +export const tilde = (path: string, home: string) => + home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; + +/** Files that make a folder a project with tasks. */ +export const TASK_FILES = [ + "package.json", + "Makefile", + "makefile", + "GNUmakefile", + "justfile", + "Justfile", + ".justfile", + "Taskfile.yml", + "Taskfile.yaml", + "taskfile.yml", + "taskfile.yaml", + "Cargo.toml", + "deno.json", + "deno.jsonc", + "composer.json", + "pyproject.toml", +]; diff --git a/extensions/project-scripts/lib_test.ts b/extensions/project-scripts/lib_test.ts new file mode 100644 index 0000000..6ee75a8 --- /dev/null +++ b/extensions/project-scripts/lib_test.ts @@ -0,0 +1,263 @@ +import { assertEquals } from "@std/assert"; +import { + cargoTasks, + composerScripts, + denoTasks, + jetbrainsRecents, + jsManager, + justDump, + justfileRecipes, + justSummary, + makeTargets, + merge, + packageScripts, + parseJsonc, + pyprojectTasks, + shellArgv, + taskfileTasks, + taskList, + terminalScript, + tilde, + vscodeRecents, +} from "./lib.ts"; +import { shellLine, terminalArgs } from "./terminal.ts"; + +const brief = (tasks: { name: string; command: string; description?: string }[]) => + tasks.map((t) => [t.name, t.command, t.description ?? ""]); + +Deno.test("recent projects", () => { + const json = JSON.stringify({ + entries: [ + { folderUri: "file:///home/ada/code/my%20app" }, + { fileUri: "file:///home/ada/notes.md" }, + { folderUri: "vscode-remote://ssh-remote+box/srv/app" }, + { folderUri: "file:///home/ada/code/api/" }, + ], + }); + const recents = vscodeRecents(json, "VS Code", 1_000_000_000); + assertEquals(recents.map((r) => r.path), ["/home/ada/code/my app", "/home/ada/code/api"]); + const xml = ``; + const merged = merge([...recents, ...jetbrainsRecents(xml, "/home/ada", "GoLand")]); + assertEquals(merged.map((r) => [r.path, r.editor]), [["/home/ada/code/api", "GoLand"], [ + "/home/ada/code/my app", + "VS Code", + ]]); +}); + +Deno.test("package.json scripts and the package manager", () => { + const pkg = JSON.stringify({ scripts: { dev: "vite", build: "tsc && vite build", "test:unit": "vitest" } }); + assertEquals(brief(packageScripts(pkg, ["package.json", "pnpm-lock.yaml"])), [ + ["dev", "pnpm run dev", ""], + ["build", "pnpm run build", ""], + ["test:unit", "pnpm run test:unit", ""], + ]); + assertEquals(packageScripts(pkg, [])[1].script, "tsc && vite build"); + assertEquals(jsManager({}, ["yarn.lock"]), "yarn"); + assertEquals(jsManager({}, ["bun.lockb"]), "bun"); + assertEquals(jsManager({}, ["package-lock.json"]), "npm"); + assertEquals(jsManager({ packageManager: "yarn@4.5.0" }, ["package-lock.json"]), "yarn"); +}); + +Deno.test("Makefile targets", () => { + const make = [ + ".PHONY: all test lint docs/build", + "VERSION := 1.2", + "CC ?= gcc", + "", + "## Build everything", + "all: build", + "", + "build: main.o ## Compile the binary", + "\t$(CC) -o app main.o", + "", + "main.o: main.c", + "\t$(CC) -c main.c", + "", + "%.o: %.c", + "test lint:", + "\t./run $@", + "docs/build:", + "\tmkdocs build", + "install: export PREFIX=/usr", + "clean:", + "\trm -f *.o", + ].join("\n"); + assertEquals(brief(makeTargets(make)), [ + ["all", "make all", "Build everything"], + ["build", "make build", "Compile the binary"], + ["test", "make test", ""], + ["lint", "make lint", ""], + ["docs/build", "make docs/build", ""], + ["clean", "make clean", ""], + ]); + assertEquals(makeTargets("dev:\n\tgo run .", "GNUmakefile")[0].command, "make dev"); +}); + +Deno.test("just recipes", () => { + const dump = JSON.stringify({ + recipes: { + build: { name: "build", doc: "Build the app", parameters: [], private: false, attributes: [] }, + deploy: { + name: "deploy", + doc: null, + parameters: [{ name: "env", default: null, kind: "singular" }, { name: "tag", default: "latest" }], + private: false, + attributes: [], + }, + _helper: { name: "_helper", doc: null, parameters: [], private: true, attributes: [] }, + }, + }); + assertEquals(brief(justDump(dump)), [["build", "just build", "Build the app"], ["deploy", "just deploy ", ""]]); + assertEquals(brief(justSummary("build deploy test\n")).map((t) => t[1]), ["just build", "just deploy", "just test"]); + const file = [ + "set dotenv-load", + "version := `git describe`", + "", + "# Run the dev server", + "dev port='8080':", + " deno run -A main.ts {{port}}", + "", + "@release tag +notes:", + " gh release create {{tag}}", + "alias r := release", + "_private:", + " echo", + ].join("\n"); + assertEquals(brief(justfileRecipes(file)), [ + ["dev", "just dev", "Run the dev server"], + ["release", "just release ", ""], + ]); +}); + +Deno.test("Taskfile tasks", () => { + const json = JSON.stringify({ + tasks: [ + { name: "build", task: "build", desc: "Build the binary", summary: "", aliases: ["b"] }, + { name: "docs:serve", task: "docs:serve", desc: "", summary: "Serve the docs\nlocally" }, + ], + }); + assertEquals(brief(taskList(json)), [["build", "task build", "Build the binary"], [ + "docs:serve", + "task docs:serve", + "Serve the docs", + ]]); + const yaml = [ + "version: '3'", + "", + "vars:", + " NAME: app", + "", + "tasks:", + " build:", + " desc: Build the binary", + " cmds:", + " - go build ./...", + " 'docs:serve':", + " cmds: [mkdocs serve]", + " _internal:", + " cmds: [echo]", + "", + "includes:", + " other: ./other", + ].join("\n"); + assertEquals(brief(taskfileTasks(yaml)), [["build", "task build", "Build the binary"], [ + "docs:serve", + "task docs:serve", + "", + ]]); +}); + +Deno.test("Cargo, deno, composer", () => { + const pkg = '[package]\nname = "app"\nversion = "0.1.0"\n'; + assertEquals(cargoTasks(pkg, true).map((t) => t.name), ["build", "run", "test", "check", "clippy", "release"]); + assertEquals(cargoTasks('[workspace]\nmembers = ["a"]\n', false).map((t) => t.command).slice(0, 2), [ + "cargo build", + "cargo test --workspace", + ]); + const deno = `{ + // Tasks + "tasks": { + "dev": "deno run -A --watch main.ts", + "check": { "command": "deno check **/*.ts", "description": "Type-check, // not a comment" }, + }, + }`; + assertEquals(parseJsonc('{"a": "http://x", /* c */ "b": [1,],}'), { a: "http://x", b: [1] }); + assertEquals(brief(denoTasks(deno, "deno.jsonc")), [ + ["dev", "deno task dev", ""], + ["check", "deno task check", "Type-check, // not a comment"], + ]); + const composer = JSON.stringify({ + scripts: { test: "phpunit", lint: ["phpcs", "phpstan"], "post-install-cmd": "x", "post-autoload-dump": "y" }, + "scripts-descriptions": { test: "Run PHPUnit" }, + }); + assertEquals(brief(composerScripts(composer)), [ + ["test", "composer run-script test", "Run PHPUnit"], + ["lint", "composer run-script lint", ""], + ]); + assertEquals(composerScripts(composer)[1].script, "phpcs && phpstan"); +}); + +Deno.test("pyproject scripts and task runners", () => { + const toml = [ + "[project]", + 'name = "app"', + "", + "[project.scripts]", + 'app = "app.cli:main"', + "", + "[tool.poe.tasks]", + 'test = "pytest -q"', + 'lint = { cmd = "ruff check .", help = "Lint the code" }', + "", + "[tool.poe.tasks.serve]", + 'cmd = "uvicorn app:api --reload"', + 'help = "Dev server"', + "", + "[tool.hatch.envs.docs.scripts]", + 'build = "mkdocs build"', + "", + "[tool.pdm.scripts]", + 'start = "flask run"', + '_ = { env_file = ".env" }', + "", + "[tool.taskipy.tasks]", + 'fmt = "black ."', + ].join("\n"); + assertEquals(brief(pyprojectTasks(toml, ["pyproject.toml", "uv.lock"])), [ + ["app", "uv run app", ""], + ["start", "pdm run start", ""], + ["test", "uv run poe test", ""], + ["lint", "uv run poe lint", "Lint the code"], + ["serve", "uv run poe serve", "Dev server"], + ["docs:build", "hatch run docs:build", ""], + ["fmt", "uv run task fmt", ""], + ]); + const tasks = pyprojectTasks(toml, []); + assertEquals(tasks[0].command, "app"); + assertEquals(tasks.find((t) => t.name === "lint")?.script, "ruff check ."); + assertEquals(tasks.find((t) => t.name === "serve")?.script, "uvicorn app:api --reload"); +}); + +Deno.test("running in a terminal", () => { + const script = terminalScript("/home/ada/code/my app", "pnpm run dev"); + assertEquals(script.split("\n").slice(0, 3), [ + "cd '/home/ada/code/my app' || exit", + "printf '\\033[2m$ %s\\033[0m\\n' 'pnpm run dev'", + "pnpm run dev", + ]); + assertEquals(terminalScript("/p", "just deploy ").split("\n").slice(1, 4), [ + "printf '\\033[1m%s:\\033[0m ' env; read -r arg1", + "printf '\\033[1m%s:\\033[0m ' tag; read -r arg2", + "printf '\\033[2m$ %s\\033[0m\\n' 'just deploy '", + ]); + assertEquals(terminalScript("/p", "just deploy ").split("\n")[4], 'just deploy "$arg1" "$arg2"'); + assertEquals(shellArgv("/usr/bin/zsh", "x"), ["/usr/bin/zsh", "-ic", "x"]); + assertEquals(shellArgv("/usr/bin/fish", "x"), ["sh", "-c", "x"]); + assertEquals(shellArgv(undefined, "x"), ["sh", "-c", "x"]); + assertEquals(terminalArgs("gnome-terminal", ["sh", "-c", "x"]), ["--", "sh", "-c", "x"]); + assertEquals(shellLine(["make", "my target"]), "make 'my target'"); + assertEquals([tilde("/home/ada/code", "/home/ada"), tilde("/home/adam", "/home/ada")], ["~/code", "/home/adam"]); +}); diff --git a/extensions/project-scripts/sources.ts b/extensions/project-scripts/sources.ts new file mode 100644 index 0000000..79ffd66 --- /dev/null +++ b/extensions/project-scripts/sources.ts @@ -0,0 +1,263 @@ +// Where projects are (editors' recent lists, git folders) and the tasks they define. + +import { DatabaseSync } from "node:sqlite"; +import { exec } from "@backslash/api"; +import { + cargoTasks, + composerScripts, + denoTasks, + jetbrainsProduct, + jetbrainsRecents, + justDump, + justfileRecipes, + justSummary, + makeTargets, + merge, + packageScripts, + pyprojectTasks, + type Recent, + type Task, + TASK_FILES, + taskfileTasks, + taskList, + vscodeRecents, + zedPaths, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +const TMP = "/tmp/backslash-project-scripts"; + +const VSCODES: [string, string][] = [ + [".config/Code", "VS Code"], + [".var/app/com.visualstudio.code/config/Code", "VS Code"], + [".config/Code - OSS", "Code - OSS"], + [".config/VSCodium", "VSCodium"], + [".var/app/com.vscodium.codium/config/VSCodium", "VSCodium"], + [".config/Cursor", "Cursor"], + [".config/Windsurf", "Windsurf"], +]; + +/** Same folders as `[permissions] read`. */ +export const FOLDERS = [ + "Projects", + "projects", + "Code", + "code", + "dev", + "Developer", + "src", + "git", + "repos", + "workspace", + "work", + "Work", +]; + +const isDir = (path: string) => { + try { + return Deno.statSync(path).isDirectory; + } catch { + return false; + } +}; + +function dirs(path: string): string[] { + try { + return [...Deno.readDirSync(path)].filter((e) => e.isDirectory && !e.name.startsWith(".")).map((e) => + `${path}/${e.name}` + ); + } catch { + return []; + } +} + +/** A readable copy of a database the editor may hold open. */ +function openDb(path: string): DatabaseSync | undefined { + try { + Deno.statSync(path); + Deno.mkdirSync(TMP, { recursive: true }); + const copy = `${TMP}/${path.replace(/[^a-zA-Z0-9]/g, "_").slice(-100)}`; + Deno.copyFileSync(path, copy); + try { + Deno.copyFileSync(`${path}-wal`, `${copy}-wal`); + } catch { /* no write-ahead log */ } + return new DatabaseSync(copy); + } catch { + return undefined; + } +} + +function vscode(): Recent[] { + return VSCODES.flatMap(([dir, editor]) => { + const db = openDb(`${HOME}/${dir}/User/globalStorage/state.vscdb`); + if (!db) return []; + try { + const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'").get() as + | { value: string } + | undefined; + return row ? vscodeRecents(String(row.value), editor) : []; + } catch { + return []; + } finally { + db.close(); + } + }); +} + +function jetbrains(): Recent[] { + return dirs(`${HOME}/.config/JetBrains`).flatMap((dir) => { + const product = jetbrainsProduct(dir.split("/").pop()!); + if (!product) return []; + try { + return jetbrainsRecents(Deno.readTextFileSync(`${dir}/options/recentProjects.xml`), HOME, product); + } catch { + return []; + } + }); +} + +function zed(): Recent[] { + return dirs(`${HOME}/.local/share/zed/db`).flatMap((dir) => { + const db = openDb(`${dir}/db.sqlite`); + if (!db) return []; + try { + const rows = db.prepare("SELECT * FROM workspaces").all() as Record[]; + return rows.flatMap((row) => { + const opened = Date.parse(String(row.timestamp ?? "")) || 0; + return Object.keys(row).filter((k) => /paths/.test(k)).flatMap((k) => zedPaths(row[k])).map((path) => ({ + path, + opened, + editor: "Zed", + })); + }); + } catch { + return []; + } finally { + db.close(); + } + }); +} + +const hasTaskFile = (dir: string) => TASK_FILES.some((f) => exists(`${dir}/${f}`)); + +function exists(path: string): boolean { + try { + Deno.statSync(path); + return true; + } catch { + return false; + } +} + +/** Projects one or two levels below the usual folders, newest change first. */ +function folders(): Recent[] { + const found: string[] = []; + for (const root of FOLDERS.map((f) => `${HOME}/${f}`)) { + for (const dir of dirs(root)) { + if (isDir(`${dir}/.git`) || hasTaskFile(dir)) found.push(dir); + else found.push(...dirs(dir).filter((d) => isDir(`${d}/.git`) || hasTaskFile(d))); + } + } + return found.map((path) => { + let opened = 0; + try { + opened = Deno.statSync(path).mtime?.getTime() ?? 0; + } catch { /* unreadable */ } + // Behind anything an editor opened recently. + return { path, opened: Math.min(opened, Date.now() - 30 * 86_400_000), editor: "" }; + }); +} + +/** Projects with task files, most recently opened first. */ +export function projects(): Recent[] { + const fromEditors = merge([...vscode(), ...jetbrains(), ...zed()]); + const known = new Set(fromEditors.map((r) => r.path)); + return merge([...fromEditors, ...folders().filter((r) => !known.has(r.path))]) + .filter((r) => isDir(r.path) && hasTaskFile(r.path)); +} + +// --- Tasks ---------------------------------------------------------------------------- + +/** Programs the extension may run to list tasks; NotFound means "not installed". */ +async function tool(program: string, args: string[]): Promise { + try { + return await exec(program, args, { signal: AbortSignal.timeout(5000) }); + } catch (err) { + if (err instanceof Deno.errors.NotFound) return undefined; + throw err; + } +} + +const read = (path: string) => Deno.readTextFile(path).catch(() => undefined); + +export interface Found { + tasks: Task[]; + /** Runners whose program would describe tasks better, if installed. */ + missing: string[]; + errors: string[]; +} + +export async function tasksOf(dir: string): Promise { + const files = (() => { + try { + return [...Deno.readDirSync(dir)].filter((e) => e.isFile).map((e) => e.name); + } catch { + return []; + } + })(); + const has = (f: string) => files.includes(f); + const out: Task[] = []; + const missing: string[] = []; + const errors: string[] = []; + const attempt = async (file: string, parse: () => Promise | Task[]) => { + try { + out.push(...await parse()); + } catch (e) { + errors.push(`${file}: ${e instanceof Error ? e.message.split("\n")[0] : e}`); + } + }; + + if (has("package.json")) { + await attempt("package.json", async () => packageScripts((await read(`${dir}/package.json`)) ?? "{}", files)); + } + for (const f of ["deno.json", "deno.jsonc"]) { + if (has(f)) await attempt(f, async () => denoTasks((await read(`${dir}/${f}`)) ?? "{}", f)); + } + const justfile = ["justfile", "Justfile", ".justfile"].find(has); + if (justfile) { + await attempt(justfile, async () => { + const args = ["--justfile", `${dir}/${justfile}`, "--working-directory", dir]; + const dump = await tool("just", [...args, "--dump", "--dump-format", "json"]).catch(() => undefined); + if (dump) return justDump(dump, justfile); + const summary = await tool("just", [...args, "--summary"]); + if (summary !== undefined) return justSummary(summary, justfile); + missing.push("just"); + return justfileRecipes((await read(`${dir}/${justfile}`)) ?? "", justfile); + }); + } + const taskfile = ["Taskfile.yml", "Taskfile.yaml", "taskfile.yml", "taskfile.yaml"].find(has); + if (taskfile) { + await attempt(taskfile, async () => { + const json = await tool("task", ["--dir", dir, "--list-all", "--json"]) ?? + await tool("go-task", ["--dir", dir, "--list-all", "--json"]); + if (json) return taskList(json, taskfile); + missing.push("task"); + return taskfileTasks((await read(`${dir}/${taskfile}`)) ?? "", taskfile); + }); + } + const makefile = ["GNUmakefile", "makefile", "Makefile"].find(has); + if (makefile) await attempt(makefile, async () => makeTargets((await read(`${dir}/${makefile}`)) ?? "", makefile)); + if (has("Cargo.toml")) { + await attempt( + "Cargo.toml", + async () => cargoTasks((await read(`${dir}/Cargo.toml`)) ?? "", exists(`${dir}/src/main.rs`)), + ); + } + if (has("composer.json")) { + await attempt("composer.json", async () => composerScripts((await read(`${dir}/composer.json`)) ?? "{}")); + } + if (has("pyproject.toml")) { + await attempt("pyproject.toml", async () => pyprojectTasks((await read(`${dir}/pyproject.toml`)) ?? "", files)); + } + return { tasks: out, missing, errors }; +} diff --git a/extensions/project-scripts/terminal.ts b/extensions/project-scripts/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/project-scripts/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/proxmox/api.ts b/extensions/proxmox/api.ts new file mode 100644 index 0000000..2572eb2 --- /dev/null +++ b/extensions/proxmox/api.ts @@ -0,0 +1,325 @@ +// Proxmox VE API with an API token, trusting the cluster's own CA when given. + +import { copy, Icons, type Item, open } from "@backslash/api"; +import { + authHeader, + expandHome, + isCertificateError, + isTokenId, + normalizePem, + normalizeUrl, + TOKEN_DOCS, +} from "./lib.ts"; + +/** + * Where a Proxmox server usually lives, as declared in `[permissions] net` + * (kept equal by a test): local names, private networks and Tailscale. + * Extensions declare their hosts statically, so a public domain is out of reach. + */ +export const HOSTS = [ + "localhost", + "127.0.0.1", + "*.local", + "*.lan", + "*.home", + "*.home.arpa", + "*.internal", + "*.ts.net", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", +]; + +function ipv4(host: string): number | undefined { + const parts = host.split("."); + if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p) || Number(p) > 255)) return undefined; + return parts.reduce((n, p) => n * 256 + Number(p), 0); +} + +/** Whether Deno's `--allow-net` with {@link HOSTS} lets us reach `host`. */ +export function isAllowedHost(host: string, patterns = HOSTS): boolean { + const h = host.toLowerCase().replace(/^\[|\]$/g, ""); + return patterns.some((p) => { + if (p.startsWith("*.")) return h.endsWith(p.slice(1)) || h === p.slice(2); + const cidr = /^([\d.]+)\/(\d+)$/.exec(p); + if (cidr) { + const ip = ipv4(h), net = ipv4(cidr[1]); + if (ip === undefined || net === undefined) return false; + const bits = Number(cidr[2]); + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return ((ip & mask) >>> 0) === ((net & mask) >>> 0); + } + return h === p; + }); +} + +export type SetupKind = "url" | "token" | "host" | "cert" | "down" | "permission"; + +export class SetupError extends Error { + constructor(message: string, readonly kind: SetupKind, readonly base?: string) { + super(message); + } +} + +export interface Server { + base: string; + auth: string; + client?: Deno.HttpClient; + /** Where the CA came from, for the error messages. */ + ca?: string; +} + +const HOME = (() => { + try { + return Deno.env.get("HOME"); + } catch { + return undefined; + } +})(); + +/** The folder `[permissions] read` covers for a CA file. */ +export const CA_DIR = "~/.config/proxmox"; +export const DEFAULT_CA = `${CA_DIR}/pve-root-ca.pem`; + +/** The CA preference: pasted PEM text, or a file in ~/.config/proxmox. */ +async function caCertificate(pref: unknown): Promise<{ pem?: string; source?: string }> { + const value = String(pref ?? "").trim() || DEFAULT_CA; + if (value.includes("BEGIN CERTIFICATE")) { + const pem = normalizePem(value); + if (!pem) throw new SetupError("The pasted CA certificate is not a PEM certificate", "cert"); + return { pem, source: "the pasted certificate" }; + } + const path = expandHome(value, HOME); + let text: string; + try { + text = await Deno.readTextFile(path); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + if (value === DEFAULT_CA) return {}; + throw new SetupError(`CA file ${value} not found`, "cert"); + } + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new SetupError(`Put the CA file in ${CA_DIR}/ (Backslash may only read there)`, "cert"); + } + throw e; + } + const pem = normalizePem(text); + if (!pem) throw new SetupError(`${value} is not a PEM certificate`, "cert"); + return { pem, source: value }; +} + +/** A fetch error with its causes: the certificate problem is in `cause`. */ +export function errorText(e: unknown): string { + const parts: string[] = []; + for (let x: unknown = e, i = 0; x && i < 5; x = (x as { cause?: unknown }).cause, i++) { + parts.push(x instanceof Error ? x.message : String(x)); + } + return parts.join(": "); +} + +let cached: { key: string; server: Server } | undefined; + +export async function server(preferences: Record): Promise { + const base = normalizeUrl(preferences.url); + if (!base) throw new SetupError("Add the address of your Proxmox server", "url"); + let host: string; + try { + host = new URL(base).hostname; + } catch { + throw new SetupError(`“${preferences.url}” is not an address`, "url"); + } + if (!isAllowedHost(host)) throw new SetupError(`${host} is outside the networks this extension may reach`, "host"); + const tokenId = String(preferences.tokenId ?? "").trim(); + const secret = String(preferences.tokenSecret ?? "").trim(); + if (!tokenId || !secret) throw new SetupError("Add an API token", "token", base); + if (!isTokenId(tokenId)) { + throw new SetupError(`“${tokenId}” is not a token ID like root@pam!backslash`, "token", base); + } + const key = JSON.stringify([base, tokenId, secret, preferences.caFile ?? ""]); + if (cached?.key === key) return cached.server; + const ca = await caCertificate(preferences.caFile); + const s: Server = { + base, + auth: authHeader(tokenId, secret), + client: ca.pem ? Deno.createHttpClient({ caCerts: [ca.pem] }) : undefined, + ca: ca.source, + }; + cached = { key, server: s }; + return s; +} + +export async function pve( + s: Server, + path: string, + options: { method?: string; body?: Record; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${s.base}/api2/json${path}`, { + method: options.method ?? "GET", + signal: options.signal, + client: s.client, + headers: { + Authorization: s.auth, + ...(options.body ? { "Content-Type": "application/x-www-form-urlencoded" } : {}), + }, + body: options.body + ? new URLSearchParams(Object.entries(options.body).map(([k, v]) => [k, String(v)])).toString() + : undefined, + }); + } catch (e) { + if (options.signal?.aborted) throw e; + const message = errorText(e); + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new SetupError(`${new URL(s.base).hostname} is outside the networks this extension may reach`, "host"); + } + if (isCertificateError(message)) { + throw new SetupError( + s.ca ? `The server's certificate isn't signed by ${s.ca}` : "The server's certificate is self-signed", + "cert", + s.base, + ); + } + throw new SetupError(`Can't reach Proxmox at ${new URL(s.base).host}`, "down", s.base); + } + if (res.status === 401) throw new SetupError("Proxmox rejected the API token", "token", s.base); + if (res.status === 403) { + const text = (await res.text()).trim(); + throw new SetupError( + `Permission denied${text ? `: ${text.replace(/^\{.*"message":"?|"?\}$/g, "").slice(0, 160)}` : ""}`, + "permission", + s.base, + ); + } + if (!res.ok) { + // Proxmox puts the reason in the status text ("595 No route to host"), errors in `errors`. + let detail = res.statusText; + try { + const body = await res.json(); + if (body?.errors) detail = Object.entries(body.errors).map(([k, v]) => `${k}: ${v}`).join(", "); + else if (body?.message) detail = String(body.message).trim(); + } catch { + // Not JSON. + } + throw new Error(`Proxmox answered ${res.status}${detail ? `: ${detail}` : ""}`); + } + return (await res.json()).data as T; +} + +// --------------------------------------------------------------------------- +// Setup + +const CONFIGURE = "**Settings → Extensions → Proxmox → Configure**"; + +export function setupItems(error: SetupError): Item[] { + const base = error.base; + const guide: Record = { + url: { + subtitle: "Address, API token and CA certificate", + icon: Icons.server, + markdown: [ + "## Connect Proxmox VE", + "", + `1. In ${CONFIGURE}, set the address of a node, e.g. \`https://pve.local:8006\` or \`https://192.168.1.10:8006\`.`, + "2. Create an API token (see below) and paste its ID and secret.", + "3. Proxmox uses its own certificate authority: give Backslash its CA (see below).", + ], + }, + token: { + subtitle: "Datacenter → Permissions → API Tokens → Add", + icon: Icons.key, + markdown: [ + "## Create an API token", + "", + "In the web UI, **Datacenter → Permissions → API Tokens → Add**: user `root@pam` (or your own), token ID `backslash`.", + "", + "- Keep **Privilege Separation** checked and give the token the role **PVEVMUser** plus **PVEAuditor** on `/` (Datacenter → Permissions → Add → API Token Permission), or uncheck it to inherit the user's rights.", + "- Paste the token ID (`root@pam!backslash`) and the secret shown once in " + CONFIGURE + ".", + ], + }, + host: { + subtitle: "Use a local, private or Tailscale address", + icon: Icons.globe, + markdown: [ + "## Address not reachable from Backslash", + "", + "Extensions declare the hosts they may reach. This one covers local names (`*.local`, `*.lan`, `*.home`, `*.home.arpa`, `*.internal`), private networks (`192.168.x.x`, `10.x.x.x`, `172.16–31.x.x`) and Tailscale (`100.64.x.x`, `*.ts.net`).", + "", + `Use the node's local IP address in ${CONFIGURE}.`, + ], + }, + cert: { + subtitle: "Give Backslash the cluster's CA certificate", + icon: Icons.lock, + markdown: [ + "## Trust your Proxmox certificate", + "", + "Proxmox signs its HTTPS certificate with a CA of its own, which Backslash can't skip. Copy the CA once:", + "", + "```", + "mkdir -p ~/.config/proxmox", + "scp root@pve.local:/etc/pve/pve-root-ca.pem ~/.config/proxmox/", + "```", + "", + `Or paste the content of \`/etc/pve/pve-root-ca.pem\` in the **CA certificate** field of ${CONFIGURE}.`, + "", + "Connect with the node's name or an IP address listed in its certificate. With a Let's Encrypt certificate (Datacenter → ACME), nothing is needed.", + ], + }, + down: { + subtitle: "Is the server on, and the address right?", + icon: Icons.server, + markdown: [ + "## Proxmox doesn't answer", + "", + `Check that the node is running and that its web UI opens at \`${ + base ?? "https://…:8006" + }\`. The address is in ${CONFIGURE}.`, + ], + }, + permission: { + subtitle: "Give the API token more rights", + icon: Icons.shield, + markdown: [ + "## Missing permissions", + "", + "Listing needs **VM.Audit** and **Sys.Audit**; starting and stopping needs **VM.PowerMgmt**; snapshots need **VM.Snapshot**.", + "", + "Give the token the roles **PVEAuditor** and **PVEVMUser** on `/` in **Datacenter → Permissions → Add → API Token Permission** (**PVEVMAdmin** for snapshots).", + ], + }, + }; + const g = guide[error.kind]; + const actions = []; + if (base && error.kind !== "host") actions.push(open(base, "Open Proxmox Web UI", { icon: Icons.globe })); + if (error.kind === "token" || error.kind === "url" || error.kind === "permission") { + actions.push(open(TOKEN_DOCS, "API Token Documentation", { icon: Icons.book, section: "Help" })); + } + if (error.kind === "cert") { + actions.push( + copy( + "mkdir -p ~/.config/proxmox && scp root@pve.local:/etc/pve/pve-root-ca.pem ~/.config/proxmox/", + "Copy Commands", + { section: "Copy" }, + ), + ); + } + return [{ + id: "setup", + title: error.message, + subtitle: g.subtitle, + icon: { source: g.icon, tint: "orange" }, + detail: { markdown: g.markdown.join("\n") }, + actions, + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof SetupError) return setupItems(e); + throw e; + } +} diff --git a/extensions/proxmox/backslash.toml b/extensions/proxmox/backslash.toml new file mode 100644 index 0000000..682753b --- /dev/null +++ b/extensions/proxmox/backslash.toml @@ -0,0 +1,80 @@ +[extension] +id = "proxmox" +title = "Proxmox" +description = "Nodes, virtual machines and containers of Proxmox VE with CPU, memory and uptime; start, shut down, reboot, consoles and snapshots" +version = "1.0.0" +author = "Backslash" +icon = "bs-server" +license = "MIT" + +# Proxmox runs on your own servers, and hosts must be declared ahead: local names, +# private IPv4 ranges (Deno's --allow-net accepts CIDR) and Tailscale. A public +# domain is out of reach. Same list as HOSTS in api.ts. +# ~/.config/proxmox holds the cluster's CA certificate (pve-root-ca.pem): Deno can't +# skip certificate checks, so Backslash trusts that CA with Deno.createHttpClient. +[permissions] +net = [ + "localhost", + "127.0.0.1", + "*.local", + "*.lan", + "*.home", + "*.home.arpa", + "*.internal", + "*.ts.net", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", +] +read = ["~/.config/proxmox"] +env = ["HOME"] + +[[commands]] +name = "proxmox" +title = "Proxmox" +description = "Running, stopped or all VMs and containers, by node" +icon = "bs-server" +keywords = [ + "proxmox", + "pve", + "vm", + "virtual machine", + "machine virtuelle", + "lxc", + "container", + "conteneur", + "hypervisor", + "hyperviseur", + "homelab", + "qemu", + "kvm", + "console", + "snapshot", + "instantané", +] + +[[preferences]] +name = "url" +title = "Server address" +description = "A node's web UI, e.g. https://pve.local:8006 or https://192.168.1.10:8006" +required = true + +[[preferences]] +name = "tokenId" +title = "API token ID" +description = "Datacenter → Permissions → API Tokens → Add, e.g. root@pam!backslash (role PVEAuditor + PVEVMUser on /)" +required = true + +[[preferences]] +name = "tokenSecret" +title = "API token secret" +description = "Shown once when the token is created" +type = "secret" +required = true + +[[preferences]] +name = "caFile" +title = "CA certificate" +description = "For Proxmox's own certificates: copy /etc/pve/pve-root-ca.pem to ~/.config/proxmox/ (the default path), or paste its content here" +default = "~/.config/proxmox/pve-root-ca.pem" diff --git a/extensions/proxmox/commands/proxmox.ts b/extensions/proxmox/commands/proxmox.ts new file mode 100644 index 0000000..2b4c4a6 --- /dev/null +++ b/extensions/proxmox/commands/proxmox.ts @@ -0,0 +1,433 @@ +import { + type Action, + action, + type Context, + copy, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + pop, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { guarded, pve, type Server, server, SetupError } from "../api.ts"; +import { + bytes, + consoleUrl, + type Guest, + guestLook, + guestName, + isGuest, + kindTitle, + matches, + nodeLook, + type NodeStatus, + pct, + plural, + ratio, + type Resource, + sectionOf, + type Snapshot, + snapshotName, + snapshotTime, + sortResources, + sortSnapshots, + summary, + tagsOf, + uptime, + webUrl, +} from "../lib.ts"; + +const POLL_MS = 5_000; +/** Right after a start or stop, to follow the task. */ +const FAST_POLL_MS = 1_500; + +// --- Live reload --------------------------------------------------------------- + +const changes = new EventTarget(); +let fastUntil = 0; +function changed() { + fastUntil = Date.now() + 20_000; + changes.dispatchEvent(new Event("change")); +} + +function wait(ms: number, signal: AbortSignal): Promise { + return 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); + }); +} + +// --- Actions ------------------------------------------------------------------- + +const guestPath = (g: Guest) => `/nodes/${encodeURIComponent(g.node)}/${g.type}/${g.vmid}`; + +const VERBS: Record = { + start: ["Starting", "started"], + shutdown: ["Shutting down", "shut down"], + stop: ["Stopping", "stopped"], + reboot: ["Rebooting", "rebooted"], + suspend: ["Pausing", "paused"], + resume: ["Resuming", "resumed"], +}; + +function power(s: Server, g: Guest, verb: keyof typeof VERBS, title: string, options: Parameters[2]) { + return action(title, async () => { + await pve(s, `${guestPath(g)}/status/${verb}`, { method: "POST", body: {} }); + await toast(`${VERBS[verb][0]} ${guestName(g)}…`, { style: "success" }); + changed(); + }, options); +} + +function snapshotForm(s: Server, g: Guest) { + return form({ + title: `Snapshot ${guestName(g)}`, + submitTitle: "Take Snapshot", + fields: [ + field.text("name", "Name", { placeholder: snapshotName(""), required: false }), + field.textArea("description", "Description", { placeholder: "Before upgrading to …" }), + ...(g.type === "qemu" && g.status === "running" ? [field.checkbox("vmstate", "Include RAM")] : []), + ], + async submit(values) { + const name = snapshotName(String(values.name ?? "")); + await pve(s, `${guestPath(g)}/snapshot`, { + method: "POST", + body: { + snapname: name, + ...(values.description ? { description: String(values.description) } : {}), + ...(values.vmstate ? { vmstate: 1 } : {}), + }, + }); + await toast(`Taking snapshot ${name}…`, { style: "success" }); + changed(); + return pop(); + }, + }); +} + +function snapshotItem(s: Server, g: Guest, snap: Snapshot): Item { + return { + id: snap.name, + title: snap.name, + subtitle: snap.description?.split("\n")[0], + icon: { source: Icons.history, tint: "blue" }, + keywords: [snap.description ?? ""], + accessories: [ + ...(snap.vmstate ? [{ tag: "RAM", color: "purple" as const }] : []), + ...(snap.snaptime ? [{ text: snapshotTime(snap) }] : []), + ], + detail: { + markdown: snap.description || undefined, + metadata: [ + meta.text("Name", snap.name, { icon: Icons.tag }), + ...(snap.snaptime ? [meta.text("Taken", snapshotTime(snap), { icon: Icons.clock })] : []), + ...(snap.parent ? [meta.text("Parent", snap.parent, { icon: Icons.branch })] : []), + meta.text("RAM included", snap.vmstate ? "Yes" : "No", { icon: Icons.cpu }), + meta.text("Guest", `${guestName(g)} (${g.vmid})`, { icon: g.type === "lxc" ? Icons.box : Icons.monitor }), + ], + }, + actions: [ + action("Take Snapshot", () => snapshotForm(s, g), { icon: Icons.plus, shortcut: "n" }), + open(webUrl(s.base, g), "Open in Web UI", { icon: Icons.globe }), + copy(snap.name, "Copy Snapshot Name", { section: "Copy" }), + ], + }; +} + +function snapshotList(s: Server, g: Guest) { + let count = 0; + return list({ + title: `Snapshots of ${guestName(g)}`, + placeholder: "Filter snapshots…", + empty: { + title: "No snapshot", + description: "Take one before an upgrade: Ctrl+N", + icon: Icons.history, + }, + status: () => plural(count, "snapshot"), + actions: [ + action("Take Snapshot", () => snapshotForm(s, g), { icon: Icons.plus, shortcut: "n" }), + open(webUrl(s.base, g), "Open in Web UI", { icon: Icons.globe }), + ], + // Reloads after a snapshot is taken from here (Proxmox needs a moment to list it). + async *items({ signal }) { + while (!signal.aborted) { + const snaps = sortSnapshots(await pve(s, `${guestPath(g)}/snapshot`, { signal })); + count = snaps.length; + yield snaps.map((snap) => snapshotItem(s, g, snap)); + await wait(Date.now() < fastUntil ? FAST_POLL_MS * 2 : 60_000, signal); + } + }, + }); +} + +// --- Items ------------------------------------------------------------------- + +function guestItem(g: Guest, s: Server, nodes: number): Item { + const look = guestLook(g); + const running = g.status === "running"; + const cpu = g.cpu ?? 0, mem = ratio(g.mem, g.maxmem); + const tags = tagsOf(g); + const metadata: Metadata[] = [ + meta.text("Status", look.label, { icon: { source: look.icon, tint: look.tint }, color: look.tint }), + ]; + if (running) { + metadata.push( + meta.progress("CPU", cpu, `${pct(cpu)} of ${plural(g.maxcpu ?? 1, "core")}`), + meta.progress("Memory", mem, `${bytes(g.mem)} of ${bytes(g.maxmem)}`), + ); + if (g.maxdisk && g.disk) { + metadata.push(meta.progress("Disk", ratio(g.disk, g.maxdisk), `${bytes(g.disk)} of ${bytes(g.maxdisk)}`)); + } + metadata.push(meta.text("Uptime", uptime(g.uptime), { icon: Icons.clock })); + } else { + metadata.push( + meta.text("Cores", String(g.maxcpu ?? "–"), { icon: Icons.cpu }), + meta.text("Memory", bytes(g.maxmem), { icon: Icons.layers }), + meta.text("Disk", bytes(g.maxdisk), { icon: Icons.drive }), + ); + } + metadata.push( + meta.separator(), + meta.text("Type", kindTitle(g), { icon: g.type === "lxc" ? Icons.box : Icons.monitor }), + meta.text("ID", String(g.vmid), { icon: Icons.hash }), + meta.text("Node", g.node, { icon: Icons.server }), + ); + if (tags.length) metadata.push(meta.tags("Tags", tags.map((t) => ({ text: t, color: "blue" as const })))); + if (g.pool) metadata.push(meta.text("Pool", g.pool, { icon: Icons.folder })); + if (g.hastate) metadata.push(meta.text("HA", g.hastate, { icon: Icons.shield })); + if (running && (g.netin || g.netout)) { + metadata.push(meta.text("Network", `↓ ${bytes(g.netin)} · ↑ ${bytes(g.netout)}`, { icon: Icons.activity })); + } + metadata.push(meta.link("Web UI", `${new URL(s.base).host}`, webUrl(s.base, g))); + + const name = guestName(g); + const consoleAction = open(consoleUrl(s.base, g), "Open Console", { icon: Icons.terminal, shortcut: "o" }); + const actions: Action[] = []; + if (g.template) { + actions.push(open(webUrl(s.base, g), "Open in Web UI", { icon: Icons.globe })); + } else if (running) { + actions.push( + consoleAction, + power(s, g, "shutdown", "Shut Down", { + icon: Icons.power, + shortcut: "s", + confirm: `Shut down ${name}?`, + section: "Power", + }), + power(s, g, "reboot", "Reboot", { + icon: Icons.refresh, + shortcut: "r", + confirm: `Reboot ${name}?`, + section: "Power", + }), + ...(g.type === "qemu" ? [power(s, g, "suspend", "Pause", { icon: Icons.pause, section: "Power" })] : []), + power(s, g, "stop", "Stop (Pull the Plug)", { + icon: Icons.stop, + destructive: true, + confirm: `Stop ${name} now? Unsaved data in the guest is lost`, + section: "Power", + }), + ); + } else if (g.status === "paused" || g.status === "suspended") { + actions.push(power(s, g, "resume", "Resume", { icon: Icons.play }), consoleAction); + } else { + actions.push(power(s, g, "start", "Start", { icon: Icons.play }), consoleAction); + } + if (!g.template) { + actions.push( + action("Snapshots", () => snapshotList(s, g), { icon: Icons.history, shortcut: "s", section: "Snapshots" }), + action("Take Snapshot", () => snapshotForm(s, g), { + icon: Icons.plus, + shortcut: "n", + section: "Snapshots", + }), + ); + } + if (!g.template) actions.push(open(webUrl(s.base, g), "Open in Web UI", { icon: Icons.globe, section: "Proxmox" })); + actions.push( + copy(String(g.vmid), "Copy ID", { section: "Copy", shortcut: "c" }), + copy(name, "Copy Name", { section: "Copy" }), + ); + + return { + id: g.id, + title: name, + subtitle: nodes > 1 ? `${g.vmid} · ${g.node}` : String(g.vmid), + icon: { source: look.icon, tint: look.tint }, + section: sectionOf(g), + keywords: [String(g.vmid), g.node, look.label, kindTitle(g), ...tags], + accessories: running + ? [ + { text: pct(cpu), icon: Icons.cpu, tooltip: "CPU", color: cpu > 0.9 ? "red" : undefined }, + { + text: bytes(g.mem), + icon: Icons.layers, + tooltip: `Memory, of ${bytes(g.maxmem)}`, + color: mem > 0.9 ? "red" : undefined, + }, + { text: uptime(g.uptime), icon: Icons.clock, tooltip: "Uptime" }, + ] + : [{ tag: look.label, color: look.tint }], + detail: { + stats: running + ? [ + { label: "CPU", value: pct(cpu), color: cpu > 0.9 ? "red" : undefined }, + { label: "Memory", value: pct(mem), color: mem > 0.9 ? "red" : undefined }, + { label: "Uptime", value: uptime(g.uptime) }, + ] + : undefined, + metadata, + }, + actions, + }; +} + +function nodeItem(n: Resource, status: NodeStatus | undefined, s: Server, guests: Guest[]): Item { + const look = nodeLook(n); + const name = n.node ?? n.name ?? n.id; + const online = n.status === "online"; + const cpu = n.cpu ?? 0, mem = ratio(n.mem, n.maxmem), disk = ratio(n.disk, n.maxdisk); + const mine = guests.filter((g) => g.node === name && !g.template); + const up = mine.filter((g) => g.status === "running").length; + const metadata: Metadata[] = [ + meta.text("Status", look.label, { icon: { source: Icons.server, tint: look.tint }, color: look.tint }), + ]; + if (online) { + metadata.push( + meta.progress("CPU", cpu, `${pct(cpu)} of ${plural(n.maxcpu ?? 1, "CPU")}`), + meta.progress("Memory", mem, `${bytes(n.mem)} of ${bytes(n.maxmem)}`), + meta.progress("Root disk", disk, `${bytes(n.disk)} of ${bytes(n.maxdisk)}`), + ); + if (status?.swap?.total) { + metadata.push( + meta.progress( + "Swap", + ratio(status.swap.used, status.swap.total), + `${bytes(status.swap.used)} of ${bytes(status.swap.total)}`, + ), + ); + } + if (status?.loadavg) metadata.push(meta.text("Load", status.loadavg.join(" · "), { icon: Icons.activity })); + metadata.push(meta.text("Uptime", uptime(n.uptime), { icon: Icons.clock })); + } + metadata.push(meta.separator(), meta.text("Guests", `${up} of ${mine.length} running`, { icon: Icons.monitor })); + if (status?.cpuinfo?.model) { + metadata.push(meta.text("CPU model", status.cpuinfo.model.replace(/\s+/g, " "), { icon: Icons.cpu })); + } + if (status?.pveversion) { + metadata.push(meta.text("Version", status.pveversion.replace(/^pve-manager\//, ""), { icon: Icons.tag })); + } + if (status?.kversion) { + metadata.push(meta.text("Kernel", status.kversion.split(" ").slice(0, 2).join(" "), { icon: Icons.code })); + } + + return { + id: n.id, + title: name, + subtitle: online ? `${up} of ${plural(mine.length, "guest")} running` : look.label, + icon: { source: Icons.server, tint: look.tint }, + section: "Nodes", + keywords: ["node", look.label], + accessories: online + ? [ + { text: pct(cpu), icon: Icons.cpu, tooltip: "CPU", color: cpu > 0.9 ? "red" : undefined }, + { + text: pct(mem), + icon: Icons.layers, + tooltip: `Memory: ${bytes(n.mem)} of ${bytes(n.maxmem)}`, + color: mem > 0.9 ? "red" : undefined, + }, + { text: uptime(n.uptime), icon: Icons.clock, tooltip: "Uptime" }, + ] + : [{ tag: look.label, color: look.tint }], + detail: { + stats: online + ? [ + { label: "CPU", value: pct(cpu), color: cpu > 0.9 ? "red" : undefined }, + { label: "Memory", value: pct(mem), color: mem > 0.9 ? "red" : undefined }, + { label: "Guests", value: `${up}/${mine.length}` }, + ] + : undefined, + metadata, + }, + actions: [ + open(consoleUrl(s.base, n), "Open Shell", { icon: Icons.terminal }), + open(webUrl(s.base, n), "Open in Web UI", { icon: Icons.globe, shortcut: "o" }), + copy(name, "Copy Node Name", { section: "Copy" }), + ], + }; +} + +// --- Command ------------------------------------------------------------------- + +export default (_ctx: Context) => { + let shown = ""; + return list({ + placeholder: "Filter VMs, containers and nodes by name, ID, tag…", + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "stopped", title: "Stopped" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "Nothing here", description: "No guest matches this view", icon: Icons.server }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : shown, + async *items({ preferences, signal, filter }) { + let s: Server; + try { + s = await server(preferences); + } catch (e) { + if (!(e instanceof SetupError)) throw e; + yield await guarded(() => Promise.reject(e)); + return; + } + const statuses = new Map(); + while (!signal.aborted) { + const items = await guarded(async () => { + const all = await pve(s, "/cluster/resources", { signal }); + const nodes = all.filter((r) => r.type === "node"); + // Node details (load, versions) once; the resources call has the live numbers. + await Promise.all( + nodes.filter((n) => n.status === "online" && !statuses.has(n.node!)).map(async (n) => { + const st = await pve(s, `/nodes/${encodeURIComponent(n.node!)}/status`, { signal }) + .catch(() => undefined); + if (st) statuses.set(n.node!, st); + }), + ); + const guests = all.filter(isGuest); + const line = summary(all); + updateCommandMetadata({ subtitle: line }).catch(() => {}); + const real = guests.filter((g) => !g.template); + shown = [ + plural(nodes.length, "node"), + line ?? "No guest", + real.filter((g) => g.status !== "running").length + ? `${real.filter((g) => g.status !== "running").length} stopped` + : "", + ].filter(Boolean).join(" · "); + return sortResources(all.filter((r) => matches(r, filter))).map((r) => + r.type === "node" ? nodeItem(r, statuses.get(r.node!), s, guests) : guestItem(r as Guest, s, nodes.length) + ); + }); + yield items; + if (items[0]?.id === "setup") return; + await wait(Date.now() < fastUntil ? FAST_POLL_MS : POLL_MS, signal); + } + }, + }); +}; diff --git a/extensions/proxmox/lib.ts b/extensions/proxmox/lib.ts new file mode 100644 index 0000000..51fcedc --- /dev/null +++ b/extensions/proxmox/lib.ts @@ -0,0 +1,257 @@ +// Proxmox VE API shapes, formatting and URLs. No I/O here. + +import type { Color } from "@backslash/api"; +import { Icons } from "@backslash/api"; + +/** One row of `GET /cluster/resources`. */ +export interface Resource { + id: string; + type: "node" | "qemu" | "lxc" | "storage" | "pool" | "sdn" | string; + node?: string; + vmid?: number; + name?: string; + status?: string; + cpu?: number; + maxcpu?: number; + mem?: number; + maxmem?: number; + disk?: number; + maxdisk?: number; + uptime?: number; + template?: number; + tags?: string; + netin?: number; + netout?: number; + diskread?: number; + diskwrite?: number; + hastate?: string; + lock?: string; + level?: string; + pool?: string; + storage?: string; +} + +/** `GET /nodes/{node}/status`. */ +export interface NodeStatus { + pveversion?: string; + kversion?: string; + loadavg?: string[]; + cpuinfo?: { model?: string; cpus?: number; sockets?: number; cores?: number }; + memory?: { used: number; total: number }; + swap?: { used: number; total: number }; + rootfs?: { used: number; total: number }; + uptime?: number; +} + +export interface Snapshot { + name: string; + description?: string; + snaptime?: number; + parent?: string; + vmstate?: number; + running?: number; +} + +export type Guest = Resource & { type: "qemu" | "lxc"; vmid: number; node: string }; + +export const isGuest = (r: Resource): r is Guest => + (r.type === "qemu" || r.type === "lxc") && typeof r.vmid === "number" && !!r.node; + +// --------------------------------------------------------------------------- +// Formatting + +export function bytes(n: number | undefined): string { + if (!n || n < 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + const v = n / 1024 ** i; + return `${i === 0 ? v : v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +export function uptime(seconds: number | undefined): string { + if (!seconds) return "–"; + const d = Math.floor(seconds / 86_400), h = Math.floor(seconds % 86_400 / 3600), m = Math.floor(seconds % 3600 / 60); + if (d) return `${d} d ${h} h`; + if (h) return `${h} h ${m} min`; + return `${Math.max(1, m)} min`; +} + +export const pct = (ratio: number) => `${Math.round(ratio * 100)} %`; + +export const ratio = (used: number | undefined, total: number | undefined) => + total ? Math.max(0, Math.min(1, (used ?? 0) / total)) : 0; + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** Proxmox tags are `a;b` (or `a,b` / spaces in older versions). */ +export const tagsOf = (r: Resource) => (r.tags ?? "").split(/[;,\s]+/).filter(Boolean); + +export const kindTitle = (r: Resource) => r.type === "lxc" ? "Container" : "Virtual Machine"; + +export const guestName = (g: Guest) => g.name || `${g.type === "lxc" ? "CT" : "VM"} ${g.vmid}`; + +export interface Look { + label: string; + tint: Color; + icon: string; +} + +export function guestLook(g: Guest): Look { + const glyph = g.type === "lxc" ? Icons.box : Icons.monitor; + if (g.template) return { label: "Template", tint: "gray", icon: Icons.layers }; + if (g.lock) return { label: `Locked (${g.lock})`, tint: "orange", icon: Icons.lock }; + switch (g.status) { + case "running": + return { label: "Running", tint: "green", icon: glyph }; + case "paused": + case "suspended": + return { label: "Paused", tint: "yellow", icon: glyph }; + case "stopped": + return { label: "Stopped", tint: "gray", icon: glyph }; + default: + return { + label: g.status ? g.status[0].toUpperCase() + g.status.slice(1) : "Unknown", + tint: "orange", + icon: glyph, + }; + } +} + +export function nodeLook(n: Resource): Look { + return n.status === "online" + ? { label: "Online", tint: "green", icon: Icons.server } + : { label: n.status === "offline" ? "Offline" : (n.status ?? "Unknown"), tint: "red", icon: Icons.server }; +} + +export type Filter = "running" | "stopped" | "all"; + +export function matches(r: Resource, filter: string | undefined): boolean { + if (r.type === "node") return filter !== "stopped"; + if (!isGuest(r)) return false; + if (filter === "running") return r.status === "running"; + if (filter === "stopped") return r.status !== "running" && !r.template; + return true; +} + +export function sectionOf(r: Resource): string { + if (r.type === "node") return "Nodes"; + if (r.template) return "Templates"; + return r.type === "lxc" ? "Containers" : "Virtual Machines"; +} + +const SECTIONS = ["Nodes", "Virtual Machines", "Containers", "Templates"]; + +/** Nodes, VMs, containers, templates; running first, then by VMID. */ +export function sortResources(rs: Resource[]): Resource[] { + return [...rs].sort((a, b) => + SECTIONS.indexOf(sectionOf(a)) - SECTIONS.indexOf(sectionOf(b)) || + Number(b.status === "running" || b.status === "online") - Number(a.status === "running" || a.status === "online") || + (a.vmid ?? 0) - (b.vmid ?? 0) || + (a.node ?? "").localeCompare(b.node ?? "") + ); +} + +/** "5 of 8 running" for the root search. */ +export function summary(rs: Resource[]): string | null { + const guests = rs.filter(isGuest).filter((g) => !g.template); + if (!guests.length) return null; + const running = guests.filter((g) => g.status === "running").length; + return `${running} of ${guests.length} running`; +} + +// --------------------------------------------------------------------------- +// URLs + +/** `pve.local` → `https://pve.local:8006`; drops a path or `#v1:…` hash. */ +export function normalizeUrl(input: unknown): string { + let url = String(input ?? "").trim(); + if (!url) return ""; + if (!/^https?:\/\//i.test(url)) url = `https://${url}`; + try { + const u = new URL(url); + if (!u.port && u.protocol === "https:" && !/^https:\/\/[^/]+:443\b/.test(url)) u.port = "8006"; + return `${u.protocol}//${u.host}`; + } catch { + return url.replace(/\/+$/, ""); + } +} + +/** The noVNC (VM) or xterm.js (container, node shell) console the web UI opens in a pop-up. */ +export function consoleUrl(base: string, r: Resource): string { + const p = new URLSearchParams(); + if (r.type === "node") { + p.set("console", "shell"); + p.set("xtermjs", "1"); + p.set("vmid", "0"); + p.set("vmname", ""); + p.set("node", r.node ?? r.name ?? ""); + } else { + p.set("console", r.type === "lxc" ? "lxc" : "kvm"); + p.set(r.type === "lxc" ? "xtermjs" : "novnc", "1"); + p.set("vmid", String(r.vmid)); + p.set("vmname", r.name ?? ""); + p.set("node", r.node ?? ""); + if (r.type !== "lxc") p.set("resize", "off"); + } + p.set("cmd", ""); + return `${base}/?${p}`; +} + +/** The resource selected in the web UI's tree. */ +export function webUrl(base: string, r: Resource): string { + return `${base}/#v1:0:=${encodeURIComponent(r.id)}:4:::::::`; +} + +export const TOKEN_DOCS = "https://pve.proxmox.com/wiki/User_Management#pveum_tokens"; + +/** `PVEAPIToken=root@pam!backslash=xxxxxxxx-…`. */ +export function authHeader(tokenId: string, secret: string): string { + return `PVEAPIToken=${tokenId.trim()}=${secret.trim()}`; +} + +export const isTokenId = (id: string) => /^[^@\s]+@[^!\s]+![\w.-]+$/.test(id.trim()); + +// --------------------------------------------------------------------------- +// Certificates + +/** + * A PEM certificate from what the user gave: the file's text, or the same + * pasted into a one-line field (newlines lost). + */ +export function normalizePem(text: string): string | undefined { + const blocks = [...text.matchAll(/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g)]; + if (!blocks.length) return undefined; + return blocks.map((m) => { + const body = m[1].replace(/\s+/g, ""); + const lines = body.match(/.{1,64}/g) ?? []; + return ["-----BEGIN CERTIFICATE-----", ...lines, "-----END CERTIFICATE-----"].join("\n"); + }).join("\n") + "\n"; +} + +export const isCertificateError = (message: string) => + /certificate|UnknownIssuer|self[- ]signed|NotValidForName|invalid peer|tls|ssl/i.test(message); + +export function expandHome(path: string, home: string | undefined): string { + if (!home) return path; + return path === "~" ? home : path.startsWith("~/") ? `${home}${path.slice(1)}` : path; +} + +export function snapshotTime(s: Snapshot): string { + if (!s.snaptime) return ""; + return new Date(s.snaptime * 1000).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }); +} + +/** Newest first, without the "current" pseudo-snapshot ("You are here"). */ +export function sortSnapshots(list: Snapshot[]): Snapshot[] { + return list.filter((s) => s.name !== "current").sort((a, b) => (b.snaptime ?? 0) - (a.snaptime ?? 0)); +} + +/** Proxmox snapshot names: a letter, then letters, digits, `-` and `_`, up to 40. */ +export function snapshotName(input: string, now = new Date()): string { + const clean = input.trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^[^A-Za-z]+/, "").slice(0, 40); + if (clean) return clean; + const p = (n: number) => String(n).padStart(2, "0"); + return `backslash_${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}_${p(now.getHours())}${ + p(now.getMinutes()) + }`; +} diff --git a/extensions/proxmox/lib_test.ts b/extensions/proxmox/lib_test.ts new file mode 100644 index 0000000..a145899 --- /dev/null +++ b/extensions/proxmox/lib_test.ts @@ -0,0 +1,159 @@ +import { assert, assertEquals } from "@std/assert"; +import { parse } from "@std/toml"; +import { errorText, HOSTS, isAllowedHost, SetupError, setupItems } from "./api.ts"; +import { + authHeader, + bytes, + consoleUrl, + type Guest, + guestLook, + isCertificateError, + isTokenId, + matches, + normalizePem, + normalizeUrl, + type Resource, + snapshotName, + sortResources, + sortSnapshots, + summary, + tagsOf, + uptime, + webUrl, +} from "./lib.ts"; + +// Shaped like `GET /api2/json/cluster/resources`. +const RESOURCES: Resource[] = [ + { id: "storage/pve/local-lvm", type: "storage", node: "pve", storage: "local-lvm", status: "available" }, + { + id: "lxc/200", + type: "lxc", + vmid: 200, + name: "pihole", + node: "pve", + status: "running", + cpu: 0.002, + maxcpu: 1, + mem: 125829120, + maxmem: 536870912, + uptime: 600000, + tags: "dns;infra", + }, + { id: "qemu/9000", type: "qemu", vmid: 9000, name: "debian-12-cloud", node: "pve", status: "stopped", template: 1 }, + { id: "qemu/101", type: "qemu", vmid: 101, name: "win11", node: "pve", status: "stopped", maxcpu: 8 }, + { id: "node/pve", type: "node", node: "pve", status: "online", cpu: 0.12, maxcpu: 16, mem: 25e9, maxmem: 64e9 }, + { + id: "qemu/100", + type: "qemu", + vmid: 100, + name: "home-assistant", + node: "pve", + status: "running", + cpu: 0.03, + maxcpu: 2, + }, +]; + +const PEM = `-----BEGIN CERTIFICATE----- +MIIBszCCAVmgAwIBAgIUQ0FfVGVzdENlcnRpZmljYXRlMDAwMDAwMDAwMDAwMDAw +MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw +MDAwMA== +-----END CERTIFICATE----- +`; + +Deno.test("hosts match the manifest", async () => { + const manifest = parse(await Deno.readTextFile(new URL("./backslash.toml", import.meta.url))) as { + permissions: { net: string[] }; + }; + assertEquals(manifest.permissions.net, HOSTS); + assert(isAllowedHost("pve.local")); + assert(isAllowedHost("pve.home")); + assert(isAllowedHost("192.168.1.10")); + assert(isAllowedHost("100.101.102.103")); + assert(!isAllowedHost("pve.example.com")); +}); + +Deno.test("addresses", () => { + assertEquals(normalizeUrl("pve.local"), "https://pve.local:8006"); + assertEquals(normalizeUrl("https://192.168.1.10:8006/#v1:0:=qemu%2F100:4"), "https://192.168.1.10:8006"); + assertEquals(normalizeUrl("https://pve.lan:443"), "https://pve.lan"); + assertEquals(normalizeUrl(""), ""); +}); + +Deno.test("API tokens", () => { + assert(isTokenId("root@pam!backslash")); + assert(isTokenId("me@pve!launcher-2")); + assert(!isTokenId("root@pam")); + assert(!isTokenId("backslash")); + assertEquals(authHeader(" root@pam!bs ", "abc "), "PVEAPIToken=root@pam!bs=abc"); +}); + +Deno.test("CA certificates, from a file or pasted on one line", () => { + assertEquals(normalizePem(PEM), PEM); + assertEquals(normalizePem(PEM.replace(/\n/g, " ")), PEM); + assertEquals(normalizePem("not a certificate"), undefined); + assert(isCertificateError("client error (Connect): invalid peer certificate: UnknownIssuer")); + assert(!isCertificateError("Connection refused (os error 111)")); + const err = new TypeError("fetch failed", { cause: new Error("invalid peer certificate: UnknownIssuer") }); + assert(isCertificateError(errorText(err))); +}); + +Deno.test("filters and order", () => { + const ids = (f: string) => sortResources(RESOURCES.filter((r) => matches(r, f))).map((r) => r.id); + assertEquals(ids("all"), ["node/pve", "qemu/100", "qemu/101", "lxc/200", "qemu/9000"]); + assertEquals(ids("running"), ["node/pve", "qemu/100", "lxc/200"]); + assertEquals(ids("stopped"), ["qemu/101"]); + assertEquals(summary(RESOURCES), "2 of 3 running"); + assertEquals(summary([]), null); +}); + +Deno.test("looks and formatting", () => { + const g = (o: Partial) => ({ id: "qemu/1", type: "qemu", vmid: 1, node: "pve", ...o }) as Guest; + assertEquals(guestLook(g({ status: "running" })).tint, "green"); + assertEquals(guestLook(g({ status: "stopped" })).label, "Stopped"); + assertEquals(guestLook(g({ status: "running", lock: "backup" })).label, "Locked (backup)"); + assertEquals(guestLook(g({ template: 1 })).label, "Template"); + assertEquals(tagsOf({ id: "x", type: "qemu", tags: "dns;infra" }), ["dns", "infra"]); + assertEquals(bytes(4294967296), "4.0 GiB"); + assertEquals(bytes(125829120), "120 MiB"); + assertEquals(uptime(86400 * 3 + 7200), "3 d 2 h"); + assertEquals(uptime(0), "–"); +}); + +Deno.test("console and web UI URLs", () => { + const base = "https://pve.local:8006"; + assertEquals( + consoleUrl(base, RESOURCES[5]), + "https://pve.local:8006/?console=kvm&novnc=1&vmid=100&vmname=home-assistant&node=pve&resize=off&cmd=", + ); + assertEquals( + consoleUrl(base, RESOURCES[1]), + "https://pve.local:8006/?console=lxc&xtermjs=1&vmid=200&vmname=pihole&node=pve&cmd=", + ); + assertEquals( + consoleUrl(base, RESOURCES[4]), + "https://pve.local:8006/?console=shell&xtermjs=1&vmid=0&vmname=&node=pve&cmd=", + ); + assertEquals(webUrl(base, RESOURCES[5]), "https://pve.local:8006/#v1:0:=qemu%2F100:4:::::::"); +}); + +Deno.test("snapshots", () => { + const sorted = sortSnapshots([ + { name: "current", description: "You are here!", parent: "b" }, + { name: "a", snaptime: 100 }, + { name: "b", snaptime: 200 }, + ]); + assertEquals(sorted.map((s) => s.name), ["b", "a"]); + assertEquals(snapshotName("before upgrade!"), "before_upgrade_"); + assertEquals(snapshotName("2026 release"), "release"); + assertEquals(snapshotName("", new Date(2026, 9, 28, 9, 5)), "backslash_20261028_0905"); +}); + +Deno.test("setup rows", () => { + const [cert] = setupItems( + new SetupError("The server's certificate is self-signed", "cert", "https://pve.local:8006"), + ); + assert(cert.detail?.markdown?.includes("pve-root-ca.pem")); + const [token] = setupItems(new SetupError("Add an API token", "token", "https://pve.local:8006")); + assertEquals(token.actions?.map((a) => a.title), ["Open Proxmox Web UI", "API Token Documentation"]); +}); diff --git a/extensions/stack-overflow/api.ts b/extensions/stack-overflow/api.ts new file mode 100644 index 0000000..ca2f12a --- /dev/null +++ b/extensions/stack-overflow/api.ts @@ -0,0 +1,117 @@ +// Stack Exchange API 2.3, with a small cache in the extension's storage to spare the daily quota. + +import { storage } from "@backslash/api"; +import { + type Answer, + apiError, + type Cached, + cacheKey, + fresh, + mapAnswer, + mapPage, + mapQuestion, + type Page, + type Question, + searchParams, + type Site, + touch, +} from "./lib.ts"; + +const API = "https://api.stackexchange.com/2.3"; +const SEARCH_TTL = 60 * 60 * 1000; // an hour +const ANSWERS_TTL = 6 * 60 * 60 * 1000; +const MAX_CACHED = 120; + +export interface Quota { + remaining: number; + max: number; + at: number; +} + +async function remember(key: string, value: T) { + try { + await storage.set(key, { at: Date.now(), value } as never); + const keys = (await storage.get("cache-keys")) ?? []; + const { keys: next, evicted } = touch(keys, key, MAX_CACHED); + await storage.set("cache-keys", next); + for (const k of evicted) await storage.delete(k); + } catch { + // The cache is a convenience. + } +} + +const recall = async (key: string) => + (await storage.get(key).catch(() => undefined)) as unknown as Cached | undefined; + +async function get(path: string, params: URLSearchParams, signal?: AbortSignal): Promise { + const until = Number(await storage.get("backoff-until").catch(() => 0)) || 0; + if (until > Date.now()) { + throw new Error(`Stack Exchange asked to wait ${Math.ceil((until - Date.now()) / 1000)} s between searches`); + } + let res: Response; + try { + res = await fetch(`${API}${path}?${params}`, { signal }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Error("Stack Exchange can't be reached: check your connection"); + } + const json = await res.json().catch(() => ({})); + if (!res.ok || json.error_id) throw new Error(apiError(res.status, json)); + if (typeof json.backoff === "number") await storage.set("backoff-until", Date.now() + json.backoff * 1000); + if (typeof json.quota_remaining === "number") { + await storage.set("quota", { remaining: json.quota_remaining, max: json.quota_max ?? 300, at: Date.now() }) + .catch(() => {}); + } + return json; +} + +export const quota = async () => (await storage.get("quota").catch(() => undefined)) as unknown as Quota | undefined; + +/** Questions matching the query, with their bodies; cached for an hour. */ +export async function searchQuestions( + input: string, + site: Site, + key: string | undefined, + signal?: AbortSignal, +): Promise & { cached: boolean }> { + const params = searchParams(input, site, key); + if (!params) return { items: [], cached: true }; + const ck = cacheKey("search", site, input); + const cached = await recall(ck); + if (fresh(cached, SEARCH_TTL)) return { items: cached.value, cached: true }; + const page = mapPage(await get("/search/advanced", params, signal), mapQuestion); + await remember(ck, page.items); + return { ...page, cached: false }; +} + +/** The answers of a question, best first, with their bodies; cached for six hours. */ +export async function answersOf(q: Question, site: Site, key: string | undefined, signal?: AbortSignal) { + const ck = cacheKey("answers", site, String(q.id)); + const cached = await recall(ck); + if (fresh(cached, ANSWERS_TTL)) return cached.value; + const params = new URLSearchParams({ + order: "desc", + sort: "votes", + site: site.id, + filter: "withbody", + pagesize: "5", + }); + if (key) params.set("key", key); + const page = mapPage(await get(`/questions/${q.id}/answers`, params, signal), mapAnswer); + await remember(ck, page.items); + return page.items; +} + +/** Questions opened lately, most recent first. */ +export async function recent(site: Site): Promise { + return ((await storage.get(`recent:${site.id}`).catch(() => undefined)) as unknown as Question[]) ?? []; +} + +export async function addRecent(site: Site, q: Question) { + const list = (await recent(site)).filter((r) => r.id !== q.id); + await storage.set(`recent:${site.id}`, [q, ...list].slice(0, 15) as never).catch(() => {}); +} + +export async function clearRecent(site: Site) { + await storage.delete(`recent:${site.id}`).catch(() => {}); +} diff --git a/extensions/stack-overflow/backslash.toml b/extensions/stack-overflow/backslash.toml new file mode 100644 index 0000000..2abbad4 --- /dev/null +++ b/extensions/stack-overflow/backslash.toml @@ -0,0 +1,46 @@ +[extension] +id = "stack-overflow" +title = "Stack Overflow" +description = "Search Stack Overflow, Super User, Ask Ubuntu, Unix & Linux and Server Fault, and read the accepted answer" +version = "1.0.0" +author = "Backslash" +icon = "bs-comment" +license = "MIT" + +# The Stack Exchange API. Searches and answers are cached for a few hours in +# the extension's storage: without a key the API allows 300 requests a day. +[permissions] +net = ["api.stackexchange.com"] + +[[commands]] +name = "search" +title = "Search Stack Overflow" +description = "Questions with their votes, answers and the accepted answer's code" +icon = "bs-comment" +keywords = [ + "stackoverflow", + "stack overflow", + "stack exchange", + "superuser", + "askubuntu", + "serverfault", + "unix", + "question", + "answer", + "programming", + "réponse", + "programmation", + "aide", +] + +[[preferences]] +name = "site" +title = "Default site" +description = "stackoverflow, superuser, askubuntu, unix or serverfault; Tab switches in the command" +default = "stackoverflow" + +[[preferences]] +name = "apiKey" +title = "Stack Apps key" +description = "Optional: raises the daily quota from 300 to 10,000 requests (stackapps.com → Register your app)" +type = "secret" diff --git a/extensions/stack-overflow/commands/search.ts b/extensions/stack-overflow/commands/search.ts new file mode 100644 index 0000000..1fcf05c --- /dev/null +++ b/extensions/stack-overflow/commands/search.ts @@ -0,0 +1,302 @@ +import { + type Action, + action, + clipboard, + close, + type Context, + copy, + detail, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { addRecent, answersOf, clearRecent, quota, recent, searchQuestions } from "../api.ts"; +import { codeBlocks, htmlToMarkdown } from "../html.ts"; +import { + ago, + type Answer, + answerUrl, + codeTitle, + compact, + date, + excerpt, + pickAnswer, + type Question, + scoreColor, + type Site, + siteFor, + SITES, +} from "../lib.ts"; + +const base = (site: Site) => `https://${site.host}`; + +function questionIcon(q: Question) { + if (q.closed) return { source: Icons.canceled, tint: "gray" as const }; + if (q.acceptedAnswerId) return { source: Icons.done, tint: "green" as const }; + if (q.answers) return { source: Icons.comment, tint: "blue" as const }; + return { source: Icons.question, tint: "gray" as const }; +} + +async function bestAnswer(q: Question, site: Site, key?: string): Promise<{ answer?: Answer; all: Answer[] }> { + const all = q.answers ? await answersOf(q, site, key) : []; + return { answer: pickAnswer(all, q.acceptedAnswerId), all }; +} + +function codeActions(answer: Answer | undefined, section?: string): Action[] { + const blocks = answer ? codeBlocks(answer.body) : []; + if (!blocks.length) return [action("No code in the answer", () => {}, { icon: Icons.info, section })]; + return blocks.map((b, i) => + copy(b.code, codeTitle(b.code, i), { + icon: Icons.code, + section, + ...(i === 0 ? { shortcut: "c" } : {}), + }) + ); +} + +async function answerView(q: Question, site: Site, key?: string) { + await addRecent(site, q); + const { answer, all } = await bestAnswer(q, site, key); + const kind = answer?.accepted ? "Accepted Answer" : "Top Answer"; + const markdown = [ + `# ${q.title}`, + "", + q.body ? htmlToMarkdown(q.body, { base: base(site), shift: 2 }) : "", + "", + "---", + "", + answer + ? `## ${kind} · ${answer.score} vote${Math.abs(answer.score) === 1 ? "" : "s"}` + : q.answers + ? "## No answer could be loaded" + : "## No answer yet", + "", + answer ? htmlToMarkdown(answer.body, { base: base(site), shift: 2 }) : "", + ].join("\n"); + const blocks = answer ? codeBlocks(answer.body) : []; + return detail({ + title: q.title, + markdown, + stats: [ + { label: "Votes", value: compact(q.score), color: scoreColor(q.score) }, + { label: "Answers", value: q.answers, color: q.acceptedAnswerId ? "green" : undefined }, + { label: "Views", value: compact(q.views) }, + ], + metadata: [ + meta.tags("Tags", q.tags.map((t) => ({ text: t, color: site.tint }))), + ...(q.owner ? [meta.person("Asked by", q.owner.name)] : []), + meta.text("Asked", date(q.created), { icon: Icons.calendar }), + meta.text("Active", ago(q.activity), { icon: Icons.clock }), + ...(answer + ? [ + meta.separator(), + meta.text(kind, `${answer.score} votes`, { + icon: answer.accepted ? Icons.done : Icons.starFilled, + color: answer.accepted ? "green" : "yellow", + }), + ...(answer.owner ? [meta.person("Answered by", answer.owner.name)] : []), + meta.text("Answered", date(answer.created), { icon: Icons.calendar }), + meta.text("Code blocks", String(blocks.length), { icon: Icons.code }), + ] + : []), + meta.separator(), + meta.link("Question", q.link.replace(/^https:\/\//, "").slice(0, 60), q.link), + ], + status: `${site.title} · ${all.length} answer${all.length === 1 ? "" : "s"} loaded`, + actions: [ + open(q.link, "Open in Browser"), + ...(answer ? [open(answerUrl(site, answer.id), "Open Answer", { shortcut: "o" })] : []), + ...codeActions(answer, "Copy Code"), + ...(answer + ? [copy(htmlToMarkdown(answer.body), "Copy Answer as Markdown", { icon: Icons.copy, section: "Copy" })] + : []), + copy(q.link, "Copy Question Link", { icon: Icons.link, section: "Copy", shortcut: "l" }), + ], + }); +} + +function questionItem(q: Question, site: Site, key: string | undefined, section?: string): Item { + return { + id: `${site.id}:${q.id}`, + title: q.title, + subtitle: q.tags.slice(0, 3).join(" · "), + icon: questionIcon(q), + section, + keywords: q.tags, + accessories: [ + { icon: Icons.arrowUp, text: compact(q.score), color: scoreColor(q.score), tooltip: "Votes" }, + { + icon: q.acceptedAnswerId ? Icons.check : Icons.comment, + text: String(q.answers), + color: q.acceptedAnswerId ? "green" : q.answers ? undefined : "gray", + tooltip: q.acceptedAnswerId ? "Answers, one accepted" : "Answers", + }, + { icon: Icons.eye, text: compact(q.views), tooltip: "Views" }, + ], + detail: { + stats: [ + { label: "Votes", value: compact(q.score), color: scoreColor(q.score) }, + { label: "Answers", value: q.answers, color: q.acceptedAnswerId ? "green" : undefined }, + { label: "Views", value: compact(q.views) }, + ], + markdown: [ + `**${q.title.replace(/\*/g, "")}**`, + "", + q.body ? excerpt(q.body, base(site)) : "", + "", + q.answers ? `*Press Enter to read the ${q.acceptedAnswerId ? "accepted" : "top"} answer.*` : "*No answer yet.*", + ].join("\n"), + metadata: [ + meta.tags("Tags", q.tags.map((t) => ({ text: t, color: site.tint }))), + meta.text( + "Status", + q.closed + ? `Closed: ${q.closed}` + : q.acceptedAnswerId + ? "Accepted answer" + : q.answered + ? "Answered" + : "Unanswered", + { + color: q.closed ? "gray" : q.acceptedAnswerId ? "green" : q.answered ? "blue" : "orange", + }, + ), + ...(q.owner + ? [ + meta.person("Asked by", q.owner.name), + ...(q.owner.reputation !== undefined + ? [meta.text("Reputation", compact(q.owner.reputation), { icon: Icons.star })] + : []), + ] + : []), + meta.text("Asked", `${date(q.created)} · ${ago(q.created)}`, { icon: Icons.calendar }), + meta.text("Active", ago(q.activity), { icon: Icons.clock }), + meta.separator(), + meta.link(site.title, `${site.host}/q/${q.id}`, q.link), + ], + }, + actions: [ + action(q.answers ? "Show Answer" : "Show Question", () => answerView(q, site, key), { icon: Icons.book }), + open(q.link, "Open in Browser"), + submenu("Copy Code from Answer…", async () => { + const { answer } = await bestAnswer(q, site, key); + return codeActions(answer); + }, { icon: Icons.code, shortcut: "c" }), + copy(q.link, "Copy Link", { icon: Icons.link, section: "Copy", shortcut: "l" }), + copy(`[${q.title.replace(/[[\]]/g, "")}](${q.link})`, "Copy as Markdown Link", { + icon: Icons.link, + section: "Copy", + }), + copy(q.title, "Copy Title", { icon: Icons.text, section: "Copy" }), + action("Copy Answer as Markdown", async () => { + const { answer } = await bestAnswer(q, site, key); + if (!answer) throw new Error("This question has no answer yet"); + await clipboard.write(htmlToMarkdown(answer.body, { base: base(site) })); + return close("Answer copied"); + }, { icon: Icons.copy, section: "Copy" }), + ], + }; +} + +function quotaText(left?: { remaining: number; max: number }) { + return left ? ` · ${left.remaining.toLocaleString("en")}/${left.max.toLocaleString("en")} requests left today` : ""; +} + +let last = ""; + +export default (ctx: Context) => { + const preferred = siteFor(ctx.preferences.site); + const key = String(ctx.preferences.apiKey ?? "").trim() || undefined; + return list({ + placeholder: "Search questions… ([tag] to filter, is:accepted)", + filter: { options: SITES.map((s) => ({ value: s.id, title: s.title })), default: preferred.id }, + empty: { + title: "No question found", + description: "Try other words, fewer tags, or another site with Tab.", + icon: Icons.search, + }, + status: () => last, + async search(query, { filter, signal }) { + const site = SITES.find((s) => s.id === filter) ?? preferred; + if (query.trim().length < 2) { + const seen = await recent(site); + last = `${site.title}${quotaText(await quota())}`; + const hint: Item = { + id: "hint", + title: `Search ${site.title}`, + subtitle: "Type a few words, add [tags] to narrow down", + icon: { source: Icons.search, tint: site.tint }, + section: "Tips", + detail: { + markdown: [ + `**Search ${site.title}**`, + "", + "- `[rust] borrow checker` — questions tagged *rust*", + "- `is:accepted tar extract` — only questions with an accepted answer", + "- Tab switches between Stack Overflow, Super User, Ask Ubuntu, Unix & Linux and Server Fault.", + "", + "Results are cached for an hour to spare the daily quota of the Stack Exchange API.", + ].join("\n"), + }, + actions: [open(`https://${site.host}/questions`, `Open ${site.title}`)], + }; + return [ + hint, + ...seen.map((q) => ({ + ...questionItem(q, site, key, "Recently Viewed"), + actions: [ + ...questionItem(q, site, key).actions!, + action("Clear Recently Viewed", async () => { + await clearRecent(site); + return refresh(); + }, { icon: Icons.trash, destructive: true, section: "Danger" }), + ], + })), + ]; + } + let page; + try { + page = await searchQuestions(query, site, key, signal); + } catch (e) { + if (signal.aborted) throw e; + last = `${site.title} · error`; + return [{ + id: "error", + title: e instanceof Error ? e.message : String(e), + subtitle: `Search on ${site.host} instead`, + icon: { source: Icons.alert, tint: "red" }, + detail: { + markdown: key ? "" : "Without an API key, Stack Exchange allows 300 requests a day per IP address. " + + "A free key from stackapps.com raises it to 10,000: add it in the extension's preferences.", + }, + actions: [ + open(`https://${site.host}/search?q=${encodeURIComponent(query)}`, `Search on ${site.title}`), + open("https://stackapps.com/apps/oauth/register", "Register a Stack Apps Key", { icon: Icons.key }), + ], + }]; + } + const left = await quota(); + last = `${page.items.length} question${page.items.length === 1 ? "" : "s"} on ${site.title}${ + page.cached ? " · cached" : "" + }${quotaText(left)}`; + if (left) { + updateCommandMetadata({ subtitle: left.remaining < 50 ? `${left.remaining} API requests left today` : null }) + .catch(() => {}); + } + const items = page.items.map((q) => questionItem(q, site, key)); + items.push({ + id: "web", + title: `Search “${query.trim()}” on ${site.title}`, + icon: { source: Icons.globe, tint: "gray" }, + section: items.length ? "More" : undefined, + actions: [open(`https://${site.host}/search?q=${encodeURIComponent(query.trim())}`, "Open in Browser")], + }); + return items; + }, + }); +}; diff --git a/extensions/stack-overflow/html.ts b/extensions/stack-overflow/html.ts new file mode 100644 index 0000000..28b3e19 --- /dev/null +++ b/extensions/stack-overflow/html.ts @@ -0,0 +1,296 @@ +// A small HTML → Markdown converter for the launcher's markdown subset (headings, +// paragraphs, **bold**, *italic*, `code`, fenced code, [links](url), lists, +// > quotes, ---). Enough for Stack Exchange posts and NixOS option docs. + +export interface Node { + tag: string; + attrs: Record; + children: (Node | string)[]; +} + +const VOID = new Set(["br", "hr", "img", "input", "meta", "link", "wbr", "source", "col", "area"]); +/** Closing one of these closes an open

    or

  • left unclosed (HTML's optional end tags). */ +const AUTO_CLOSE: Record = { p: ["p"], li: ["li"], dt: ["dt", "dd"], dd: ["dt", "dd"], tr: ["tr"] }; + +const ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + hellip: "…", + mdash: "—", + ndash: "–", + laquo: "«", + raquo: "»", + ldquo: "“", + rdquo: "”", + lsquo: "‘", + rsquo: "’", + copy: "©", + reg: "®", + trade: "™", + times: "×", + rarr: "→", + larr: "←", + bull: "•", + middot: "·", + deg: "°", +}; + +/** `&`, `'`, `'` → characters. */ +export function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all; + } + return ENTITIES[e.toLowerCase()] ?? all; + }); +} + +function parseAttrs(text: string): Record { + const attrs: Record = {}; + for (const m of text.matchAll(/([a-zA-Z_:][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) { + attrs[m[1].toLowerCase()] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? ""); + } + return attrs; +} + +/** A forgiving parser: unknown or unbalanced tags never throw. */ +export function parse(html: string): Node { + const root: Node = { tag: "#root", attrs: {}, children: [] }; + const stack: Node[] = [root]; + const top = () => stack[stack.length - 1]; + const re = /|<(\/?)([a-zA-Z][\w-]*)((?:\s+[^>]*?)?)\s*(\/?)>/g; + let last = 0; + for (const m of html.matchAll(re)) { + if (m.index! > last) top().children.push(html.slice(last, m.index)); + last = m.index! + m[0].length; + if (m[0].startsWith("